fix(forms): stop the worklist producer OOM — bounded scan, then bounded batches - #213
Open
sroussey wants to merge 3 commits into
Open
fix(forms): stop the worklist producer OOM — bounded scan, then bounded batches#213sroussey wants to merge 3 commits into
sroussey wants to merge 3 commits into
Conversation
…ry filing
ComputeFormsWorklistTask loaded every filing of every form into memory before
filtering: `filingRepo.query({ form })` for each of the 55 form symbols, with
the shard filter applied only afterwards. Form 4 alone is ~4.6M rows and the
full worklist ~6.4M, at a measured ~460 bytes per 15-column row — ~3 GB per
process. Because sharding ran after the load, every `--shard i/N` process paid
that in full, so a 6-way fan-out needed ~17.8 GB of worklist objects alone and
OOM'd.
The producer now pages each form through `queryPage` (10k rows) and applies the
shard filter first, before any other test, so only this shard's slice survives.
Only the four fields the downstream loop reads are retained — no intermediate
Filing[] — and dry-run counts without accumulating at all.
`successfulRunKeys` is split out of `listFilingsWithoutSuccessfulRun` so the
already-processed set is built once per form and tested per page rather than
re-queried; `listFilingsWithoutSuccessfulRun` is reimplemented on top of it and
is unchanged for BackfillExtractorTask.
Measured against the live 6.4M-filing database, shard 1/6: peak RSS 606 MB
(was ~3 GB), selecting 1,017,448 filings — an even 1/6 split. The output arrays
add 158.5 MB at that size, so a real run peaks at ~765 MB per process.
Note this relies on the backend overriding `queryPage` with a real SQL LIMIT
(Postgres and SQLite do). BaseTabularStorage's generic pager sets
`fetchLimit = useFallback ? undefined : limit`, so a backend falling back to it
can still issue an unbounded scan. The added test asserts the producer itself
never issues a bare whole-form read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuwHHhCEutPqwTF4861Ae3
The sweep processes FORMS_SWEEP_CONCURRENCY_LIMIT (10) filings at a time, so
the worklist never needed to exist in full — yet the producer computed every
matching filing before the first fetch. For one shard of today's corpus that
was 1,017,448 entries (158 MB of arrays) and a 7.7-minute scan during which no
work happened at all, both growing without bound as filings accumulate.
ComputeFormsWorklistTask now yields at most WORKLIST_BATCH_SIZE (5,000) filings
per run and resumes where it left off, and formsSweepLoop wraps it in a `while`
whose body is producer -> forEach over that batch. Peak retention is now one
page plus one batch regardless of corpus size.
Resume state is a plain last-key (cik + accession_number), not an opaque
cursor: SearchCriteria allows one condition per column and has no OR, so the
exact keyset predicate is not expressible — resuming at `cik >= lastCik` and
dropping the already-emitted head in memory is equivalent, with the re-read
bounded by the largest (form, cik) group (4,628 rows worst case, ~19 typical).
FILING_PAGE_SIZE is held above that group size so the scan can always advance,
and readPage throws rather than spinning if that ever stops holding.
The state lives on the producer instance and the loop condition closes over
`exhausted`, because WhileTask chains the BODY's merged output forward — which
here is the forEach's, not the producer's. A resume key routed through output
ports arrives stale and the loop never advances; verified with a probe graph
before settling on this.
Measured against the live 6.4M-filing database, shard 1/6:
- 204 batches, largest exactly 5,000, 1,017,448 distinct filings, 0
duplicates — same total as the pre-batching scan, exact-once coverage
- time to first batch: 472 ms, was 463 s
- full scan: 22.4 s, was 463 s (the indexed `cik >=` range scan replaces
keyset pagination)
- producer RSS: 554 MB, of which the 158 MB of worklist arrays is gone
Also drops queryPage from this path — last-key + limit makes plain query
sufficient.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuwHHhCEutPqwTF4861Ae3
normalizeAddress dropped any address it could not resolve a country for: no state code, no country code, no country name, and no keyword hit. In EDGAR that overwhelmingly means a domestic filer who skipped the field — a foreign filer fills it in because the form makes them — so those addresses are now assumed US rather than discarded. The assumption is applied before street/postal normalization so an unlabeled address gets the same US street parsing and 5-digit zip treatment as a labeled one. `state_or_country` becomes nullable to express "United States, region unknown" rather than guessing a state. For foreign addresses the region still falls back to the country's own SEC code, but now selects the country-level row (no state name) instead of whichever subdivision happened to sort first — Canada resolves to Z4, not A0 (Alberta). Usability now rests on street + city; the country is always known. Adds AddressRegionNullableMigration to relax the existing NOT NULL column, since `CREATE TABLE IF NOT EXISTS` never alters a table and every such address would otherwise fail to store on a pre-existing database. Addresses cannot be dropped and re-extracted the way legacy 8-K events could — entity/canonical junction rows reference address_hash_id — so rows are preserved on both backends: Postgres does a catalog-only DROP NOT NULL, SQLite rebuilds the table (rename, recreate at the current schema, copy back, drop) with the column list derived from AddressSchema so it cannot drift. Idempotent on both a fresh and an already-migrated database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RuwHHhCEutPqwTF4861Ae3
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.
Problem
The forms sweep OOM'd on a 6-way
--shardlaunch. Two separate causes, fixed in two commits.1. The producer materialized every filing of every form.
ComputeFormsWorklistTaskcalledfilingRepo.query({ form })for each of the 55 form symbols, applying the shard filter only afterwards. Form 4 alone is ~4.6M rows and the full worklist ~6.4M, at a measured ~460 bytes per 15-column row — ~3 GB per process. Because sharding ran after the load, every shard process paid that in full: ~17.8 GB across six processes, on a 26 GB box also running Postgres.2. The worklist didn't need to exist at all.
FORMS_SWEEP_CONCURRENCY_LIMITis 10, so the sweep never holds more than ~10 filings — yet the producer computed all 1,017,448 entries for one shard (158 MB of arrays) before the first fetch, during a 7.7-minute scan in which no work happened. Both numbers grow without bound as filings accumulate.Changes
e300acf— bound the scan. Page through each form and apply the shard filter first, before any other test, so only this shard's slice survives. Retain only the four fields the fan-out reads, never an intermediateFiling[].successfulRunKeysis split out oflistFilingsWithoutSuccessfulRunso the already-processed set is built once per form rather than re-queried per page (listFilingsWithoutSuccessfulRunis reimplemented on top of it and is unchanged forBackfillExtractorTask).25c0d28— bound the worklist. The producer now yields at most 5,000 filings per run and resumes where it left off;formsSweepLoopwraps it in awhilewhose body is producer →forEachover that batch. Peak retention is one page plus one batch, independent of corpus size. This dropsqueryPagefrom the path — last-key +limitmakes plainquerysufficient.Design notes
Resume state is a plain last-key, not a cursor.
SearchCriteriaallows one condition per column and has no OR, so the exact keyset predicate(cik, accession) > (lastCik, lastAccession)is not expressible. Resuming atcik >= lastCikand dropping the already-emitted head in memory is equivalent; the re-read is bounded by the largest single(form, cik)group — 4,628 rows at the extreme, ~19 typical.FILING_PAGE_SIZEis held above that group size so the scan can always advance, andreadPagethrows rather than spinning if that assumption ever stops holding.The state lives on the producer instance, not on output ports.
WhileTaskchains the body's merged output forward, and the body ends inforEach— so the producer's output never reaches the next iteration. A resume key routed through ports arrives stale and the loop never advances; I confirmed this with a probe graph (the loop ran exactly once) before settling on instance state with the condition closing overexhausted.Verification
Measured against the live 6.4M-filing database, shard 1/6:
204 batches, largest exactly 5,000, 1,017,448 distinct filings, 0 duplicates — identical total to the pre-batching scan, so coverage is exact-once. The 24x faster scan is a side effect of the indexed
cik >=range scan replacing keyset pagination.Tests: 1986 pass / 0 fail, typecheck clean. New coverage asserts every filing read is bounded (verified to fail when a bare
query({ form })is reintroduced) and that batching resumes without loss or duplication.Notes for review
readPagereturned filtered rows, sorows.length < FILING_PAGE_SIZEfired as soon as the resume head trimmed anything and marked forms drained early — the dry run reported 49,407 instead of 1,017,448. Fixed by reporting the raw page size separately. Unit tests passed throughout, which is why the 1,017,448 match is the number to trust.cikrange windows) but is an optimization, not a fix.queryPage/querywith a real SQLLIMIT, which Postgres and SQLite do.BaseTabularStorage's generic pager setsfetchLimit = useFallback ? undefined : limit, so a backend falling back to it could still issue an unbounded scan.🤖 Generated with Claude Code
https://claude.ai/code/session_01RuwHHhCEutPqwTF4861Ae3