// ── Inbox retention worker ───────────────────────────────────────────────── // // ENGAGEMENT.md Phase 7. `user_notifications` is written by a rule that can fire // on every event of its trigger, for every member of its audience, forever — // nothing in the engine deletes anything, and neither the outbox nor the send // log is a bound on this table (both hold one row per DELIVERY, and an inbox // item outlives its delivery by design). The plan specifies no retention at all, // which is how `team_activity` grew until §4.2 gave it this same worker. // // **Read items only, and that is the policy rather than an implementation // detail.** An unread item is one the user has not seen; deleting it because it // is old is the inbox quietly answering "nothing waiting" when something is. // A never-read backlog is bounded in practice by the per-rule hourly ceiling // (§7.1 Q3), which is the limit an operator actually tunes. // // Same in-process shape as `teamActivityPrune` and `announceWorker` — 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 inbox = require('../model/userNotifications/userNotifications.db') const settings = require('../model/settings/settings.model') const log = require('./logger')('engagement') const INTERVAL_MS = Number(process.env.INBOX_PRUNE_MS) || 24 * 60 * 60 * 1000 const FIRST_RUN_MS = Number(process.env.INBOX_PRUNE_DELAY_MS) || 5 * 60 * 1000 // In `settings`, not in env, for the reason §4.2 gives: an operator tightening a // busy shard should not need a deploy. The key is namespaced with the table it // governs rather than with the phase that added it. const RETAIN_KEY = 'user_notifications_retain_days' const DEFAULT_RETAIN_DAYS = 90 // A bound per sweep, so one run after a long outage is a series of bounded // statements rather than a delete of a million rows holding locks. The sweep // repeats until it clears, and stops early rather than looping forever. const BATCH = 1000 const MAX_BATCHES = 50 /** * How many days of read items to keep. * * Wrapped in a try like `teamActivity.retentionConfig`, and for its reason: this * runs on a timer with nobody watching, so a settings table that is briefly * unavailable must yield the default rather than an exception that kills the * nightly job. A zero or negative value would delete the whole inbox, so it is * rejected rather than honoured. */ async function retainDays() { try { const raw = await settings.get(RETAIN_KEY) const days = Number(raw) if (Number.isFinite(days) && days > 0) return Math.floor(days) } catch (err) { log.debug('inbox retention setting unreadable; using the default', { message: err.message }) } return DEFAULT_RETAIN_DAYS } /** One prune. Never throws — it runs on a timer with nobody to catch it. */ async function tick() { try { const days = await retainDays() let removed = 0 for (let i = 0; i < MAX_BATCHES; i += 1) { const n = await inbox.pruneRead(days, BATCH) removed += n if (n < BATCH) break } if (removed) log.info('inbox pruned', { removed, retainDays: days }) return removed } catch (err) { log.error('inbox prune failed', { message: err.message }) return null } } 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('inbox 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, retainDays, RETAIN_KEY, DEFAULT_RETAIN_DAYS, INTERVAL_MS, FIRST_RUN_MS }