Skip to content

Scope extractor_runs queries to candidate filings, not corpus - #327

Merged
sroussey merged 8 commits into
mainfrom
claude/embarc-data-sync-memory-leak-emf6gc
Aug 24, 2026
Merged

Scope extractor_runs queries to candidate filings, not corpus#327
sroussey merged 8 commits into
mainfrom
claude/embarc-data-sync-memory-leak-emf6gc

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

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() to successfulRunKeysForFilings() and refactored to:

    • Accept the candidate filings as input and scope queries to only their CIKs
    • Chunk CIK lists by 900 (respecting SQLite's SQLITE_MAX_VARIABLE_NUMBER limit)
    • Filter results to only include keys present in the candidate set
    • Eliminates the need to hold corpus-wide Sets in memory
  • ComputeFormsWorklistTask: Updated to:

    • Call successfulRunKeysForFilings() once per page with only the eligible filings
    • Extract cheap in-memory tests (shard, CIK allow-list, filing date) into passesCheapTests() method
    • Apply cheap tests before the database lookup to minimize query scope
    • Removed the form-scoped successfulKeys cache that was held for the entire form scan
  • backfillDescriptors.ts: Refactored filing selection to:

    • Introduce pageFilingsOfForm() generator for keyset-resume pagination
    • Page through filings with BACKFILL_PAGE_SIZE = 10_000 to bound memory
    • Apply paging to both selectFilingsByForms() and spacTrigger8KSelector()
    • Reduces peak memory from hundreds of MB to ~5 MB per page
  • ProcessSpacTimelineTask: Updated loadSuccessfulKeys() to:

    • Accept the timeline filings and scope queries to only that issuer's filings
    • Eliminates corpus-wide queries that were repeated per CIK in sec sync spacs
  • Constants.ts: Added SecSqliteCacheMb configuration:

    • Replaces hardcoded cache_size = 1000000 (which was ~4 GB) with configurable 256 MB default
    • Clamped to 2..4096 MB range via SEC_SQLITE_CACHE_MB env var
    • Reduces RSS growth during long form sweeps from +31 MB to +3 MB
  • db.ts: Updated SQLite pragma to use negative cache_size (KiB form) with the new constant

Notable Implementation Details

  • The chunking strategy in successfulRunKeysForFilings() is necessary because SQLite has no two-column IN operator; 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_runs lookup to minimize query scope—under --shard 1/6 this 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:

  • Verifies that only candidate CIKs are queried (not the corpus)
  • Verifies that a CIK's other accessions are filtered out
  • Verifies that chunking works correctly past the SQLite bind parameter limit (1000 CIKs)

https://claude.ai/code/session_01CsYGzG99SnoxeQbdAqijBS

sroussey and others added 8 commits August 24, 2026 13:06
`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
`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
`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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ExtractorRunRepo to 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_runs lookup per page/issuer.
  • Introduces a configurable SQLite page-cache ceiling (SEC_SQLITE_CACHE_MB, default 256 MB) and applies it via a negative PRAGMA 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.

@sroussey
sroussey merged commit 49cf0e2 into main Aug 24, 2026
2 checks passed
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.

2 participants