Files
2026-07-13 13:32:57 +08:00

253 lines
10 KiB
TypeScript

import type { MollifierBuffer } from "@trigger.dev/redis-worker";
import { logger as defaultLogger } from "~/services/logger.server";
import { getMollifierBuffer } from "./mollifierBuffer.server";
import { type StaleSweepStateStore } from "./mollifierStaleSweepState.server";
import {
recordStaleEntry as defaultRecordStaleEntry,
reportStaleEntrySnapshot as defaultReportStaleEntrySnapshot,
} from "./mollifierTelemetry.server";
// One pass of the sweep scans a bounded slice of orgs from the buffer's
// queue LIST, identified by a durable cursor in Redis. Per-env entry
// scan is also bounded so a single pathological env can't extend the
// pass.
const DEFAULT_MAX_ENTRIES_PER_ENV = 1000;
// Max orgs visited per tick. Together with `maxEntriesPerEnv` this
// caps Redis traffic per pass. One "cycle" (visiting every org once)
// takes `ceil(N_orgs / cap)` ticks, after which the cursor wraps and a
// fresh org list is taken.
const DEFAULT_MAX_ORGS_PER_PASS = 100;
export type StaleSweepConfig = {
// Entries whose dwell exceeds this threshold are flagged stale. Set
// it well below `entryTtlSeconds * 1000` so ops have lead time before
// TTL-induced silent loss; the default (half of entryTtlSeconds)
// matches the cadence in the plan doc.
staleThresholdMs: number;
maxEntriesPerEnv?: number;
// Hard cap on orgs visited per tick. Bounds the per-pass Redis traffic
// and wall-time. Default 100 — at typical fleet sizes one or two
// ticks cover everyone; under incident-scale fan-out a full cycle
// takes a handful of ticks (~minutes) which is still well below the
// staleness signal latency that ops cares about.
maxOrgsPerPass?: number;
};
export type StaleSweepDeps = {
getBuffer?: () => MollifierBuffer | null;
// Durable cursor + per-env counts hash. Required: the sweep is
// useless without persistent state across ticks. The webapp wires up
// a real `MollifierStaleSweepState`; tests pass one constructed
// against the test container.
state: StaleSweepStateStore;
// No `envId` arg — `envId` is a high-cardinality metric attribute and
// is intentionally not emitted as a metric label. The structured warn
// log below carries envId for forensic drill-down.
recordStaleEntry?: () => void;
reportStaleEntrySnapshot?: (snapshot: Map<string, number>) => void;
logger?: { warn: (message: string, fields: Record<string, unknown>) => void };
now?: () => number;
};
export type StaleSweepResult = {
orgsScanned: number;
envsScanned: number;
entriesScanned: number;
staleCount: number;
};
// Walks a bounded slice of `orgs → envs → entries`, emitting an OTel
// counter tick and a structured warning log for each buffer entry whose
// dwell exceeds the stale threshold. Read-only on the buffer's own
// state; writes only to the sweep's three dedicated keys
// (`mollifier:stale_sweep:*`). The sweep does NOT remove or salvage
// buffer entries; that decision is deferred to a separate retention-
// policy change. The signal here exists so ops sees the drainer falling
// behind well before TTL-induced loss kicks in.
//
// Sharding contract:
// - Cursor starts at 0. On cursor=0 the org list is refreshed by
// snapshotting `buffer.listOrgs()` into the durable LIST — that is
// the cycle's frozen view of orgs to visit.
// - Each tick consumes up to `maxOrgsPerPass` orgs from the LIST,
// advances the cursor, and persists.
// - When the cursor reaches the end of the LIST it wraps to 0; the next
// tick rebuilds the org list, capturing any orgs that joined the
// buffer mid-cycle.
// - The per-env counts HASH carries over across ticks: an env visited
// on tick N and not revisited until tick N+M keeps its last-known
// stale count in the gauge for that window. This is the price of
// sharding — accepted because the alternative (re-scan everything
// every tick) does not bound work.
export async function runStaleSweepOnce(
config: StaleSweepConfig,
deps: StaleSweepDeps
): Promise<StaleSweepResult> {
const getBuffer = deps.getBuffer ?? getMollifierBuffer;
const recordStale = deps.recordStaleEntry ?? defaultRecordStaleEntry;
const reportSnapshot = deps.reportStaleEntrySnapshot ?? defaultReportStaleEntrySnapshot;
const log = deps.logger ?? defaultLogger;
const now = (deps.now ?? Date.now)();
const maxEntries = config.maxEntriesPerEnv ?? DEFAULT_MAX_ENTRIES_PER_ENV;
const maxOrgsPerPass = config.maxOrgsPerPass ?? DEFAULT_MAX_ORGS_PER_PASS;
const buffer = getBuffer();
if (!buffer) {
// Replace any previous snapshot with empty so a previously-paging
// env doesn't stay latched if mollifier is turned off mid-flight.
// Also clear the durable state so a re-enable starts from a clean
// slate instead of resuming on a stale cursor.
await deps.state.clearAll();
reportSnapshot(new Map());
return { orgsScanned: 0, envsScanned: 0, entriesScanned: 0, staleCount: 0 };
}
let cursor = await deps.state.readCursor();
if (cursor === 0) {
// Fresh cycle — capture the current set of orgs into the frozen
// LIST. Any orgs that join after this snapshot wait until the next
// cycle to be visited. Acceptable for an observational sweep; the
// staleness signal would only fire on entries that have been
// dwelling for `staleThresholdMs` anyway, so they're not new.
const orgs = await buffer.listOrgs();
await deps.state.rebuildOrgList(orgs);
}
const { orgs: slice, total } = await deps.state.readOrgListSlice(cursor, maxOrgsPerPass);
let envsScanned = 0;
let entriesScanned = 0;
let staleCount = 0;
for (const orgId of slice) {
const envs = await buffer.listEnvsForOrg(orgId);
for (const envId of envs) {
envsScanned += 1;
let envStale = 0;
const entries = await buffer.listEntriesForEnv(envId, maxEntries);
for (const entry of entries) {
entriesScanned += 1;
const dwellMs = now - entry.createdAt.getTime();
if (dwellMs > config.staleThresholdMs) {
recordStale();
log.warn("mollifier.stale_entry", {
runId: entry.runId,
envId,
orgId,
dwellMs,
staleThresholdMs: config.staleThresholdMs,
});
envStale += 1;
}
}
// Persist the per-env count to the durable hash. HSET when stale
// > 0, HDEL when it dropped back to zero — the hash is the source
// of truth for the gauge snapshot below.
await deps.state.setEnvStaleCount(envId, envStale);
// Track that this env was visited during the current cycle. The
// reconcile step at cycle wrap uses this to HDEL counts hash
// entries for envs that fully drained mid-cycle (they disappear
// from listEnvsForOrg, so the inner loop above never reaches them
// and never HDELs their hash field — without reconcile the gauge
// would stay elevated forever).
await deps.state.markEnvVisited(envId);
staleCount += envStale;
}
}
// Advance the cursor. If the slice consumed the end of the LIST, wrap
// to 0 so the next tick rebuilds the org list and starts a new cycle.
const advanced = cursor + slice.length;
const wrapped = advanced >= total;
const newCursor = wrapped ? 0 : advanced;
await deps.state.writeCursor(newCursor);
if (wrapped) {
// Cycle ended. HDEL any env still in the counts hash that didn't
// appear in any tick of the just-completed cycle — these are envs
// that fully drained from the buffer mid-cycle and would otherwise
// hold their stale gauge value forever. Also DELs the visited set
// so the next cycle starts clean.
await deps.state.reconcileVisited();
}
// Emit the snapshot from the durable hash, which carries values for
// envs visited in earlier ticks too. This is what makes the gauge
// stable across ticks (and across webapp restarts).
const snapshot = await deps.state.readAllEnvStaleCounts();
reportSnapshot(snapshot);
return { orgsScanned: slice.length, envsScanned, entriesScanned, staleCount };
}
export type StaleSweepIntervalHandle = {
stop: () => Promise<void>;
};
// Production wrapper: schedule `runStaleSweepOnce` on a fixed interval.
// One pass at a time — if a sweep is still running when the timer fires
// the next tick is skipped (a backed-up Redis would otherwise queue
// overlapping sweeps that all log the same stale entries).
export function startStaleSweepInterval(
config: StaleSweepConfig & { intervalMs: number },
deps: StaleSweepDeps
): StaleSweepIntervalHandle {
let stopped = false;
let inFlight = false;
// Tracks the current tick so `stop()` can await it before closing the
// state's Redis client. Without this, a tick that's already past the
// `stopped` guard at entry would continue making `state.*` calls
// against an ioredis client that `stop()` has already `quit()`ed,
// raising errors that the tick's own try/catch then logs as
// `mollifier.stale_sweep.failed` warnings — spurious noise on every
// graceful shutdown.
let currentTick: Promise<void> | null = null;
const tick = async () => {
if (stopped || inFlight) return;
inFlight = true;
const run = (async () => {
try {
await runStaleSweepOnce(config, deps);
} catch (err) {
const log = deps.logger ?? defaultLogger;
log.warn("mollifier.stale_sweep.failed", {
err: err instanceof Error ? err.message : String(err),
});
} finally {
inFlight = false;
currentTick = null;
}
})();
currentTick = run;
await run;
};
const timer = setInterval(() => {
void tick();
}, config.intervalMs);
return {
stop: async () => {
stopped = true;
clearInterval(timer);
// Drain any tick that started before `stopped` flipped. Its
// `state.*` calls must land before we close the Redis client.
if (currentTick) {
try {
await currentTick;
} catch {
// tick has its own catch — this await is just to ensure
// ordering, not to surface errors that have already been
// logged inside the tick.
}
}
// Close the state's underlying resource. The `close()` method is
// part of the `StaleSweepStateStore` contract — production's
// `MollifierStaleSweepState` shuts down its ioredis client; fake
// test states implement a no-op.
await deps.state.close();
},
};
}