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:
78
server/src/model/engagement/engagementCooldowns.db.js
Normal file
78
server/src/model/engagement/engagementCooldowns.db.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Claim a fire for (rule, user, subject), or refuse it because the pair is still
|
||||
* cooling. ENGAGEMENT.md §4.1.
|
||||
*
|
||||
* **Two statements, each of which is its own atomic decision** - and it is worth
|
||||
* saying why it is not the single `INSERT ... ON DUPLICATE KEY UPDATE` §4.1
|
||||
* describes, because that version was written, tested green against an in-memory
|
||||
* stub, and disproved by the first run against a real MariaDB.
|
||||
*
|
||||
* The one-statement form reads its answer out of `affectedRows`, on the usual
|
||||
* contract: 1 for an insert, 2 for an update that changed something, and 0 for a
|
||||
* duplicate key whose update changed nothing - that 0 being "the guard failed, so
|
||||
* this pair is still cooling". **The mariadb Node connector sets `foundRows: true`
|
||||
* by default**, which makes `affectedRows` report rows MATCHED rather than rows
|
||||
* CHANGED, and `utils/db.js` does not override it. Under that pool the no-op case
|
||||
* returns 1, indistinguishable from a fresh insert: every cooldown would have
|
||||
* passed, always, and nothing in a stubbed test could have noticed.
|
||||
*
|
||||
* So the guard moves into a WHERE clause, where a row either matches or does not
|
||||
* and `foundRows` has nothing to fold together:
|
||||
*
|
||||
* 1. UPDATE the row, guarded on the interval. `affectedRows = 1` means this
|
||||
* caller moved it and owns the fire.
|
||||
* 2. If that matched nothing, the row either does not exist yet or is still
|
||||
* cooling. `INSERT IGNORE` separates the two: 1 means we inserted the first
|
||||
* fire, 0 means the row was there and step 1 already said it is cooling.
|
||||
*
|
||||
* It is still race-free, and each race resolves the right way:
|
||||
* - two concurrent first fires: neither UPDATEs, both INSERT IGNORE, exactly
|
||||
* one gets 1 (the primary key decides). The loser is treated as cooling.
|
||||
* - two concurrent fires after expiry: the row is locked by the first UPDATE,
|
||||
* and the second re-evaluates its guard against the committed row - which now
|
||||
* holds `now`, so it fails and is refused.
|
||||
*
|
||||
* `cooldown_seconds = 0` always passes, which is the documented meaning of a rule
|
||||
* with no cooldown: the guard becomes `last_fired_at <= now`, and it is.
|
||||
*/
|
||||
async function claim(ruleId, userId, subjectKey, cooldownSeconds, now = new Date()) {
|
||||
const moved = await query(
|
||||
`UPDATE engagement_cooldowns
|
||||
SET last_fired_at = ?, fire_count = fire_count + 1
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ?
|
||||
AND last_fired_at <= ? - INTERVAL ? SECOND`,
|
||||
[now, ruleId, userId, subjectKey, now, cooldownSeconds],
|
||||
)
|
||||
if (Number(moved?.affectedRows || 0) === 1) return true
|
||||
|
||||
const inserted = await query(
|
||||
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count)
|
||||
VALUES (?, ?, ?, ?, 1)`,
|
||||
[ruleId, userId, subjectKey, now],
|
||||
)
|
||||
return Number(inserted?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
const get = async (ruleId, userId, subjectKey) => {
|
||||
const [row] = await query(
|
||||
'SELECT * FROM engagement_cooldowns WHERE rule_id = ? AND user_id = ? AND subject_key = ?',
|
||||
[ruleId, userId, subjectKey],
|
||||
)
|
||||
return row || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop cooldown rows older than `olderThan`.
|
||||
*
|
||||
* `idx_engc_sweep (last_fired_at)` exists for this: the table is written on every
|
||||
* fire and read once per fire, so without a prune it is the unbounded-growth
|
||||
* failure `teamActivityPrune` was written for. A dropped row means the next fire
|
||||
* is treated as a first fire, which is correct as long as the retention window is
|
||||
* longer than the longest configured cooldown - the caller's job, not this one's.
|
||||
*/
|
||||
const prune = (olderThan) =>
|
||||
query('DELETE FROM engagement_cooldowns WHERE last_fired_at < ?', [olderThan])
|
||||
|
||||
module.exports = { claim, get, prune }
|
||||
Reference in New Issue
Block a user