feat(engagement): the rules engine, cooldowns and outbox (engagement Phase 4a)
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>
This commit is contained in:
241
server/src/engagement/engine.js
Normal file
241
server/src/engagement/engine.js
Normal file
@@ -0,0 +1,241 @@
|
||||
// ── The engagement engine ──────────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 4a. `ctx.events.emit` validated a payload against a
|
||||
// declaration and stopped (Phase 2); this is what it now hands the validated
|
||||
// event to. The engine's whole job is to answer, for one event, **who gets told,
|
||||
// on what, and not too often** - and then to write that down as outbox rows.
|
||||
// It never delivers: `engagementWorker` drains the outbox, and what actually
|
||||
// carries a message arrives with the channels' `deliver` in Phases 6 and 7.
|
||||
//
|
||||
// **The order of the gates is the design, and each one is here because skipping
|
||||
// it is a way to mail the wrong people or too many of them:**
|
||||
//
|
||||
// 1. enabled rules for this trigger - nothing is seeded, nothing is on by default
|
||||
// 2. conditions - is this particular firing interesting
|
||||
// 3. audience -> user ids - core's tables, or a composed segment
|
||||
// 4. ceiling re-check (G24) - re-run at SEND time, not only at save
|
||||
// 5. per-channel preference - a user's own opt-in, effective mode
|
||||
// 6. per-rule hourly ceiling (§7.1 Q3) - the hard stop that makes rules-as-data safe
|
||||
// 7. cooldown, per (rule, user, subject) - one statement, so two emits cannot race
|
||||
// 8. enqueue, deduped - a replayed event is one row, not two
|
||||
//
|
||||
// Steps 6 and 7 are in that order deliberately. The hourly ceiling is about the
|
||||
// RULE and is the thing that stops a mail storm; the cooldown is about one
|
||||
// recipient and one subject. Checking the cheap global bound before consuming a
|
||||
// per-recipient cooldown slot means a rule that has hit its ceiling does not also
|
||||
// silently burn everybody's cooldowns on sends that never happen.
|
||||
//
|
||||
// **Nothing here throws at its caller.** It is invoked from inside a game-event
|
||||
// handler by way of `ctx.events.emit`, and a database problem of core's must not
|
||||
// become a module's control flow (the same posture the emit validator takes).
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const outboxDb = require('../model/engagement/engagementOutbox.db')
|
||||
const cooldownsDb = require('../model/engagement/engagementCooldowns.db')
|
||||
const sendsDb = require('../model/engagement/engagementSends.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const conditions = require('./conditions')
|
||||
const audiences = require('./audiences')
|
||||
const channels = require('./channels')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Which of a rule's channels are actually deliverable right now?
|
||||
*
|
||||
* A rule stores channel ids as data (`channels JSON`), so it can name one whose
|
||||
* module has been removed since. An unregistered channel is dropped rather than
|
||||
* failing the rule: the other channels of that rule are still correct, and a
|
||||
* dropped one is visible in the log line below.
|
||||
*/
|
||||
const liveChannels = (rule) => (rule.channels || []).filter((c) => channels.has(c))
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to the users whose EFFECTIVE mode for (id, channel) is
|
||||
* not 'off'.
|
||||
*
|
||||
* Effective, not stored: a row exists only where a user has expressed something,
|
||||
* and absence means the channel's `defaultMode` (§3.1). Reading the stored rows
|
||||
* and applying the default here keeps that answer in the registry, which is the
|
||||
* invariant Phase 3 established.
|
||||
*
|
||||
* A 'digest' preference is kept, not dropped. Digest delivery is Phase 6's, and
|
||||
* an outbox row for it is still the right record of "this person should be told";
|
||||
* what changes in Phase 6 is who drains it.
|
||||
*/
|
||||
async function subscribedTo(userIds, streamId, channel) {
|
||||
if (!userIds.length) return []
|
||||
const stored = await recipients.storedModes(userIds, streamId, channel)
|
||||
const fallback = channels.defaultMode(channel)
|
||||
return userIds.filter((id) => (stored.get(id) ?? fallback) !== 'off')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one rule against one event. Returns a small summary, for the log line and
|
||||
* for tests; it is not read by the caller for control flow.
|
||||
*/
|
||||
async function applyRule(rule, event, now) {
|
||||
const summary = { ruleId: rule.id, enqueued: 0, deduped: 0, cooled: 0, capped: 0, skipped: null }
|
||||
|
||||
if (!conditions.evaluate(rule.conditions, event.data)) {
|
||||
summary.skipped = 'conditions'
|
||||
return summary
|
||||
}
|
||||
|
||||
const resolved = await audiences.resolveForRule(rule, event)
|
||||
if (resolved.dormant) {
|
||||
summary.skipped = resolved.reason || 'dormant'
|
||||
return summary
|
||||
}
|
||||
if (!resolved.userIds.length) {
|
||||
summary.skipped = resolved.reason || 'empty audience'
|
||||
return summary
|
||||
}
|
||||
|
||||
// G24, re-run at send time. A rule saved when its trigger permitted a wider
|
||||
// audience must not keep reaching it after a module upgrade narrowed the
|
||||
// declaration - and that is the only way this can fail, since the save path
|
||||
// ran the same check.
|
||||
if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
|
||||
log.warn('rule audience exceeds its trigger ceiling - refusing', {
|
||||
rule: rule.id,
|
||||
trigger: event.triggerId,
|
||||
audience: resolved.ceiling,
|
||||
})
|
||||
summary.skipped = 'ceiling'
|
||||
return summary
|
||||
}
|
||||
|
||||
const live = liveChannels(rule)
|
||||
if (!live.length) {
|
||||
summary.skipped = 'no registered channel'
|
||||
return summary
|
||||
}
|
||||
|
||||
// The per-rule hourly ceiling (§7.1 Q3). Counted once for the whole event
|
||||
// rather than per channel: an operator setting "100 an hour" means a hundred
|
||||
// messages, not a hundred per channel per event.
|
||||
const sentThisHour = await sendsDb.countSentSince(rule.id, new Date(now.getTime() - HOUR_MS))
|
||||
let budget = Math.max(0, rule.max_sends_per_hour - sentThisHour)
|
||||
if (budget === 0) {
|
||||
log.warn('rule is at its hourly send ceiling', {
|
||||
rule: rule.id,
|
||||
trigger: event.triggerId,
|
||||
ceiling: rule.max_sends_per_hour,
|
||||
})
|
||||
summary.skipped = 'hourly ceiling'
|
||||
return summary
|
||||
}
|
||||
|
||||
const subjectKey = (event.subject ?? '').toString().slice(0, 190)
|
||||
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
|
||||
|
||||
for (const channel of live) {
|
||||
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel)
|
||||
for (const userId of eligible) {
|
||||
if (budget <= 0) {
|
||||
summary.capped += 1
|
||||
continue
|
||||
}
|
||||
// One statement, guarded on the interval, so two concurrent emits cannot
|
||||
// both pass a read-then-write check (§4.1).
|
||||
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
|
||||
if (!allowed) {
|
||||
summary.cooled += 1
|
||||
continue
|
||||
}
|
||||
const id = await outboxDb.enqueue({
|
||||
rule_id: rule.id,
|
||||
trigger_id: event.triggerId,
|
||||
user_id: userId,
|
||||
channel,
|
||||
subject_key: subjectKey,
|
||||
payload: event.data,
|
||||
// Scoped per (rule, user, channel) by the unique index, so one event
|
||||
// fanned out to fifty people is fifty rows carrying the same key.
|
||||
dedupe_key: event.dedupeKey,
|
||||
due_at: dueAt,
|
||||
})
|
||||
if (id === null) summary.deduped += 1
|
||||
else {
|
||||
summary.enqueued += 1
|
||||
budget -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending rows that this event resolves (§4.2a).
|
||||
*
|
||||
* This is the actual point of `delay_seconds`: without cancellation a delay is
|
||||
* just a late mail. A house repaired back to LikeNew fires a trigger that some
|
||||
* rule names in its `cancel_on`, and every still-scheduled row for that
|
||||
* (rule, subject) stops.
|
||||
*
|
||||
* When the resolving event names an owner, only that user's rows are cancelled;
|
||||
* when it does not, every user queued about that subject is - which is the
|
||||
* house-repaired case, where the event is about the house and not about any one
|
||||
* of the people who were going to be told.
|
||||
*/
|
||||
async function applyCancellations(event, summary) {
|
||||
const rules = await rulesDb.enabledCancelledBy(event.triggerId)
|
||||
if (!rules.length) return
|
||||
const subjectKey = (event.subject ?? '').toString().slice(0, 190)
|
||||
for (const rule of rules) {
|
||||
const n = await outboxDb.cancel(rule.id, subjectKey, event.ownerUserId || null)
|
||||
if (n) {
|
||||
summary.cancelled += n
|
||||
log.info('cancelled scheduled sends', {
|
||||
rule: rule.id,
|
||||
by: event.triggerId,
|
||||
subject: subjectKey,
|
||||
rows: n,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one validated event. Called by `engagementEmit.emit` after the payload
|
||||
* has been checked against the declaration.
|
||||
*
|
||||
* @param {object} event the envelope `engagementEmit` built
|
||||
* @returns {Promise<{ rules: number, enqueued: number, cancelled: number }>}
|
||||
*/
|
||||
async function dispatch(event, now = new Date()) {
|
||||
const summary = { rules: 0, enqueued: 0, deduped: 0, cooled: 0, capped: 0, cancelled: 0 }
|
||||
try {
|
||||
const rules = await rulesDb.enabledForTrigger(event.triggerId)
|
||||
summary.rules = rules.length
|
||||
|
||||
for (const rule of rules) {
|
||||
const result = await applyRule(rule, event, now)
|
||||
summary.enqueued += result.enqueued
|
||||
summary.deduped += result.deduped
|
||||
summary.cooled += result.cooled
|
||||
summary.capped += result.capped
|
||||
}
|
||||
|
||||
await applyCancellations(event, summary)
|
||||
|
||||
// Keys and counts, never values - the same rule the emit log line follows.
|
||||
// A payload carries player names, house locations and forum excerpts, and a
|
||||
// log that reproduces them is a second copy of exactly the content
|
||||
// `engagement_sends` is careful to keep out of the database.
|
||||
if (summary.rules || summary.cancelled) {
|
||||
log.info('event dispatched', { trigger: event.triggerId, ...summary })
|
||||
}
|
||||
} catch (err) {
|
||||
// A database problem of core's must not become the module's control flow at
|
||||
// three in the morning. The emit already succeeded as a contract; what failed
|
||||
// is delivery, and it is logged as core's failure.
|
||||
log.error('dispatch failed', { trigger: event.triggerId, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, liveChannels, HOUR_MS }
|
||||
Reference in New Issue
Block a user