Scope extractor_runs queries to candidate filings, not corpus - #327
Merged
Conversation
`PRAGMA cache_size` is stated in PAGES when its argument is positive, so `1000000` was a ~4 GB ceiling at these databases' 4 KiB page size — on a cache that fills as a sweep touches pages and never shrinks, which reads as a slow leak because the growth is in the pager rather than the JS heap. Measured scanning a 271 MB database: +31 MB RSS at the old setting versus +3 MB with a small cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsYGzG99SnoxeQbdAqijBS
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsYGzG99SnoxeQbdAqijBS
`successfulRunKeys` read every successful run for an extractor in one un-LIMITed `SELECT *` and its caller held the resulting Set for the life of the form. Measured at 289 bytes per key retained and ~484 bytes per row transient, Form D's ~1M successful runs cost ~300 MB held plus a ~500 MB spike — paid in full by a nightly sweep with two new filings to do, and paid again by every `--shard` process. On Postgres the spike is worse than the arithmetic suggests: the driver buffers the entire result set before any JS Set exists. `successfulRunKeysForFilings` asks only about the candidates it is handed, keyed on `cik` (the primary key's leading column, so each chunk is an index seek) and chunked at 900 for SQLite's bind cap. There is no two-column `in`, so a chunk also returns that CIK's other accessions — the result is filtered against the candidates' own keys so a filer with tens of thousands of filings cannot inflate the Set beyond the page that asked for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsYGzG99SnoxeQbdAqijBS
One chunked `extractor_runs` lookup per page, over the rows that pass the cheap shard/CIK/date tests, replaces the corpus-wide Set that was built once per form and held for its whole scan. At Form D's size that Set was hundreds of MB resident before the first filing was fetched, and every `--shard` process paid it in full. Reading per page also closes a staleness window: a multi-hour sweep used to test every batch against a snapshot taken before its first filing was processed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsYGzG99SnoxeQbdAqijBS
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsYGzG99SnoxeQbdAqijBS
`loadSuccessfulKeys` asked for every successful run of each extractor across the whole corpus — form-unscoped, the widest variant of that query — and held one such Set per extractor id simultaneously, to answer a question about a single issuer's handful of filings. `sec sync spacs` walks thousands of CIKs and paid it again for each one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsYGzG99SnoxeQbdAqijBS
…rpus
`query({form})` materialized every `Filing` row of the form to read two or
three columns off each. At a measured ~460 bytes per 15-column row that is
several hundred MB for Form D, and 8-K is larger still. Keyset-resume a
page at a time — two queries per page, since SearchCriteria has no OR, so
the exact `(cik, accession) > (last, last)` predicate is "rest of this CIK"
then "later CIKs". That is what lets one serial filer hold more filings of
a form than the page size without stalling the scan.
The slim candidate list is still materialized in full: `selected` /
`skipped` are counts over it and `filterTodo` is handed the whole set.
Three properties the whole-table variant did not have: it reads only the candidates' CIKs, it drops the other accessions those CIKs bring back (there is no two-column `in`), and it answers a page wider than SQLite's 900-bind cap in full rather than truncating to the first chunk.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR refactors how successful extractor_runs are queried during large sweeps so lookups are scoped to the current candidate filings/page instead of materializing corpus-wide sets, significantly reducing peak memory usage during processing.
Changes:
- Refactors
ExtractorRunRepoto query successful runs only for the candidate filings’ CIKs (chunked to stay under SQLite bind limits) and filter results back to the candidate set. - Updates forms sweep and SPAC timeline processing to apply cheap in-memory eligibility checks first, then perform one scoped
extractor_runslookup per page/issuer. - Introduces a configurable SQLite page-cache ceiling (
SEC_SQLITE_CACHE_MB, default 256 MB) and applies it via a negativePRAGMA cache_size(KiB units).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/storage/versioning/ExtractorRunRepo.ts | Replaces corpus-wide successful-run materialization with candidate-scoped, chunked lookups filtered to the candidate set. |
| src/storage/versioning/ExtractorRunRepo.test.ts | Adds tests validating scoping to candidate CIKs, filtering out non-candidate accessions, and chunking beyond SQLite bind limits. |
| src/task/forms/ComputeFormsWorklistTask.ts | Moves to per-page successful-run lookups after cheap eligibility filters; removes long-lived per-form successful-key cache. |
| src/task/forms/backfillDescriptors.ts | Adds paged form scanning (pageFilingsOfForm) to avoid loading entire form corpuses into memory during backfill candidate selection. |
| src/task/spac/ProcessSpacTimelineTask.ts | Scopes successful-run key loading to the current issuer’s timeline filings and routed extractor ids. |
| src/config/Constants.ts | Adds SecSqliteCacheMb with env override + clamping to control SQLite pager cache growth. |
| src/util/db.ts | Applies the new cache ceiling using negative PRAGMA cache_size (KiB form). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
Summary
This change refactors how the system queries successful extractor runs to dramatically reduce memory usage during large-scale processing. Instead of loading all successful runs for an extractor into memory at once, queries are now scoped to only the candidate filings being examined, with results chunked to respect SQLite's parameter binding limits.
Key Changes
ExtractorRunRepo: Renamed
successfulRunKeys()tosuccessfulRunKeysForFilings()and refactored to:SQLITE_MAX_VARIABLE_NUMBERlimit)ComputeFormsWorklistTask: Updated to:
successfulRunKeysForFilings()once per page with only the eligible filingspassesCheapTests()methodsuccessfulKeyscache that was held for the entire form scanbackfillDescriptors.ts: Refactored filing selection to:
pageFilingsOfForm()generator for keyset-resume paginationBACKFILL_PAGE_SIZE = 10_000to bound memoryselectFilingsByForms()andspacTrigger8KSelector()ProcessSpacTimelineTask: Updated
loadSuccessfulKeys()to:sec sync spacsConstants.ts: Added
SecSqliteCacheMbconfiguration:cache_size = 1000000(which was ~4 GB) with configurable 256 MB defaultSEC_SQLITE_CACHE_MBenv vardb.ts: Updated SQLite pragma to use negative
cache_size(KiB form) with the new constantNotable Implementation Details
The chunking strategy in
successfulRunKeysForFilings()is necessary because SQLite has no two-columnINoperator; querying by CIK returns all accessions for those CIKs, so results must be filtered against the candidate set to prevent a high-volume filer from inflating the Set beyond the page size.Keyset-resume pagination in
pageFilingsOfForm()uses two queries per page: one for remaining accessions of the last CIK, then one for later CIKs. This prevents stalling on serial filers with more filings than the page size.The cheap tests (shard, CIK allow-list, filing date) are applied before the
extractor_runslookup to minimize query scope—under--shard 1/6this reduces the lookup to ~1/6 of the page.All changes maintain backward compatibility; the refactoring is purely an optimization that changes memory characteristics without altering query results.
Testing
Added three new test cases to
ExtractorRunRepo.test.ts:https://claude.ai/code/session_01CsYGzG99SnoxeQbdAqijBS