feat(engagement): retention — three sweeps and one recorded refusal
ENGAGEMENT.md Phase 14, the last phase of the workstream. Four engagement
tables grew on every fire and nothing had ever deleted from any of them.
Three of them now have a horizon, swept nightly by one worker
(utils/engagementRetentionPrune.js — setInterval + unref + stop(), batched
1000 x 50, each table's failure caught on its own so a lock timeout on one
does not leave the other two unbounded):
engagement_sends 180 days engagement_sends_retain_days (7-3650)
engagement_cooldowns 30 days engagement_cooldowns_retain_days (2-3650)
engagement_outbox 30 days engagement_outbox_retain_days (2-3650)
The fourth, engagement_suppressions, does not expire, and that is the
recorded decision rather than an omission: a suppression is a standing
decision, and ageing out a hard bounce re-mails an address that already
bounced. The way out stays deliberate, and is now reachable per row.
Six decisions were settled by the org lead before any code. Two of them
widened the phase past what was offered:
* the send-log horizon is admin-configurable, so retention got a SCREEN
(Admin -> Engagement -> Retention) where team_activity and
user_notifications keep theirs in invisible settings rows. The send-log
horizon changes what an operator-facing page is able to show, so it has
to be visible; the other two came with it, because "what does this
deployment keep" is one question.
* the suppression purge, which cost a Phase 9 decision. The list
deliberately stripped address_hash from every row, so the only way out
was a window.prompt asking the operator to retype an address the screen
has never shown them. The row had no handle at all. The hash is now
returned: this route is admin-only and an admin can already suppress and
unsuppress any address they can name, so it grants no capability they
lack. GET /sends still strips its own.
The outbox sweep is TERMINAL-ONLY and that is a correctness rule: a
scheduled row is a send this deployment still intends to make (delay_seconds
can put one a day out) and a sending row may be mid-flight.
One shipped defect had to be fixed for the sweep to be a bound at all.
reclaimStale returned every stale sending row to scheduled, and MAX_ATTEMPTS
is consulted only on a graceful retry outcome — so a send that killed the
process mid-flight cycled sending -> scheduled -> sending forever, never
terminal, therefore never eligible for any sweep. It now fails an exhausted
row BEFORE reclaiming the rest; the order is the fix.
Two indexes (idx_engo_sweep, idx_engs_sweep): every existing index on those
tables has created_at in second position, which serves a per-rule window and
is useless to a whole-table horizon.
Proved twice: engagementRetentionSql.test.js against a real MariaDB (7
tests, incl. the acceptance case and the wrong reclaim order run
deliberately), and the live stack, where a 90-day-old cancelled row was
swept and a 90-day-old scheduled row survived.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -78,8 +78,23 @@ const get = async (ruleId, userId, subjectKey, channel) => {
|
||||
* 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.
|
||||
* Phase 14 gave it that caller (`engagementRetentionPrune`), which also enforces
|
||||
* the horizon-versus-longest-cooldown rule this comment names.
|
||||
*
|
||||
* `limit` bounds one statement, for `userNotificationsPrune`'s reason: a first
|
||||
* sweep after a long outage must be a series of bounded DELETEs rather than one
|
||||
* that holds locks over a million rows. Omitting it keeps the original
|
||||
* unbounded behaviour, so existing callers and tests are unaffected.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const prune = (olderThan) =>
|
||||
query('DELETE FROM engagement_cooldowns WHERE last_fired_at < ?', [olderThan])
|
||||
const prune = async (olderThan, limit = 0) => {
|
||||
const bounded = Number(limit) > 0
|
||||
const result = await query(
|
||||
`DELETE FROM engagement_cooldowns WHERE last_fired_at < ?${bounded ? ' LIMIT ?' : ''}`,
|
||||
bounded ? [olderThan, Math.floor(limit)] : [olderThan],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { claim, get, prune }
|
||||
|
||||
@@ -132,11 +132,61 @@ async function cancel(ruleId, subjectKey, userId = null) {
|
||||
* stamped it), and the window has to be comfortably longer than the slowest
|
||||
* legitimate send or this reclaims rows that are merely slow.
|
||||
*/
|
||||
const reclaimStale = (before) =>
|
||||
query(
|
||||
const reclaimStale = async (before, maxAttempts = 0) => {
|
||||
// Give up first, reclaim second, and in that order: a row that has already
|
||||
// burned its attempts must leave 'sending' as `failed`, or the reclaim below
|
||||
// hands it straight back to `findDue` and it is retried forever.
|
||||
//
|
||||
// **This is what makes `pruneTerminal` a bound at all** (Phase 14). Attempts
|
||||
// are incremented by `claim`, but `MAX_ATTEMPTS` is only consulted on a
|
||||
// graceful `retry` outcome — a send that kills the process mid-flight never
|
||||
// reaches that branch, so before this the row cycled sending → scheduled →
|
||||
// sending forever, never reached a terminal status, and was therefore never
|
||||
// eligible for any retention sweep. One poisoned payload was an outbox row
|
||||
// that outlived every horizon.
|
||||
let failed = 0
|
||||
if (Number(maxAttempts) > 0) {
|
||||
const gaveUp = await query(
|
||||
`UPDATE engagement_outbox
|
||||
SET status = 'failed', last_error = 'gave up after repeated interruptions'
|
||||
WHERE status = 'sending' AND updated_at < ? AND attempts >= ?`,
|
||||
[before, Math.floor(maxAttempts)],
|
||||
)
|
||||
failed = Number(gaveUp?.affectedRows || 0)
|
||||
}
|
||||
const reclaimed = await query(
|
||||
"UPDATE engagement_outbox SET status = 'scheduled' WHERE status = 'sending' AND updated_at < ?",
|
||||
[before],
|
||||
)
|
||||
return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete terminal rows older than `before` (Phase 14).
|
||||
*
|
||||
* **Terminal only, and the status list is the whole policy.** A `scheduled` row
|
||||
* is a promise the engine has not kept yet — `delay_seconds` can legitimately
|
||||
* put one up to a day out (`MAX_DELAY_SECONDS`) — and a `sending` row may be a
|
||||
* worker mid-flight. Deleting either is not retention, it is cancelling a send
|
||||
* nobody asked to cancel. Only `sent`, `failed`, `cancelled` and `suppressed`
|
||||
* are outcomes that have already happened.
|
||||
*
|
||||
* `created_at` rather than `updated_at` is the clock deliberately: the horizon
|
||||
* an operator sets means "how long we keep the record of a delivery", which is
|
||||
* measured from when it was enqueued, not from whenever it was last touched.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const pruneTerminal = async (before, limit = 1000) => {
|
||||
const result = await query(
|
||||
`DELETE FROM engagement_outbox
|
||||
WHERE status IN ('sent', 'failed', 'cancelled', 'suppressed')
|
||||
AND created_at < ?
|
||||
LIMIT ?`,
|
||||
[before, Math.floor(limit)],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_outbox WHERE id = ?', [id])
|
||||
@@ -157,6 +207,7 @@ module.exports = {
|
||||
finish,
|
||||
cancel,
|
||||
reclaimStale,
|
||||
pruneTerminal,
|
||||
getById,
|
||||
listForRule,
|
||||
}
|
||||
|
||||
175
server/src/model/engagement/engagementRetention.model.js
Normal file
175
server/src/model/engagement/engagementRetention.model.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// ── Engagement retention policy ────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 14. Three horizons, one place to read and write them, so
|
||||
// the nightly worker and Admin → Engagement → Retention cannot disagree about
|
||||
// what this deployment keeps — and so `/privacy` has one thing to describe.
|
||||
//
|
||||
// **The fourth engagement table is deliberately absent from this file.**
|
||||
// `engagement_suppressions` does not expire (org lead, 2026-09-01): a
|
||||
// suppression is a standing decision, and ageing out a hard bounce re-mails an
|
||||
// address that already bounced, which is how a sender loses a domain's
|
||||
// reputation. The one way out of that table stays what Phase 9 built — a
|
||||
// deliberate act by an admin, which Phase 14 only made reachable per row.
|
||||
//
|
||||
// Settings rows rather than env, for `teamActivityPrune`'s reason: an operator
|
||||
// tightening a busy shard should not need a deploy. Unlike the two workers this
|
||||
// copies, these three get a screen — the send log's horizon changes what an
|
||||
// operator-facing page can show, so it cannot be an invisible key.
|
||||
|
||||
const settings = require('../settings/settings.model')
|
||||
const rulesDb = require('./engagementRules.db')
|
||||
const log = require('../../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* One entry per horizon. `min` is not a UI nicety — each is the point below
|
||||
* which the sweep breaks something that is not retention:
|
||||
*
|
||||
* - **cooldowns**: a pruned row makes the next fire a FIRST fire, i.e. a
|
||||
* duplicate send. `MAX_COOLDOWN_SECONDS` is a validated 86 400 (one day), so
|
||||
* 2 days is the smallest provably-safe value against any rule that can be
|
||||
* saved. `checkCooldownHorizon` re-checks it against the rules that exist.
|
||||
* - **outbox**: `MAX_DELAY_SECONDS` is also 86 400, so no `scheduled` row is
|
||||
* ever more than a day out; terminal rows younger than that are still the
|
||||
* most recent thing an operator would look at.
|
||||
* - **sends**: the per-rule hourly ceiling (§7.1 Q3) counts this table, so a
|
||||
* horizon under an hour would silently disable it. The floor is set far
|
||||
* above that, at the point the Send Log stops being worth opening.
|
||||
*/
|
||||
const HORIZONS = {
|
||||
sends: {
|
||||
key: 'engagement_sends_retain_days',
|
||||
default: 180,
|
||||
min: 7,
|
||||
max: 3650,
|
||||
label: 'Send log',
|
||||
},
|
||||
cooldowns: {
|
||||
key: 'engagement_cooldowns_retain_days',
|
||||
default: 30,
|
||||
min: 2,
|
||||
max: 3650,
|
||||
label: 'Cooldowns',
|
||||
},
|
||||
outbox: {
|
||||
key: 'engagement_outbox_retain_days',
|
||||
default: 30,
|
||||
min: 2,
|
||||
max: 3650,
|
||||
label: 'Outbox',
|
||||
},
|
||||
}
|
||||
|
||||
const NAMES = Object.keys(HORIZONS)
|
||||
|
||||
/**
|
||||
* The current policy, as `{ sends, cooldowns, outbox }` in days.
|
||||
*
|
||||
* Wrapped in a try like `teamActivity.retentionConfig` and for its reason: the
|
||||
* nightly worker calls this with nobody watching, so a settings table that is
|
||||
* briefly unavailable must yield defaults rather than an exception that kills
|
||||
* the job. An unreadable, absent, non-numeric or out-of-range value all mean
|
||||
* the same thing — use the default — because none of them is a horizon.
|
||||
*/
|
||||
async function get() {
|
||||
const out = {}
|
||||
for (const name of NAMES) {
|
||||
const spec = HORIZONS[name]
|
||||
let days = spec.default
|
||||
try {
|
||||
const raw = await settings.get(spec.key)
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n) && n >= spec.min && n <= spec.max) days = Math.floor(n)
|
||||
} catch (err) {
|
||||
log.debug('retention setting unreadable; using the default', {
|
||||
key: spec.key,
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
out[name] = days
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one or more horizons. Unknown names are ignored rather than rejected, so
|
||||
* a client sending the whole object back is not coupled to this list; an
|
||||
* out-of-range value IS rejected, because silently clamping a number an operator
|
||||
* typed would leave the screen showing something the deployment is not doing.
|
||||
*
|
||||
* @returns {Promise<object>} the policy as it now stands
|
||||
*/
|
||||
async function set(patch = {}, updatedBy = null) {
|
||||
for (const name of NAMES) {
|
||||
if (!(name in patch) || patch[name] === undefined || patch[name] === null) continue
|
||||
const spec = HORIZONS[name]
|
||||
const n = Number(patch[name])
|
||||
if (!Number.isFinite(n) || Math.floor(n) !== n) {
|
||||
throw Object.assign(new Error(`${spec.label} retention must be a whole number of days`), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
if (n < spec.min || n > spec.max) {
|
||||
throw Object.assign(
|
||||
new Error(`${spec.label} retention must be between ${spec.min} and ${spec.max} days`),
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
await settings.set(spec.key, String(n), updatedBy)
|
||||
}
|
||||
return get()
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest cooldown any ENABLED rule configures, in seconds — see
|
||||
* `engagementRules.db.maxEnabledCooldownSeconds` for why enabled only.
|
||||
*
|
||||
* Swallows its error rather than propagating: this is consulted by a worker
|
||||
* running on a timer, and a database hiccup must degrade the WARNING, never the
|
||||
* sweep. Zero reads as "no enabled rule has a cooldown", which produces no
|
||||
* warning — the same answer as an unreadable table, and the safe one, because
|
||||
* the alternative is a nightly alarm nobody can act on.
|
||||
*/
|
||||
async function longestEnabledCooldownSeconds() {
|
||||
try {
|
||||
return await rulesDb.maxEnabledCooldownSeconds()
|
||||
} catch (err) {
|
||||
log.debug('could not read the longest enabled cooldown', { message: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 14's acceptance line: the cooldown horizon is CHECKED against the
|
||||
* longest enabled rule's cooldown rather than picked.
|
||||
*
|
||||
* It returns a warning rather than throwing, and the worker sweeps anyway. The
|
||||
* alternative — refusing to prune — trades a bounded, describable fault (one
|
||||
* rule may re-fire early once) for the unbounded one this phase exists to end.
|
||||
* The screen surfaces the same warning, which is where an operator can act on it.
|
||||
*
|
||||
* @returns {Promise<{ ok: boolean, longestCooldownSeconds: number, message: string|null }>}
|
||||
*/
|
||||
async function checkCooldownHorizon(days) {
|
||||
const longest = await longestEnabledCooldownSeconds()
|
||||
const horizonSeconds = days * 24 * 60 * 60
|
||||
if (longest > 0 && horizonSeconds <= longest) {
|
||||
return {
|
||||
ok: false,
|
||||
longestCooldownSeconds: longest,
|
||||
message:
|
||||
`Cooldown retention is ${days} day(s), but an enabled rule has a cooldown of `
|
||||
+ `${longest} second(s). Pruning a cooldown row that is still in force makes the next `
|
||||
+ 'fire count as a first fire, so that rule can send twice. Raise the horizon.',
|
||||
}
|
||||
}
|
||||
return { ok: true, longestCooldownSeconds: longest, message: null }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HORIZONS,
|
||||
NAMES,
|
||||
get,
|
||||
set,
|
||||
longestEnabledCooldownSeconds,
|
||||
checkCooldownHorizon,
|
||||
}
|
||||
@@ -135,9 +135,25 @@ const countUsingSegment = async (segmentId) => {
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest cooldown any ENABLED rule configures, in seconds (Phase 14).
|
||||
*
|
||||
* Enabled only, deliberately: a disabled rule fires nothing, so it writes no
|
||||
* cooldown row a retention sweep could destroy, and letting a forgotten disabled
|
||||
* rule with a 24-hour cooldown veto a tighter horizon would make the warning
|
||||
* advice nobody can act on.
|
||||
*/
|
||||
const maxEnabledCooldownSeconds = async () => {
|
||||
const [row] = await query(
|
||||
'SELECT MAX(cooldown_seconds) AS n FROM engagement_rules WHERE enabled = 1',
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
maxEnabledCooldownSeconds,
|
||||
enabledForTrigger,
|
||||
enabledCancelledBy,
|
||||
insert,
|
||||
|
||||
@@ -107,4 +107,26 @@ const count = async (opts = {}) => {
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list, count, TEST_SEND_TRIGGER }
|
||||
/**
|
||||
* Delete send-log rows older than `before` (Phase 14).
|
||||
*
|
||||
* **Every row here is terminal**, which is why this has no status filter and the
|
||||
* outbox's sweep does: `engagement_sends` records an attempt that has already
|
||||
* resolved. The care is entirely in the horizon, because this table has two live
|
||||
* readers and they pull in opposite directions — `countSentSince` implements the
|
||||
* per-rule hourly ceiling (§7.1 Q3), so any horizon under an hour silently
|
||||
* disables that ceiling, and Admin -> Engagement -> Send Log is the operator's
|
||||
* only answer to "was this person told", so a short one blinds it. Both are the
|
||||
* caller's problem, and `engagementRetention` is where that judgement lives.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const prune = async (before, limit = 1000) => {
|
||||
const result = await query('DELETE FROM engagement_sends WHERE created_at < ? LIMIT ?', [
|
||||
before,
|
||||
Math.floor(limit),
|
||||
])
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list, count, prune, TEST_SEND_TRIGGER }
|
||||
|
||||
@@ -34,6 +34,7 @@ const templates = require('../../../model/engagement/engagementTemplates.model')
|
||||
const sendsDb = require('../../../model/engagement/engagementSends.db')
|
||||
const suppressionsDb = require('../../../model/engagement/engagementSuppressions.db')
|
||||
const suppressions = require('../../../engagement/suppressions')
|
||||
const retention = require('../../../model/engagement/engagementRetention.model')
|
||||
|
||||
// The lattice, flattened for a client: for each ceiling, the ones a rule may
|
||||
// choose under it. Served with the catalog rather than hardcoded in the admin
|
||||
@@ -496,12 +497,18 @@ exports.listSends = async (req, res, next) => {
|
||||
// human in the loop, and without a way back a mistyped-then-corrected mailbox is
|
||||
// silenced permanently.
|
||||
//
|
||||
// **The list returns `address_masked`, never `address_hash`.** The send log route
|
||||
// above strips the hash for a stated reason — shipping a sha256 of every address
|
||||
// on the deployment to a browser is an offline dictionary attack waiting to be
|
||||
// run — and the same reasoning applies twice over here, where the rows are
|
||||
// exactly the addresses somebody would most want to confirm. The mask is what an
|
||||
// operator can act on and is not reversible.
|
||||
// **The list DOES return `address_hash`, and Phase 14 reversed a Phase 9
|
||||
// decision to get there** (org lead, 2026-09-01). Phase 9 stripped it on the
|
||||
// grounds that a sha256 of every address on the deployment is an offline
|
||||
// dictionary attack waiting to be run, and left the only way out of the table a
|
||||
// `window.prompt` asking the operator to retype the full address — which they do
|
||||
// not have, because the screen shows a mask. The trade taken: this route is
|
||||
// admin-only and an admin can already suppress and unsuppress any address they
|
||||
// can name, so the hash grants them no capability they lack; what it buys is a
|
||||
// Lift button on the row the operator is actually looking at. The send log route
|
||||
// above still strips its hash, because nothing there needs to act on a row.
|
||||
//
|
||||
// The mask remains what is DISPLAYED. The hash is a handle, never rendered.
|
||||
|
||||
/** GET /api/v1/admin/engagement/suppressions */
|
||||
exports.listSuppressions = async (req, res, next) => {
|
||||
@@ -526,7 +533,7 @@ exports.listSuppressions = async (req, res, next) => {
|
||||
suppressionsDb.countsByReason(),
|
||||
])
|
||||
res.json({
|
||||
suppressions: rows.map(({ address_hash: _hash, ...row }) => row),
|
||||
suppressions: rows,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
@@ -595,3 +602,90 @@ exports.deleteSuppression = async (req, res, next) => {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/engagement/suppressions/by-hash/:hash
|
||||
*
|
||||
* The per-row Lift button (Phase 14). Same effect as the route above and a
|
||||
* different input: the operator is looking at a masked row and does not know the
|
||||
* address, so the only thing they can act on is the row's own handle.
|
||||
*
|
||||
* **The hash still goes in the path and that is safe where an address is not.**
|
||||
* The objection to a path parameter above is that an access log, a browser
|
||||
* history and every proxy in front of the deployment would capture a real
|
||||
* person's address; a sha256 that is already only ever served to an admin
|
||||
* session leaks nothing further by being logged.
|
||||
*
|
||||
* A 404 rather than a 200 when nothing matched, so a stale screen (two admins,
|
||||
* one list, one already lifted) tells the operator rather than claiming success.
|
||||
*/
|
||||
exports.deleteSuppressionByHash = async (req, res, next) => {
|
||||
try {
|
||||
const hash = typeof req.params.hash === 'string' ? req.params.hash.trim().toLowerCase() : ''
|
||||
// Validated in shape rather than trusted: this value reaches a WHERE clause,
|
||||
// and a 64-character hex string is the only thing this column ever holds.
|
||||
if (!/^[0-9a-f]{64}$/.test(hash)) {
|
||||
return res.status(400).json({ message: 'Not a suppression handle' })
|
||||
}
|
||||
const channel = typeof req.query.channel === 'string' && req.query.channel
|
||||
? req.query.channel
|
||||
: 'email'
|
||||
const removed = await suppressionsDb.remove(hash, channel)
|
||||
if (!removed) return res.status(404).json({ message: 'That address is not suppressed' })
|
||||
res.json({ removed: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retention (Phase 14) ───────────────────────────────────────────────────
|
||||
//
|
||||
// Three horizons on one screen, because "what does this deployment keep" is one
|
||||
// question. The send-log horizon is the reason this is a screen at all rather
|
||||
// than the invisible settings row `team_activity` and `user_notifications` each
|
||||
// use: it changes what an operator-facing page is able to show, so an operator
|
||||
// has to be able to see and set it.
|
||||
|
||||
/** GET /api/v1/admin/engagement/retention */
|
||||
exports.getRetention = async (req, res, next) => {
|
||||
try {
|
||||
const policy = await retention.get()
|
||||
// The guard travels with the policy rather than only being logged at 3am by
|
||||
// the worker: the screen that can fix a too-short cooldown horizon is the one
|
||||
// that has to say it is too short.
|
||||
const cooldownCheck = await retention.checkCooldownHorizon(policy.cooldowns)
|
||||
res.json({
|
||||
retention: policy,
|
||||
limits: retention.HORIZONS,
|
||||
longestCooldownSeconds: cooldownCheck.longestCooldownSeconds,
|
||||
warnings: cooldownCheck.ok ? [] : [cooldownCheck.message],
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/engagement/retention
|
||||
*
|
||||
* A sparse PUT: only the horizons present in the body are written, so a screen
|
||||
* saving one select does not have to round-trip the other two and cannot
|
||||
* clobber a value another admin changed between load and save. Out-of-range is
|
||||
* a 400 rather than a clamp — silently storing something other than what was
|
||||
* typed would leave the screen describing a policy the deployment is not running.
|
||||
*/
|
||||
exports.putRetention = async (req, res, next) => {
|
||||
try {
|
||||
const policy = await retention.set(req.body || {}, req.user?.id ?? null)
|
||||
const cooldownCheck = await retention.checkCooldownHorizon(policy.cooldowns)
|
||||
res.json({
|
||||
retention: policy,
|
||||
limits: retention.HORIZONS,
|
||||
longestCooldownSeconds: cooldownCheck.longestCooldownSeconds,
|
||||
warnings: cooldownCheck.ok ? [] : [cooldownCheck.message],
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.status === 400) return res.status(400).json({ message: err.message })
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,4 +381,48 @@ engagementRouter.delete(
|
||||
controller.deleteSuppression,
|
||||
)
|
||||
|
||||
engagementRouter.delete(
|
||||
'/suppressions/by-hash/:hash',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Lift a suppression by its row handle'
|
||||
// #swagger.description = 'The per-row Lift button (Phase 14). Same effect as the route above, different input: the screen shows a mask, so the operator does not know the address and can only act on the row handle the list gives them. The handle IS safe in the path where an address is not - it is a sha256 already served only to an admin session, so an access log or proxy that captures it learns nothing new. 404 rather than 200 when nothing matched, so a stale screen (two admins, one list) says so instead of claiming success.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['hash'] = { in: 'path', description: 'The address_hash the list returns for that row, 64 hex characters', required: true, schema: { type: 'string' } }
|
||||
// #swagger.parameters['channel'] = { in: 'query', description: 'Defaults to email', required: false, schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'Lifted', content: { "application/json": { schema: { type: "object", properties: { removed: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Not a suppression handle', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'That address is not suppressed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.deleteSuppressionByHash,
|
||||
)
|
||||
|
||||
// ── Retention (Phase 14) ───────────────────────────────────────────────────
|
||||
|
||||
engagementRouter.get(
|
||||
'/retention',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Read the engagement retention policy'
|
||||
// #swagger.description = 'The three horizons the nightly sweep uses, in days, with the bounds each is validated against. `engagement_suppressions` is deliberately absent: a suppression is a standing decision and does not expire, because ageing out a hard bounce re-mails an address that already bounced. `warnings` carries the one check that cannot be a static bound - a cooldown horizon shorter than the longest cooldown on an ENABLED rule, which would let that rule send twice.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The current policy', content: { "application/json": { schema: { type: "object", properties: { retention: { type: "object", properties: { sends: { type: "integer" }, cooldowns: { type: "integer" }, outbox: { type: "integer" } } }, limits: { type: "object", additionalProperties: true }, longestCooldownSeconds: { type: "integer" }, warnings: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.getRetention,
|
||||
)
|
||||
|
||||
engagementRouter.put(
|
||||
'/retention',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Set the engagement retention policy'
|
||||
// #swagger.description = 'Sparse: only the horizons named in the body are written, so saving one select cannot clobber a value another admin changed between load and save. Out of range is a 400 rather than a clamp - storing something other than what was typed would leave the screen describing a policy the deployment is not running. The floors are not UI niceties: below 2 days a pruned cooldown row makes the next fire a FIRST fire (a duplicate send), and the send log is counted by the per-rule hourly ceiling.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { sends: { type: "integer", nullable: true }, cooldowns: { type: "integer", nullable: true }, outbox: { type: "integer", nullable: true } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The policy as it now stands', content: { "application/json": { schema: { type: "object", properties: { retention: { type: "object", additionalProperties: { type: "integer" } }, limits: { type: "object", additionalProperties: true }, longestCooldownSeconds: { type: "integer" }, warnings: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A horizon was not a whole number of days, or was out of range', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.putRetention,
|
||||
)
|
||||
|
||||
module.exports = engagementRouter
|
||||
|
||||
@@ -11,6 +11,7 @@ const botScore = require('./middleware/botScore')
|
||||
const announceWorker = require('./utils/announceWorker')
|
||||
const teamActivityPrune = require('./utils/teamActivityPrune')
|
||||
const inboxPrune = require('./utils/userNotificationsPrune')
|
||||
const engagementRetentionPrune = require('./utils/engagementRetentionPrune')
|
||||
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
|
||||
const teamDigestWorker = require('./utils/teamDigestWorker')
|
||||
const engagementWorker = require('./utils/engagementWorker')
|
||||
@@ -160,6 +161,7 @@ async function start() {
|
||||
// rather than after someone notices. No-op on a deployment with no Teams.
|
||||
teamActivityPrune.start()
|
||||
inboxPrune.start()
|
||||
engagementRetentionPrune.start()
|
||||
teamForumUploadSweep.start()
|
||||
teamDigestWorker.start()
|
||||
|
||||
@@ -186,6 +188,7 @@ function setupShutdown(server, internalServer) {
|
||||
announceWorker.stop() // stop the news-announcement dispatcher poller
|
||||
teamActivityPrune.stop() // stop the Team activity retention timer
|
||||
inboxPrune.stop() // stop the in-app inbox retention timer
|
||||
engagementRetentionPrune.stop() // stop the engagement retention timer
|
||||
teamForumUploadSweep.stop() // stop the forum upload sweep
|
||||
teamDigestWorker.stop() // stop the Team forum digest timer
|
||||
engagementWorker.stop() // stop the engagement outbox worker
|
||||
|
||||
134
server/src/utils/engagementRetentionPrune.js
Normal file
134
server/src/utils/engagementRetentionPrune.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── Engagement retention worker ────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 14. Three of the four engagement tables grow on every fire
|
||||
// and nothing has ever deleted from any of them: `engagement_cooldowns` (one row
|
||||
// per rule × user × subject × channel per fire), `engagement_outbox` (one row per
|
||||
// enqueued delivery, terminal rows included) and `engagement_sends` (one row per
|
||||
// attempt). `engagement_suppressions` is the fourth and does not expire — see
|
||||
// `engagementRetention.model.js` for why that is a decision rather than an
|
||||
// omission.
|
||||
//
|
||||
// **One worker, three sweeps, not three workers.** They share a timer, a batch
|
||||
// discipline and one settings-backed policy object; splitting them would give an
|
||||
// operator three independent nightly table-wide DELETEs to reason about and
|
||||
// three places for a horizon to be read differently.
|
||||
//
|
||||
// Same in-process shape as `teamActivityPrune` and `userNotificationsPrune` —
|
||||
// setInterval + unref + stop(), wired into server.js start/shutdown beside them,
|
||||
// with the first run delayed so a table-wide DELETE never lands in front of the
|
||||
// first request on a crash-looping deployment.
|
||||
|
||||
const cooldownsDb = require('../model/engagement/engagementCooldowns.db')
|
||||
const outboxDb = require('../model/engagement/engagementOutbox.db')
|
||||
const sendsDb = require('../model/engagement/engagementSends.db')
|
||||
const retention = require('../model/engagement/engagementRetention.model')
|
||||
const log = require('./logger')('engagement')
|
||||
|
||||
const INTERVAL_MS = Number(process.env.ENGAGEMENT_PRUNE_MS) || 24 * 60 * 60 * 1000
|
||||
const FIRST_RUN_MS = Number(process.env.ENGAGEMENT_PRUNE_DELAY_MS) || 10 * 60 * 1000
|
||||
|
||||
// A bound per statement, so one run after a long outage is a series of bounded
|
||||
// DELETEs rather than one holding locks over a million rows. Each sweep repeats
|
||||
// until it clears and stops early rather than looping forever; a run that hits
|
||||
// the ceiling simply resumes tomorrow, which is what a horizon means anyway.
|
||||
const BATCH = 1000
|
||||
const MAX_BATCHES = 50
|
||||
|
||||
const daysAgo = (days, now) => new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
|
||||
/** Repeat a bounded delete until it stops filling its batch. Never throws. */
|
||||
async function sweep(name, del) {
|
||||
let removed = 0
|
||||
for (let i = 0; i < MAX_BATCHES; i += 1) {
|
||||
const n = await del()
|
||||
removed += n
|
||||
if (n < BATCH) break
|
||||
}
|
||||
if (removed) log.info('engagement retention swept', { table: name, removed })
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass over all three tables.
|
||||
*
|
||||
* Each sweep is caught on its own: a failure in one (a lock timeout on a huge
|
||||
* outbox, say) must not stop the other two from being bounded. Never throws — it
|
||||
* runs on a timer with nobody to catch it.
|
||||
*/
|
||||
async function tick(now = new Date()) {
|
||||
const result = { cooldowns: 0, outbox: 0, sends: 0, warnings: [] }
|
||||
let policy
|
||||
try {
|
||||
policy = await retention.get()
|
||||
} catch (err) {
|
||||
log.error('engagement retention policy unreadable; skipping this run', { message: err.message })
|
||||
return result
|
||||
}
|
||||
|
||||
// Phase 14's acceptance line: checked, not picked. A horizon shorter than a
|
||||
// live cooldown is logged and swept anyway — see the model for that trade.
|
||||
try {
|
||||
const check = await retention.checkCooldownHorizon(policy.cooldowns)
|
||||
if (!check.ok) {
|
||||
result.warnings.push(check.message)
|
||||
log.warn('cooldown retention is shorter than a live cooldown', {
|
||||
retainDays: policy.cooldowns,
|
||||
longestCooldownSeconds: check.longestCooldownSeconds,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('cooldown horizon check failed', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
result.cooldowns = await sweep('engagement_cooldowns', () =>
|
||||
cooldownsDb.prune(daysAgo(policy.cooldowns, now), BATCH))
|
||||
} catch (err) {
|
||||
log.error('cooldown prune failed', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
result.outbox = await sweep('engagement_outbox', () =>
|
||||
outboxDb.pruneTerminal(daysAgo(policy.outbox, now), BATCH))
|
||||
} catch (err) {
|
||||
log.error('outbox prune failed', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
result.sends = await sweep('engagement_sends', () =>
|
||||
sendsDb.prune(daysAgo(policy.sends, now), BATCH))
|
||||
} catch (err) {
|
||||
log.error('send-log prune failed', { message: err.message })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let timer = null
|
||||
let firstRun = null
|
||||
|
||||
function start() {
|
||||
if (timer || firstRun) return timer
|
||||
firstRun = setTimeout(() => {
|
||||
firstRun = null
|
||||
tick()
|
||||
timer = setInterval(() => { tick() }, INTERVAL_MS)
|
||||
if (timer.unref) timer.unref()
|
||||
}, FIRST_RUN_MS)
|
||||
if (firstRun.unref) firstRun.unref()
|
||||
log.info('engagement retention started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
|
||||
return timer
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (firstRun) {
|
||||
clearTimeout(firstRun)
|
||||
firstRun = null
|
||||
}
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, BATCH, MAX_BATCHES, INTERVAL_MS, FIRST_RUN_MS }
|
||||
@@ -153,7 +153,7 @@ async function processRow(row, now = new Date(), deliverFn = deliver) {
|
||||
|
||||
async function tick(now = new Date()) {
|
||||
try {
|
||||
await outboxDb.reclaimStale(new Date(now.getTime() - STALE_MS))
|
||||
await outboxDb.reclaimStale(new Date(now.getTime() - STALE_MS), MAX_ATTEMPTS)
|
||||
} catch (err) {
|
||||
log.error('failed to reclaim stale rows', { message: err.message })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user