mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
fix(telemetry): count late index uploads, treat usage as live ingest, purge before catch-up (#2333) (#2356)
Three bugs in the self-hosted telemetry services (telemetry-worker/ and
telemetry-dashboard/), reported against 6560052a and all still on main.
1. An index run that uploads late never counted toward activation. The
dashboard's funnel reads machine_first_seen.first_index_day, which only the
nightly rollup wrote, and the rollup re-rolls just the last three days plus
days with no daily_machines row. Ingest accepts timestamps up to 30 days old
and the client keeps an event's original timestamp when it re-queues it, so
an index event 4+ days late landed on a day nothing revisited and
first_index_day stayed NULL. The ingest upsert of machine_first_seen now
lowers first_index_day the way it lowers first_day:
coalesce(min(old, new), old, new), because SQLite's min() is NULL if either
side is. It is the same statement and row, with no new index and no
migration (the column exists since 0002). The rollup still re-derives it
from raw events, which covers events stored before this change.
2. ingest_stalled read only max(day) FROM events, but usage counters have
lived in usage_daily since 0003, so a day with usage and no lifecycle events
read as "The ingest worker ... is not storing anything". /api/meta now also
reads max(day) FROM usage_daily (a primary-key lookup) and returns
latest_usage_day and latest_ingest_day. Ingest counts as stalled only when
there is no lifecycle event from yesterday or later and no usage counter
from the day before yesterday or later, because clients upload a day's
counters only after that day ends. The banner names latest_ingest_day.
3. runNightly rolled up the 3 regular days, then up to 31 missed days, and ran
purgeOldEvents last. Each day first folds its legacy usage_rollup rows,
which takes minutes on a heavy day, so a backlog could run past Cloudflare's
15-minute Cron Trigger limit and skip the purge and the summary line night
after night. The purge now runs first: it is bounded and touches only days
past the window, which no rollup reads. After that, rollups stop starting
new days or fold chunks 10 minutes in (NIGHTLY_BUDGET_MS). A day stopped
mid-fold gets no rollup at all, so it stays a missed day and the next night
continues from its last committed chunk. The summary line counts those days
as `deferred`, and `caught_up` now counts missed days actually rolled up.
Verification: __tests__/telemetry-services.test.ts (new; the root vitest
config picks it up) runs the worker's fetch/cron code and the dashboard API
against the checked-in migrations in in-memory node:sqlite through a small
D1-shaped adapter. With the source fix reversed, all 5 tests fail (activation
0, first_index_day null, ingest_stalled true, and "nightly rollup incomplete:
4 day(s) failed, purge failed" once statements pass the 15-minute mark). With
the fix, all 5 pass. `npm run check` is clean in both packages. Against
wrangler dev, run locally only: smoke:rollup 71/71, smoke:cutover 62/62,
smoke:api 122/122 and render-check 87/87. smoke (ingest) is 46/47; the one
failure is the 70 KB oversized-body curl argument hitting the Windows
command-line limit, and main fails it the same way. The new smoke assertions
fail on main.
Takes effect only once both workers are redeployed; no migration.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,8 @@ nothing looks broken from the outside. That ran from 2026-08-11 to October 2026.
|
||||
|
||||
## Rollups & retention (nightly cron)
|
||||
|
||||
`src/rollup.ts` runs on a Cron Trigger at **00:30 UTC** and does two things.
|
||||
`src/rollup.ts` runs on a Cron Trigger at **00:30 UTC** and does two things, the purge
|
||||
first.
|
||||
|
||||
**Rolls up** the day that just ended into `daily_machines`, `daily_event_counts`,
|
||||
`daily_dim_counts` and `machine_first_seen.first_index_day`, then re-runs the two days before
|
||||
@@ -88,7 +89,12 @@ it — offline clients ship completed-day rollups late, so a day keeps growing a
|
||||
It then **catches up** on any earlier day that saw activity but never got a rollup (a
|
||||
`machine_days` day with no `daily_machines` row — a night the run failed or the database
|
||||
refused writes), newest first, up to 31 a night. An outage heals on the first good night
|
||||
instead of leaving a hole someone has to notice. The aggregation is one
|
||||
instead of leaving a hole someone has to notice. Cloudflare ends a Cron Trigger after 15
|
||||
minutes of wall-clock time, and a day that still holds millions of legacy usage rows (below)
|
||||
takes minutes, so the run starts no new day and no new fold chunk once 10 minutes have
|
||||
passed (`NIGHTLY_BUDGET_MS`). A day it stops partway gets no rollup at all, so it is still a
|
||||
missed day the next night, which carries on from the last folded chunk; the summary line
|
||||
counts those days as `deferred`. The aggregation is one
|
||||
`INSERT … SELECT … ON CONFLICT DO UPDATE` per table or dimension, so it happens inside D1 and
|
||||
no event row crosses the wire. Every write overwrites the recomputed value rather than adding
|
||||
to it: **re-running a day is a no-op, never a double count.** Two things the SQL is careful
|
||||
@@ -100,7 +106,9 @@ rollup over old days is the whole migration. Adding a breakdown is a line in `RO
|
||||
never a migration — that is what the generic `(dim, value)` shape buys.
|
||||
|
||||
**Purges** raw `events` and `usage_daily` rows older than `RETENTION_DAYS` (90, a var in
|
||||
`wrangler.jsonc`) in bounded `DELETE` batches, and logs one line of counts. `machine_days` and `machine_first_seen` are
|
||||
`wrangler.jsonc`) in bounded `DELETE` batches, before any rollup: behind a long catch-up it
|
||||
would be the part the 15-minute limit cuts off, night after night, while the database
|
||||
grows. The run ends with one line of counts. `machine_days` and `machine_first_seen` are
|
||||
never purged — retention cohorts need the full history and they are two orders of magnitude
|
||||
smaller. Rollups are kept forever, so shortening the window costs ad-hoc drill-back, never a
|
||||
chart.
|
||||
@@ -122,10 +130,13 @@ each day is a full scan of that day's events, and the request has a wall-clock b
|
||||
|
||||
### Backfilling first_index_day
|
||||
|
||||
`first_index_day` (migration `0002`) is what the dashboard's activation funnel reads, and only
|
||||
the rollup writes it. Days rolled up before the migration left it NULL, and days stored before
|
||||
`first_index_day` (migration `0002`) is what the dashboard's activation funnel reads. The
|
||||
ingest worker lowers it as each index event is stored, so an index run that uploads days late
|
||||
still counts, and the rollup re-derives it from the raw events of every day it rolls up. Days
|
||||
rolled up before the migration left it NULL, an index event stored before the ingest worker
|
||||
began setting it (#2333) counts only once its day is rolled up again, and days stored before
|
||||
`0003` still hold their usage as one `events` row per upload. Re-running the rollup over every
|
||||
day that still has raw events fixes both — it folds that day's legacy usage rows into
|
||||
day that still has raw events fixes all three — it folds that day's legacy usage rows into
|
||||
`usage_daily`, sets `first_index_day`, and recomputes the day's rollups. It is idempotent, so
|
||||
overlapping or repeating a range is harmless. A day with millions of legacy rows takes several
|
||||
minutes, so go a day at a time:
|
||||
@@ -300,6 +311,15 @@ To drive the cron body by hand, run `wrangler dev --test-scheduled` and hit
|
||||
`.dev.vars.example` to `.dev.vars` — without an `ADMIN_TOKEN` the route 404s, exactly as a
|
||||
deploy that never set the secret does.
|
||||
|
||||
The repo's own test suite also covers this worker and the dashboard API without wrangler:
|
||||
it runs their source against these migrations in an in-memory SQLite database, including
|
||||
late uploads and a nightly run whose catch-up outlasts the cron's time limit. From the repo
|
||||
root (it is part of `npm test` there):
|
||||
|
||||
```bash
|
||||
npx vitest run __tests__/telemetry-services.test.ts
|
||||
```
|
||||
|
||||
## Changing the schema
|
||||
|
||||
The allowlist in `src/index.ts` mirrors `docs/design/telemetry.md` (and the user-facing
|
||||
|
||||
@@ -235,11 +235,14 @@ ts "tool calls (sums the prop)" tool_calls '[20]' '[2]'
|
||||
MET=$(api "meta")
|
||||
is "meta reports the rolled-up day" "$DAY" "$(jget "$MET" latest_day)"
|
||||
is "meta reports the rollup ran" "$DAY" "$(jget "$MET" latest_rollup_day)"
|
||||
# Usage lands in usage_daily rather than events; the stalled-ingest check reads both.
|
||||
is "meta reports the last day ingest stored" "$DAY" "$(jget "$MET" latest_ingest_day)"
|
||||
|
||||
# The funnel reads machine_first_seen.first_index_day, which only the nightly
|
||||
# rollup writes (from raw `index` events). This is the seam that pins it: ingest
|
||||
# writes the events, the rollup sets the column, the dashboard reads it. If the
|
||||
# rollup stopped setting it, "activated" here would read 0.
|
||||
# The funnel reads machine_first_seen.first_index_day. The ingest worker sets it as
|
||||
# each index event is stored, and the nightly rollup re-derives it from raw `index`
|
||||
# events (smoke-rollup.sh pins that half, on events it seeds past the ingest path).
|
||||
# This is the seam: ingest writes the column, the dashboard reads it. If neither
|
||||
# writer set it, "activated" here would read 0.
|
||||
#
|
||||
# Its denominator is FIRST-SEEN MACHINES, not `install` events (api.ts: "a machine
|
||||
# that reinstalls does not re-enter the funnel"). m3 is the discriminator: it never
|
||||
|
||||
@@ -214,6 +214,14 @@ is "machine_days: each backdated batch gets its own day" "$BACK_DAY,$RECENT_DAY"
|
||||
is "machine_first_seen recorded" "$RECENT_DAY" "$(q "select first_day from machine_first_seen where machine_id='$M_OK'")"
|
||||
is "machine_first_seen only moves earlier" "$BACK_DAY" \
|
||||
"$(q "select first_day from machine_first_seen where machine_id='$M_BACK'")"
|
||||
# The activation funnel's input is kept here too, not only by the nightly rollup: an
|
||||
# index run that uploads after the cron stopped re-rolling its day must still count.
|
||||
is "first_index_day recorded as the index run is stored" "$RECENT_DAY" \
|
||||
"$(q "select first_index_day from machine_first_seen where machine_id='$M_OK'")"
|
||||
is "first_index_day only moves earlier, like first_day" "$BACK_DAY" \
|
||||
"$(q "select first_index_day from machine_first_seen where machine_id='$M_BACK'")"
|
||||
is "a machine that never sent an index run has none" "null" \
|
||||
"$(q "select coalesce(first_index_day, 'null') from machine_first_seen where machine_id='$M_USE'")"
|
||||
|
||||
echo
|
||||
echo "usage counters add up instead of piling up"
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
# * the purge deletes only rows past the window, and leaves machine_days /
|
||||
# machine_first_seen alone
|
||||
# * each machine's first_index_day is its earliest index, and survives the purge
|
||||
# * the cron catches up on a day that saw activity but was never rolled up
|
||||
# * the cron catches up on a day that saw activity but was never rolled up, and
|
||||
# logs its one summary line
|
||||
# * usage comes from usage_daily; a legacy usage row still in `events` is folded
|
||||
# into it (counts added, row deleted) before the day is rolled up
|
||||
# * /admin/rollup does not exist without ADMIN_TOKEN, and rejects a wrong one
|
||||
@@ -196,6 +197,12 @@ is "rollup $DAY_RESET with reset → 200" 200 "$(roll "day=$DAY_RESET&reset=1")"
|
||||
# rolled up), and purges everything past the window.
|
||||
is "cron trigger → 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/__scheduled?cron=30+0+*+*+*")"
|
||||
sleep 2
|
||||
# Its one summary line: written once the purge and every rollup are done, and with
|
||||
# nothing this small left for the next night by the time budget.
|
||||
is "the cron logs its summary, nothing failed or deferred" "0/0" \
|
||||
"$(grep -ao '{"msg":"nightly rollup".*}' "$LOG" | tail -1 |
|
||||
node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
|
||||
const r=JSON.parse(s||"{}");console.log(`${r.failed}/${r.deferred}`);})')"
|
||||
|
||||
# Rolling a purged day with reset=1 must NOT blank the rollups it already has: past
|
||||
# the window the reset is ignored, so the delete-then-rebuild can't find zero events.
|
||||
|
||||
@@ -200,9 +200,18 @@ const INSERT_EVENT = `INSERT INTO events (
|
||||
const UPSERT_MACHINE_DAY = `INSERT INTO machine_days (machine_id, day, prod) VALUES (?, ?, ?)
|
||||
ON CONFLICT (machine_id, day) DO UPDATE SET prod = max(machine_days.prod, excluded.prod)`;
|
||||
|
||||
// A late-arriving offline buffer can move a machine's first day earlier, never later.
|
||||
const UPSERT_FIRST_SEEN = `INSERT INTO machine_first_seen (machine_id, first_day) VALUES (?, ?)
|
||||
ON CONFLICT (machine_id) DO UPDATE SET first_day = min(machine_first_seen.first_day, excluded.first_day)`;
|
||||
// A late-arriving offline buffer can move a machine's first day earlier, never later —
|
||||
// and its first index day (the activation funnel's input) the same way. That one is set
|
||||
// here as well as by the nightly rollup because the rollup only re-rolls the last three
|
||||
// days, while this path accepts events up to 30 days old: an index run that uploads a
|
||||
// week late would otherwise never count. SQLite's multi-argument min() is NULL when
|
||||
// either side is, hence the coalesce: a batch with no index run leaves the stored day
|
||||
// alone, and the first index run on record sets it.
|
||||
const UPSERT_FIRST_SEEN = `INSERT INTO machine_first_seen (machine_id, first_day, first_index_day) VALUES (?, ?, ?)
|
||||
ON CONFLICT (machine_id) DO UPDATE SET
|
||||
first_day = min(machine_first_seen.first_day, excluded.first_day),
|
||||
first_index_day = coalesce(min(machine_first_seen.first_index_day, excluded.first_index_day),
|
||||
machine_first_seen.first_index_day, excluded.first_index_day)`;
|
||||
|
||||
// usage_rollup counters ADD into one row per machine × day × tool (migrations/0003):
|
||||
// clients upload the same counter many times over — once per process — so storing
|
||||
@@ -305,10 +314,13 @@ async function writeToD1(
|
||||
}
|
||||
}
|
||||
|
||||
// The earliest day this batch indexed on, if it did — see UPSERT_FIRST_SEEN.
|
||||
let firstIndexDay: string | null = null;
|
||||
for (const e of batch) {
|
||||
if (e.event === 'usage_rollup') continue;
|
||||
const day = (e.ts ?? receivedAt).slice(0, 10);
|
||||
days.add(day);
|
||||
if (e.event === 'index' && (firstIndexDay === null || day < firstIndexDay)) firstIndexDay = day;
|
||||
stmts.push(
|
||||
insertEvent.bind(
|
||||
receivedAt,
|
||||
@@ -328,7 +340,7 @@ async function writeToD1(
|
||||
|
||||
const firstDay = [...days].sort()[0];
|
||||
if (firstDay !== undefined) {
|
||||
stmts.push(env.DB.prepare(UPSERT_FIRST_SEEN).bind(machineId, firstDay));
|
||||
stmts.push(env.DB.prepare(UPSERT_FIRST_SEEN).bind(machineId, firstDay, firstIndexDay));
|
||||
}
|
||||
|
||||
await env.DB.batch(stmts);
|
||||
|
||||
+108
-41
@@ -9,12 +9,13 @@
|
||||
* 1. ROLL UP the just-completed UTC day into `daily_machines`, `daily_event_counts`,
|
||||
* `daily_dim_counts` and `machine_first_seen.first_index_day` — plus the two days
|
||||
* before it, because clients buffer offline and ship completed-day rollups late, so
|
||||
* a day keeps growing after it ends, plus any earlier day a failed run missed. Every
|
||||
* write is an upsert that OVERWRITES the recomputed value rather than adding to it
|
||||
* (or, for first_index_day, only ever lowers it), so re-running a day is a no-op
|
||||
* and never double-counts.
|
||||
* a day keeps growing after it ends, plus any earlier day a failed run missed, as
|
||||
* many as fit in the run's time budget. Every write is an upsert that OVERWRITES
|
||||
* the recomputed value rather than adding to it (or, for first_index_day, only ever
|
||||
* lowers it), so re-running a day is a no-op and never double-counts.
|
||||
*
|
||||
* 2. PURGE raw `events` past the retention window, in bounded batches. Rollups are
|
||||
* 2. PURGE raw `events` past the retention window, in bounded batches — first, so a
|
||||
* long catch-up can never push it past the cron's wall-clock limit. Rollups are
|
||||
* kept forever, so only ad-hoc drill-down has a horizon; `machine_days` and
|
||||
* `machine_first_seen` are never purged, because retention cohorts need the full
|
||||
* history and they are two orders of magnitude smaller than the raw rows.
|
||||
@@ -32,6 +33,15 @@ export const ROLLUP_LOOKBACK_DAYS = 3;
|
||||
export const MAX_MANUAL_DAYS = 31;
|
||||
/** Most missed days one nightly run catches up on, newest first; the next night takes the rest. */
|
||||
export const MAX_CATCHUP_DAYS = 31;
|
||||
/**
|
||||
* How long into a nightly run it still starts rollup work. Cloudflare ends a Cron
|
||||
* Trigger after 15 minutes of wall-clock time, and catching up a day that still holds
|
||||
* millions of legacy usage rows takes minutes, so a backlog can run straight into that
|
||||
* limit. Past this point the run starts no new day and no new fold chunk; the 5
|
||||
* minutes left cover the one statement already in flight and the summary line. What
|
||||
* did not fit stays a missed day, so the next night carries on from there.
|
||||
*/
|
||||
export const NIGHTLY_BUDGET_MS = 10 * 60_000;
|
||||
|
||||
/** Rows per purge DELETE — bounded so one statement stays well inside D1's limits. */
|
||||
const PURGE_BATCH_ROWS = 5_000;
|
||||
@@ -148,6 +158,10 @@ const DAILY_MACHINES = `INSERT INTO daily_machines (day, machines, prod_machines
|
||||
* it straight off `machine_first_seen`, which keeps the funnel off raw `events` (a
|
||||
* cohort join there took most of a minute per week of cohorts) and past the purge.
|
||||
*
|
||||
* The ingest path lowers it the same way as each index event is stored (src/index.ts),
|
||||
* because a run that uploads late lands on a day this cron has stopped re-rolling. This
|
||||
* statement is what fills it for events stored before that, and what a backfill re-runs.
|
||||
*
|
||||
* Only rows that actually move are written, so re-running a day is free. `?1` is the
|
||||
* day — still the one bound parameter, used three times.
|
||||
*/
|
||||
@@ -221,6 +235,11 @@ export interface DayResult {
|
||||
rows: number;
|
||||
/** Day is past the retention window — a `reset` on it is ignored (see below). */
|
||||
pastRetention: boolean;
|
||||
/**
|
||||
* The deadline passed before the day's legacy usage rows were all folded, so nothing
|
||||
* was rolled up: the next run finishes the fold and rolls the day up then.
|
||||
*/
|
||||
deferred: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,14 +255,20 @@ export interface DayResult {
|
||||
* exists would otherwise linger. It is IGNORED past the retention window, where it
|
||||
* would delete rows and then find no events to rebuild them from: silently blanking a
|
||||
* real day is the one irreversible thing this file could do.
|
||||
*
|
||||
* `deadline` (epoch ms) bounds the legacy fold that runs first. If it stops the fold
|
||||
* partway, the day is not rolled up at all — usage counted from a half-folded day would
|
||||
* be short, and its `daily_machines` row would stop the nightly run from ever coming
|
||||
* back to it. The chunks already folded are committed, so the next call carries on.
|
||||
*/
|
||||
export async function rollupDay(
|
||||
env: Env,
|
||||
day: string,
|
||||
opts: { cutoff: string; reset?: boolean },
|
||||
opts: { cutoff: string; reset?: boolean; deadline?: number },
|
||||
): Promise<DayResult> {
|
||||
const pastRetention = day < opts.cutoff;
|
||||
await foldLegacyUsage(env, day);
|
||||
const fold = await foldLegacyUsage(env, day, opts.deadline);
|
||||
if (!fold.complete) return { day, rows: 0, pastRetention, deferred: true };
|
||||
const statements: D1PreparedStatement[] = [];
|
||||
|
||||
if (opts.reset && !pastRetention) {
|
||||
@@ -257,7 +282,7 @@ export async function rollupDay(
|
||||
|
||||
const results = await env.DB.batch(statements);
|
||||
const rows = results.reduce((total, r) => total + (r.meta?.changes ?? 0), 0);
|
||||
return { day, rows, pastRetention };
|
||||
return { day, rows, pastRetention, deferred: false };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -298,15 +323,32 @@ const LEGACY_FOLD = `INSERT INTO usage_daily (
|
||||
|
||||
const LEGACY_DELETE = `DELETE FROM events WHERE day = ?1 AND event = 'usage_rollup' AND id < ?2`;
|
||||
|
||||
/** Folds any legacy usage rows for `day` into usage_daily. Returns how many rows it moved. */
|
||||
export async function foldLegacyUsage(env: Env, day: string): Promise<number> {
|
||||
export interface FoldResult {
|
||||
/** Legacy rows moved into usage_daily by this call. */
|
||||
moved: number;
|
||||
/** False when the deadline stopped it with rows still to fold; the next call carries on. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds any legacy usage rows for `day` into usage_daily, a chunk per transaction.
|
||||
* With a `deadline` (epoch ms) it starts no chunk once that has passed: each chunk
|
||||
* commits on its own and always takes the oldest rows left, so stopping between two
|
||||
* loses nothing and the next call simply continues.
|
||||
*/
|
||||
export async function foldLegacyUsage(
|
||||
env: Env,
|
||||
day: string,
|
||||
deadline = Number.POSITIVE_INFINITY,
|
||||
): Promise<FoldResult> {
|
||||
const any = await env.DB.prepare(`SELECT 1 AS hit FROM events WHERE day = ? AND event = 'usage_rollup' LIMIT 1`)
|
||||
.bind(day)
|
||||
.first<{ hit: number }>();
|
||||
if (!any) return 0;
|
||||
if (!any) return { moved: 0, complete: true };
|
||||
|
||||
let moved = 0;
|
||||
for (let chunk = 0; chunk < LEGACY_MAX_CHUNKS; chunk++) {
|
||||
if (Date.now() >= deadline) return { moved, complete: false };
|
||||
// Always the oldest remaining rows: everything below the next chunk's first id.
|
||||
// Rows folded by earlier chunks are gone, so there is no cursor to carry.
|
||||
const next = await env.DB.prepare(
|
||||
@@ -320,7 +362,7 @@ export async function foldLegacyUsage(env: Env, day: string): Promise<number> {
|
||||
env.DB.prepare(LEGACY_DELETE).bind(day, below),
|
||||
]);
|
||||
moved += deleted?.meta?.changes ?? 0;
|
||||
if (!next) return moved;
|
||||
if (!next) return { moved, complete: true };
|
||||
}
|
||||
throw new Error(`legacy usage fold for ${day} did not finish within ${LEGACY_MAX_CHUNKS} chunks`);
|
||||
}
|
||||
@@ -399,44 +441,30 @@ async function missedDays(env: Env, cutoff: string, before: string): Promise<str
|
||||
}
|
||||
|
||||
/**
|
||||
* The cron body: roll up the completed day and the two before it, catch up any
|
||||
* earlier day a past run missed, then purge.
|
||||
* The cron body: purge, roll up the completed day and the two before it, then catch
|
||||
* up any earlier day a past run missed, for as long as the time budget lasts.
|
||||
*
|
||||
* The purge goes first. It is bounded, it deletes only days past the window (which
|
||||
* nothing below reads), and it is the job that must not slip: a database that stops
|
||||
* purging grows until D1's size cap refuses every write. Run last, it was what
|
||||
* Cloudflare's 15-minute limit cut off whenever a catch-up ran long, night after night.
|
||||
*
|
||||
* Rollups then start until NIGHTLY_BUDGET_MS has passed. A day the budget stops —
|
||||
* before it starts, or partway through its legacy fold — gets no rollup at all, so it
|
||||
* is still a missed day tomorrow and picks up where it stopped (each fold chunk is
|
||||
* already committed).
|
||||
*
|
||||
* Logs one line of counts — never a day's contents, never a machine id. Throws if
|
||||
* anything failed so the invocation is marked failed (and retried) rather than
|
||||
* quietly skipping a day; every write here is idempotent, so a retry is safe.
|
||||
* quietly skipping a day; every write here is idempotent, so a retry is safe. A day
|
||||
* left for tomorrow is not a failure.
|
||||
*/
|
||||
export async function runNightly(env: Env, atMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
const deadline = started + NIGHTLY_BUDGET_MS;
|
||||
const keepDays = retentionDays(env);
|
||||
const cutoff = retentionCutoff(atMs, keepDays);
|
||||
|
||||
const days: string[] = [];
|
||||
for (let back = 1; back <= ROLLUP_LOOKBACK_DAYS; back++) days.push(utcDay(atMs - back * DAY_MS));
|
||||
|
||||
// A failure to find the missed days must not cost tonight's regular rollup.
|
||||
let caughtUp = 0;
|
||||
try {
|
||||
const missed = await missedDays(env, cutoff, days[days.length - 1] ?? utcDay(atMs));
|
||||
days.push(...missed);
|
||||
caughtUp = missed.length;
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({ msg: 'missed-day scan failed', err: String(err) }));
|
||||
}
|
||||
|
||||
const rolled: string[] = [];
|
||||
const failed: string[] = [];
|
||||
let rows = 0;
|
||||
for (const day of days) {
|
||||
try {
|
||||
rows += (await rollupDay(env, day, { cutoff })).rows;
|
||||
rolled.push(day);
|
||||
} catch (err) {
|
||||
failed.push(day);
|
||||
console.error(JSON.stringify({ msg: 'rollup day failed', day, err: String(err) }));
|
||||
}
|
||||
}
|
||||
|
||||
let purge: PurgeResult | null = null;
|
||||
try {
|
||||
purge = await purgeOldEvents(env, cutoff);
|
||||
@@ -444,11 +472,50 @@ export async function runNightly(env: Env, atMs: number): Promise<void> {
|
||||
console.error(JSON.stringify({ msg: 'purge failed', cutoff, err: String(err) }));
|
||||
}
|
||||
|
||||
const regular: string[] = [];
|
||||
for (let back = 1; back <= ROLLUP_LOOKBACK_DAYS; back++) regular.push(utcDay(atMs - back * DAY_MS));
|
||||
|
||||
// A failure to find the missed days must not cost tonight's regular rollup.
|
||||
let missed: string[] = [];
|
||||
try {
|
||||
missed = await missedDays(env, cutoff, regular[regular.length - 1] ?? utcDay(atMs));
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({ msg: 'missed-day scan failed', err: String(err) }));
|
||||
}
|
||||
|
||||
const rolled: string[] = [];
|
||||
const failed: string[] = [];
|
||||
const deferred: string[] = [];
|
||||
let caughtUp = 0;
|
||||
let rows = 0;
|
||||
for (const day of [...regular, ...missed]) {
|
||||
if (Date.now() >= deadline) {
|
||||
deferred.push(day);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await rollupDay(env, day, { cutoff, deadline });
|
||||
if (result.deferred) {
|
||||
deferred.push(day);
|
||||
continue;
|
||||
}
|
||||
rows += result.rows;
|
||||
rolled.push(day);
|
||||
if (!regular.includes(day)) caughtUp++;
|
||||
} catch (err) {
|
||||
failed.push(day);
|
||||
console.error(JSON.stringify({ msg: 'rollup day failed', day, err: String(err) }));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
msg: 'nightly rollup',
|
||||
days: rolled,
|
||||
/** Missed days rolled up tonight. */
|
||||
caught_up: caughtUp,
|
||||
/** Days the time budget left for the next run. */
|
||||
deferred: deferred.length,
|
||||
rows,
|
||||
failed: failed.length,
|
||||
retention_days: keepDays,
|
||||
|
||||
Reference in New Issue
Block a user