190 lines
7.8 KiB
TypeScript
190 lines
7.8 KiB
TypeScript
import { createRedisClient, type Redis, type RedisOptions } from "@internal/redis";
|
|
import { Logger } from "@trigger.dev/core/logger";
|
|
|
|
// Durable per-tick state for the sharded stale sweep. Four Redis keys,
|
|
// all in the `mollifier:` namespace alongside the buffer's own state:
|
|
//
|
|
// mollifier:stale_sweep:cursor STRING next position in org_list (0 = fresh cycle)
|
|
// mollifier:stale_sweep:org_list LIST org IDs frozen at the start of the cycle
|
|
// mollifier:stale_sweep:counts HASH envId -> last-known stale count
|
|
// mollifier:stale_sweep:visited SET envIds visited during the current cycle
|
|
//
|
|
// The state survives webapp restarts: a restarted process picks up the
|
|
// cursor where the previous one left off and re-emits the last-known
|
|
// gauge values immediately, rather than blinking to zero until the next
|
|
// cycle visits each env.
|
|
//
|
|
// The `visited` set exists to GC the `counts` hash at cycle wrap: an env
|
|
// that drains completely between sweep ticks disappears from
|
|
// `buffer.listEnvsForOrg`, so the sweep's inner loop never revisits it
|
|
// and never HDELs its counts entry. Without the visited-set GC the
|
|
// counts hash retains the env's last-known stale count forever and the
|
|
// gauge stays permanently elevated. At cursor wrap we diff the hash
|
|
// against the cycle's visited set and HDEL the difference.
|
|
//
|
|
// Storage is owned by this class rather than added to MollifierBuffer
|
|
// because the keys are sweep-internal — the buffer abstracts the
|
|
// drainer/queue state, this abstracts sweep state. They share a
|
|
// namespace prefix but no API surface.
|
|
|
|
export interface StaleSweepStateStore {
|
|
readCursor(): Promise<number>;
|
|
writeCursor(value: number): Promise<void>;
|
|
/** Replaces the cycle's frozen org_list. Called at cursor=0. */
|
|
rebuildOrgList(orgs: string[]): Promise<void>;
|
|
/** Returns up to `count` org IDs starting at `start`, plus the LIST's total length. */
|
|
readOrgListSlice(start: number, count: number): Promise<{ orgs: string[]; total: number }>;
|
|
/** HSET when count > 0, HDEL when count === 0 (so the snapshot reflects current truth). */
|
|
setEnvStaleCount(envId: string, count: number): Promise<void>;
|
|
readAllEnvStaleCounts(): Promise<Map<string, number>>;
|
|
/** SADD `envId` to the current cycle's visited set. Called once per env scanned per tick. */
|
|
markEnvVisited(envId: string): Promise<void>;
|
|
/**
|
|
* HDEL every env in the counts hash that is NOT in the visited set, then
|
|
* DEL the visited set. Called when the cursor wraps (cycle ends) so
|
|
* envs that fully drained mid-cycle get cleaned out of the gauge.
|
|
*/
|
|
reconcileVisited(): Promise<void>;
|
|
clearAll(): Promise<void>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
const CURSOR_KEY = "mollifier:stale_sweep:cursor";
|
|
const ORG_LIST_KEY = "mollifier:stale_sweep:org_list";
|
|
const COUNTS_KEY = "mollifier:stale_sweep:counts";
|
|
const VISITED_KEY = "mollifier:stale_sweep:visited";
|
|
|
|
export class MollifierStaleSweepState implements StaleSweepStateStore {
|
|
private readonly redis: Redis;
|
|
private readonly logger: Logger;
|
|
|
|
constructor(options: {
|
|
redisOptions: RedisOptions;
|
|
logger?: Logger;
|
|
maxRetriesPerRequest?: number;
|
|
}) {
|
|
this.logger = options.logger ?? new Logger("MollifierStaleSweepState", "debug");
|
|
this.redis = createRedisClient(
|
|
{ ...options.redisOptions, maxRetriesPerRequest: options.maxRetriesPerRequest ?? 20 },
|
|
{
|
|
onError: (error) => {
|
|
this.logger.error("MollifierStaleSweepState redis client error:", { error });
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
async readCursor(): Promise<number> {
|
|
const raw = await this.redis.get(CURSOR_KEY);
|
|
if (raw === null) return 0;
|
|
const n = Number.parseInt(raw, 10);
|
|
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
}
|
|
|
|
async writeCursor(value: number): Promise<void> {
|
|
await this.redis.set(CURSOR_KEY, String(value));
|
|
}
|
|
|
|
async rebuildOrgList(orgs: string[]): Promise<void> {
|
|
// DEL + RPUSH in a pipeline — close enough to atomic for an
|
|
// observational sweep (the inFlight guard at startStaleSweepInterval
|
|
// serialises sweep passes; nothing else writes these keys).
|
|
const pipeline = this.redis.pipeline();
|
|
pipeline.del(ORG_LIST_KEY);
|
|
if (orgs.length > 0) {
|
|
pipeline.rpush(ORG_LIST_KEY, ...orgs);
|
|
}
|
|
await pipeline.exec();
|
|
}
|
|
|
|
async readOrgListSlice(start: number, count: number): Promise<{ orgs: string[]; total: number }> {
|
|
const pipeline = this.redis.pipeline();
|
|
pipeline.lrange(ORG_LIST_KEY, start, start + count - 1);
|
|
pipeline.llen(ORG_LIST_KEY);
|
|
const results = await pipeline.exec();
|
|
// `pipeline.exec()` returning null is the abort-on-broken-pipe path.
|
|
// Surface it as a thrown error — the previous `return { orgs: [], total: 0 }`
|
|
// looked indistinguishable from a genuinely empty org list to the
|
|
// caller (`runStaleSweepOnce`), which then wrote cursor=0, reconciled
|
|
// visited envs against the empty result, and cleared the stale-entry
|
|
// gauge. That hid real Redis problems and silenced the alerts the
|
|
// sweep exists to raise.
|
|
if (!results) {
|
|
throw new Error("MollifierStaleSweepState.readOrgListSlice: pipeline.exec returned null");
|
|
}
|
|
const [lrangeErr, lrangeRes] = results[0] as [Error | null, string[] | null];
|
|
const [llenErr, llenRes] = results[1] as [Error | null, number | null];
|
|
if (lrangeErr || llenErr) {
|
|
this.logger.error("MollifierStaleSweepState.readOrgListSlice failed", {
|
|
lrangeErr: lrangeErr?.message,
|
|
llenErr: llenErr?.message,
|
|
});
|
|
// Same reasoning as the null-result path above — propagate the
|
|
// failure so the sweep's interval wrapper records a failed cycle
|
|
// and the durable cursor / counts hash stay untouched.
|
|
throw lrangeErr ?? llenErr ?? new Error("MollifierStaleSweepState.readOrgListSlice failed");
|
|
}
|
|
return { orgs: lrangeRes ?? [], total: llenRes ?? 0 };
|
|
}
|
|
|
|
async setEnvStaleCount(envId: string, count: number): Promise<void> {
|
|
if (count > 0) {
|
|
await this.redis.hset(COUNTS_KEY, envId, String(count));
|
|
} else {
|
|
await this.redis.hdel(COUNTS_KEY, envId);
|
|
}
|
|
}
|
|
|
|
async readAllEnvStaleCounts(): Promise<Map<string, number>> {
|
|
const raw = await this.redis.hgetall(COUNTS_KEY);
|
|
const out = new Map<string, number>();
|
|
for (const [envId, value] of Object.entries(raw)) {
|
|
const n = Number.parseInt(value, 10);
|
|
if (Number.isFinite(n)) out.set(envId, n);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async markEnvVisited(envId: string): Promise<void> {
|
|
await this.redis.sadd(VISITED_KEY, envId);
|
|
}
|
|
|
|
async reconcileVisited(): Promise<void> {
|
|
// HKEYS + SMEMBERS in a pipeline, then HDEL the difference locally.
|
|
// For typical fleet sizes (counts and visited both bounded by the
|
|
// count of buffered envs) this is well within a single RTT plus one
|
|
// small HDEL.
|
|
const pipeline = this.redis.pipeline();
|
|
pipeline.hkeys(COUNTS_KEY);
|
|
pipeline.smembers(VISITED_KEY);
|
|
const results = await pipeline.exec();
|
|
if (!results) return;
|
|
const [hkeysErr, hkeysRes] = results[0] as [Error | null, string[] | null];
|
|
const [smembersErr, smembersRes] = results[1] as [Error | null, string[] | null];
|
|
if (hkeysErr || smembersErr) {
|
|
this.logger.error("MollifierStaleSweepState.reconcileVisited failed", {
|
|
hkeysErr: hkeysErr?.message,
|
|
smembersErr: smembersErr?.message,
|
|
});
|
|
return;
|
|
}
|
|
const hashEnvs = hkeysRes ?? [];
|
|
const visited = new Set(smembersRes ?? []);
|
|
const orphans = hashEnvs.filter((envId) => !visited.has(envId));
|
|
const cleanup = this.redis.pipeline();
|
|
if (orphans.length > 0) {
|
|
cleanup.hdel(COUNTS_KEY, ...orphans);
|
|
}
|
|
cleanup.del(VISITED_KEY);
|
|
await cleanup.exec();
|
|
}
|
|
|
|
async clearAll(): Promise<void> {
|
|
await this.redis.del(CURSOR_KEY, ORG_LIST_KEY, COUNTS_KEY, VISITED_KEY);
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
await this.redis.quit();
|
|
}
|
|
}
|