Phase 4 of docs/website/ENGAGEMENT.md, split 4a/4b at the org lead's direction. This is 4a: the engine, server only, with no HTTP surface at all. A fired trigger now produces outbox rows and send-log entries; Admin - Engagement - Rules and the segment composition UI are 4b. Five tables (rules, audience segments, cooldowns, outbox, sends), the sweep worker, audience resolution, condition evaluation, the grace window and its cancellation, and the save-path validation 4b's form will call. engagementEmit's Phase 2 log line becomes the engine call. Two settled questions this phase was blocked on: Q2 (multi-instance) - neither SKIP LOCKED nor documented single-instance: the outbox claims each row with a compare-and-set into the 'sending' state the ENUM already carried. It makes the outbox safe for two instances, not the deployment. Q4 (admin surface) - its own top-level nav group, built in 4b. Two defects in the plan's own section 4, both found by building it: The global UNIQUE(dedupe_key) was data loss. A dedupe key names the EVENT, and one event is one row per (rule, user, channel) - so a fifty-person audience would have had one row admitted and forty-nine silently ignored. Scoped. Section 4.1's single INSERT ... ON DUPLICATE KEY UPDATE cooldown claim always passes against this codebase's pool: the mariadb connector defaults foundRows:true, so a no-op update reports affectedRows 1 rather than 0. It is two statements now, with the interval guard in a WHERE clause. The second defect is why there is a second test file. The stubbed suite was green against the broken claim, because a stub can only agree with whoever wrote it; engagementEngineSql.test.js runs the raw statements against a real MariaDB and skips when there is none. Verification: 43 new tests green in engagementEngine.test.js, 12 more against MariaDB 11.8, and the whole path exercised end to end against a live database - per-subject cooldowns, conditions, the CAS claim, the send log's honest failure detail, and dormancy on uninstall. The three pre-existing Windows-only CRLF failures in the generated-artifact tests are unchanged from clean edge. Co-Authored-By: Claude <noreply@anthropic.com>
169 lines
7.0 KiB
JavaScript
169 lines
7.0 KiB
JavaScript
// ── 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 }
|