Make the scheduled jobs incremental, with a --full sweep for manual edits - #104
Merged
wasabipesto merged 1 commit intoAug 19, 2026
Merged
Conversation
…dits The 6-hourly jobs run was re-deriving everything from scratch every time: consensus re-evaluated all ~7M fields that have ever had a detailed submission (one query each), and downsampling loaded every canon submission of each base in a single query - 3.58M rows for base 49, 40-70GB once the jsonb columns were decoded, then cloned wholesale again for base-level stats. Measured effects in production: ~80 minutes per run at nearly all of the machine's memory, evicting the page cache that postgres (128MB shared_buffers, 305GB database) depends on, causing an hour-long window of API pool-timeout 503s every six hours. Neither pass needs history. Consensus is a pure function of a field's submissions, so only fields with new submissions can change; chunk and base statistics derive from field check levels and canon submissions, which move only at submit time or in the consensus pass. A new one-row job_state table stores the highest processed submission id; each run: - evaluates consensus only for fields with new detailed submissions in the window (one pkey range scan to find them, not a per-base sweep) - recomputes statistics only for chunks whose fields received any submission (niceonly submissions bump check levels too), skipping untouched bases entirely - streams what it does read: canon submissions are fetched one chunk at a time and folded through DistributionAccumulator/NumbersAccumulator, which are batch-order-independent (unit tested), so peak memory tracks the largest chunk instead of the largest base - takes base totals from the ~100 freshly-updated chunk rows instead of re-aggregating over every field - advances the watermark, held back by a safety margin because MAX(id) is not a transactionally safe boundary: an id allocated before the snapshot can commit after it. Held-back submissions are simply re-examined next run; everything keyed on the watermark is idempotent. A run that fails partway leaves the watermark untouched. Manual edits that create no submission are invisible to the watermark - disqualifying a submission in particular. `--full` (just jobs-full) is the previous sweep-everything behavior for exactly that case. Verified end-to-end against a seeded database: disqualifying a canon submission is missed by the incremental run (expected) and fully propagated by the sweep - field reset to CL1, chunk checked_detailed and distribution shrink accordingly, and the submission's nice numbers drop out of the chunk and base top-numbers lists. Logging drops from one line per field/chunk (millions per run through cronic) to one line per change plus per-base summaries. Migration: schema/migrations/2026-08-20_job_state.sql. It initializes the watermark to the current max submission id, assuming stats are current as of applying it; run `just jobs-full` once after deploying if in doubt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The 6-hourly jobs run takes ~80 minutes at 40–70 GB RSS and causes an hour-long window of API pool-timeout 503s every run (prometheus shows it plainly: 10:48–12:08 UTC today, every 5-minute bucket elevated). Root causes, quantified against production:
get_fields_in_base_with_detailed_subshas no notion of "new" — 3.70M fields for base 49, ~7M across bases, one query each, every run.load(), materialized as decodedserde_json::Values (~10–20 KB each against 1.5 KB on disk) — tens of GB transient — then the converted set was cloned wholesale for base-level stats, plus a.cloned()per chunk. Evicting the page cache (postgres runs 128 MBshared_buffersagainst a 305 GB database) is what turns this into API 503s.Neither pass needs history: consensus is a pure function of a field's submissions, and chunk/base stats derive from field check levels + canon submissions, which move only at submit time or in the consensus pass itself.
What this does
Incremental by default, bounded by a one-row
job_statewatermark (highest processed submission id):MAX(id)is not a transactionally safe boundary (an id allocated before the snapshot can commit after it), and everything keyed on the watermark is idempotent, so re-examining a few seconds' worth of stragglers is the cheap side of that trade. A run that fails partway leaves the watermark untouched and the window is redone.Streaming everywhere it still reads bulk data. Canon submissions are fetched one chunk at a time and folded through new
DistributionAccumulator/NumbersAccumulatortypes — batch-order-independent by construction (the histogram is a count-sum; a top-N of a set is contained in the top-N of every superset), with unit tests asserting fold-in-batches ≡ single-pass. Peak memory now tracks the largest chunk (~tens of MB) instead of the largest base, in full mode too. The old whole-base query and its double-clone are deleted.--full/just jobs-full: the previous behavior, kept for manual edits. Disqualifying a submission creates no new submission, so the watermark cannot see it — the full sweep re-evaluates every field with detailed submissions and recomputes every chunk and base, then advances the watermark like any run.Verified end-to-end against a seeded database
The disqualification lifecycle specifically, since that's the workflow that must stay trustworthy:
Skipped 1 bases with no new submissions, 0 work, 0.0sjust jobs-fullchecked_detailed130000→129000, distribution total follows, the disqualified submission's nice number drops out of chunk and base top-numbers, base totals updateAlso covered: the full sweep trued up a chunk whose
minimum_clhad never been written (seeded state no real flow produces), confirming its audit role. Accumulator equivalence has pure unit tests, including across the top-N compaction boundary; the fullnice_commonsuite passes (105 tests).Deploying
schema/migrations/2026-08-20_job_state.sql(creates the table and initializes the watermark to the current max submission id — noCONCURRENTLY, no locks of note, safe anytime). It assumes stats are current as of applying; if in doubt,just jobs-fullonce.just jobsis now incremental;just jobs-fullis the manual sweep.Expected steady state: minutes per run at <100 MB, no more 6-hourly 503 windows. The full sweep keeps its old ~hour duration (consensus is still one query per field there — an acceptable cost for a manual audit path, and its memory problem is fixed by the streaming regardless).
Logging drops from one line per field/chunk — millions of lines per run through cronic — to one line per change plus per-base summaries. Measured honestly: that's log hygiene, not performance; stdout writes were never a material cost.
🤖 Generated with Claude Code