feat(teams): the activity feed, its two writers and its retention
TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.
Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.
Three rules shape the model:
- core never composes a summary. It arrives already rendered and is stored
verbatim; core cannot phrase "gained 15,000 gold" for a game whose
vocabulary it does not know.
- visibility fails closed. An item with no stated visibility is `members`.
- a push never throws at its call site. It is called from inside a game-event
handler, and a storage problem of core's must not become the module's
control flow.
Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.
Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.
The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
64
server/src/utils/teamActivityPrune.js
Normal file
64
server/src/utils/teamActivityPrune.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// ── Team activity retention worker ──────────────────────────────────────────
|
||||
//
|
||||
// TEAMS.md §4.2's nightly prune: an age horizon (`team_activity_retain_days`,
|
||||
// default 90) and a per-Team row cap (`team_activity_row_cap`, default 2000).
|
||||
// Both live in `settings`, so an operator can tighten a busy shard without a
|
||||
// deploy.
|
||||
//
|
||||
// Same in-process shape as utils/announceWorker and middleware/botScore's
|
||||
// sweeper — setInterval + unref + stop(), wired into server.js start/shutdown.
|
||||
// There is no cron in this stack and adding one for a single daily DELETE would
|
||||
// be a dependency to justify at every future upgrade.
|
||||
//
|
||||
// **The first run is delayed rather than immediate.** A prune at boot would put a
|
||||
// table-wide DELETE in front of the first request on every restart, and a
|
||||
// deployment that is crash-looping would run it on every loop. Five minutes in is
|
||||
// past the point where a boot has either succeeded or failed.
|
||||
|
||||
const teamActivity = require('../model/teams/teamActivity.model')
|
||||
const log = require('./logger')('teams')
|
||||
|
||||
// Nightly, per §4.2. Not aligned to a wall-clock hour: the work is proportional
|
||||
// to what arrived rather than to when it is done, and pinning it to 03:00 would
|
||||
// mean a process that restarts each afternoon never prunes at all.
|
||||
const INTERVAL_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_MS) || 24 * 60 * 60 * 1000
|
||||
const FIRST_RUN_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_DELAY_MS) || 5 * 60 * 1000
|
||||
|
||||
let timer = null
|
||||
let firstRun = null
|
||||
|
||||
/** One prune. Never throws — it runs on a timer with nobody to catch it. */
|
||||
async function tick() {
|
||||
try {
|
||||
return await teamActivity.prune()
|
||||
} catch (err) {
|
||||
log.error('team activity prune failed', { message: err.message })
|
||||
return 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('team activity 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, INTERVAL_MS, FIRST_RUN_MS }
|
||||
Reference in New Issue
Block a user