// ── Engagement outbox worker ─────────────────────────────────────────────── // // ENGAGEMENT.md §4.2a, Phase 4a. Every ENGAGEMENT_POLL_MS it sweeps // `engagement_outbox` for rows whose `due_at` has passed, claims each one, hands // it to its channel, and records the outcome in `engagement_sends`. Same // setInterval + unref + stop() shape as `announceWorker` and the three Team // sweepers, wired into server.js start/shutdown beside them. // // **Claiming is a compare-and-set, not a lock** (§7.1 Q2, settled by the org lead // 2026-08-29 over `SELECT ... FOR UPDATE SKIP LOCKED`): an // `UPDATE ... SET status='sending' WHERE id=? AND status='scheduled'`, and the // instance the server reports `affectedRows = 1` to owns the row. No transaction // to hold open, no MariaDB version floor, and it uses a status the ENUM already // carried for exactly this. What it makes safe is the outbox; the four existing // workers are still single-instance, so this does not by itself make the // deployment multi-instance. // // **Nothing is delivered in this phase, and that is visible rather than // pretended.** A channel's `deliver` arrives with email in Phase 6 and the in-app // inbox in Phase 7; until then `channels.get(id)` has no such function, the row // finishes as `failed` and the send log says why in as many words. The // alternatives were both worse: recording 'sent' would be a lie in the one table // whose whole purpose is answering "did they get it", and leaving the row // scheduled would mean an IDOC warning queued today arriving three weeks later // on the deploy that first shipped a mailer. // // In practice this path is unreachable on a real deployment for now: core seeds // no rules and `enabled` defaults to 0, so the outbox stays empty until an // operator turns a rule on from the screen Phase 4b builds. const outboxDb = require('../model/engagement/engagementOutbox.db') const sendsDb = require('../model/engagement/engagementSends.db') const channels = require('../engagement/channels') const log = require('./logger')('engagement-worker') const POLL_MS = Number(process.env.ENGAGEMENT_POLL_MS) || 30_000 // How many rows one sweep will look at. A bound rather than a target: the sweep // runs again in POLL_MS, and an unbounded batch is how a backlog turns one tick // into a stall. const BATCH = Number(process.env.ENGAGEMENT_BATCH) || 100 // A transient failure is retried with a flat backoff, and then given up on. // Flat rather than exponential because `due_at` is also the grace window's clock // and a doubling backoff would push a delayed message arbitrarily far past the // moment it was about. const MAX_ATTEMPTS = 5 const RETRY_MS = 5 * 60 * 1000 // A row claimed into 'sending' by a process that then died is invisible to every // other sweeper - `status='scheduled'` will never match it again. This window is // how long a claim may look alive before it is taken back; it has to be // comfortably longer than the slowest legitimate send or a slow one gets sent // twice. const STALE_MS = 15 * 60 * 1000 /** * Deliver one claimed row. * * @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string }} */ async function deliver(row) { const channel = channels.get(row.channel) if (!channel) { // The channel's module was removed between enqueue and now. Terminal: there // is nothing to retry towards, and leaving the row scheduled would make it // sweep forever. return { outcome: 'terminal', detail: `channel "${row.channel}" is no longer registered` } } if (typeof channel.deliver !== 'function') { return { outcome: 'terminal', detail: `channel "${row.channel}" has no delivery implementation yet` } } try { const result = await channel.deliver(row) if (result && result.ok) return { outcome: 'sent', transport: result.transport, detail: result.detail } if (result && result.retry) return { outcome: 'retry', detail: result.detail || 'transient failure' } return { outcome: 'terminal', detail: (result && result.detail) || 'delivery refused' } } catch (err) { // A channel shouldn't throw, but if one does it is a transient failure // rather than a crashed tick - announceWorker's posture with its legs. log.error('channel deliver threw', { outbox: row.id, channel: row.channel, message: err.message }) return { outcome: 'retry', detail: err.message } } } /** * Claim, deliver, record. One row, start to finish. * * `deliverFn` is injectable so a test can drive the retry/give-up path without a * channel that fails on demand - the alternative is registering a fake channel, * which would make the registry, not this function, the thing under test. */ async function processRow(row, now = new Date(), deliverFn = deliver) { if (!(await outboxDb.claim(row.id))) return null // another sweeper got there first const result = await deliverFn(row) if (result.outcome === 'retry' && row.attempts + 1 < MAX_ATTEMPTS) { await outboxDb.reschedule(row.id, new Date(now.getTime() + RETRY_MS), result.detail) return 'retry' } const status = result.outcome === 'sent' ? 'sent' : 'failed' await outboxDb.finish(row.id, status, status === 'failed' ? result.detail : null) // The send log is written for every terminal outcome, not only success. G15's // question is "did user X get the mail?", and "no, and here is why" is an // answer that table has to be able to give. await sendsDb.record({ outbox_id: row.id, rule_id: row.rule_id, trigger_id: row.trigger_id, user_id: row.user_id, channel: row.channel, transport: result.transport ?? null, status, detail: result.detail ?? null, }) return status } async function tick(now = new Date()) { try { await outboxDb.reclaimStale(new Date(now.getTime() - STALE_MS)) } catch (err) { log.error('failed to reclaim stale rows', { message: err.message }) } let due try { due = await outboxDb.findDue(now, BATCH) } catch (err) { log.error('failed to load due rows', { message: err.message }) return } if (!due || !due.length) return const counts = { sent: 0, failed: 0, retry: 0, taken: 0 } for (const row of due) { try { const outcome = await processRow(row, now) if (outcome === null) counts.taken += 1 else counts[outcome] += 1 } catch (err) { log.error('row failed', { outbox: row.id, message: err.message }) } } log.info('outbox swept', counts) } let timer = null function start() { if (timer) return timer timer = setInterval(() => { tick().catch((err) => log.error('engagement tick failed', { message: err.message })) }, POLL_MS) if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown) log.info('engagement outbox worker started', { pollMs: POLL_MS, batch: BATCH }) return timer } function stop() { if (timer) { clearInterval(timer) timer = null } } module.exports = { start, stop, tick, processRow, deliver, POLL_MS, MAX_ATTEMPTS, RETRY_MS, STALE_MS }