Skip to content

fix(forms): stop the worklist producer OOM — bounded scan, then bounded batches - #213

Open
sroussey wants to merge 3 commits into
mainfrom
forms-worklist-streaming
Open

fix(forms): stop the worklist producer OOM — bounded scan, then bounded batches#213
sroussey wants to merge 3 commits into
mainfrom
forms-worklist-streaming

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

Problem

The forms sweep OOM'd on a 6-way --shard launch. Two separate causes, fixed in two commits.

1. The producer materialized every filing of every form. ComputeFormsWorklistTask called filingRepo.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_LIMIT is 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 intermediate Filing[]. successfulRunKeys is split out of listFilingsWithoutSuccessfulRun so the already-processed set is built once per form rather than re-queried per page (listFilingsWithoutSuccessfulRun is reimplemented on top of it and is unchanged for BackfillExtractorTask).

25c0d28 — bound the worklist. The producer now yields at most 5,000 filings per run and resumes where it left off; formsSweepLoop wraps it in a while whose body is producer → forEach over that batch. Peak retention is one page plus one batch, independent of corpus size. This drops queryPage from the path — last-key + limit makes plain query sufficient.

Design notes

Resume state is a plain last-key, not a cursor. SearchCriteria allows one condition per column and has no OR, so the exact keyset predicate (cik, accession) > (lastCik, lastAccession) is not expressible. Resuming at cik >= lastCik and 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_SIZE is held above that group size so the scan can always advance, and readPage throws rather than spinning if that assumption ever stops holding.

The state lives on the producer instance, not on output ports. WhileTask chains the body's merged output forward, and the body ends in forEach — 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 over exhausted.

Verification

Measured against the live 6.4M-filing database, shard 1/6:

before after
time to first batch 463 s 472 ms
full scan 463 s 22.4 s
worklist arrays 158 MB ~0.8 MB
peak RSS ~3 GB (est. ~17.8 GB across 6) 554 MB

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

  • A bug shipped mid-build and only the full-scale run caught it: readPage returned filtered rows, so rows.length < FILING_PAGE_SIZE fired 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.
  • Each shard still reads all 6.4M rows to keep its 1/6. That's now 22 s spread across the run rather than a startup stall. Pushing the shard into SQL is possible (a stored shard column, or cik range windows) but is an optimization, not a fix.
  • Bounded paging assumes the backend overrides queryPage/query with a real SQL LIMIT, which Postgres and SQLite do. BaseTabularStorage's generic pager sets fetchLimit = 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

sroussey and others added 3 commits July 25, 2026 05:39
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant