Skip to content

sec spac download is unbounded and materializes the whole corpus before the first fetch #271

Description

@sroussey

Found while reviewing #267 (sec spac download registration|8k|everything). Deliberately kept out of the fix PRs on that branch (#268, #270) because it is a design change to the producer, not a bug fix.

The problem

DownloadSpacCandidateDocsTask.execute selects every filing of every high+medium SPAC candidate and builds the entire worklist in memory before issuing a single fetch:

const filings: Filing[] = [];
for (chunk of ciks) filings.push(...(await filingRepo.query({ cik: { value: chunk, operator: "in" } })));
const matchedRows = filings.filter(...);
const todo: CacheOneInput[] = [];
for (const row of matchedRows) { ...; todo.push({...}); }

That is three live copies of the same set — filings, matchedRows, todo — held simultaneously, and there is no bound of any kind: no --limit, no --from/--to filing-date range, no --cik, no form filter beyond the three fixed sets. sec spac download everything means literally every filing for every candidate.

This reintroduces exactly the pattern formsSweep.ts / ComputeFormsWorklistTask were rewritten to remove. From ComputeFormsWorklistTask's own comments, measured on this corpus:

The candidate set is far too large to materialize: form 4 alone is ~4.6M filings and the full 55-form worklist ~6.4M, at a measured ~460 bytes per 15-column row — ~3 GB per process […]

it previously materialized every matching filing before the first fetch, ~1M entries and 158 MB for one shard of today's corpus, and growing without bound. Batching caps that at ~5k entries (~0.8 MB) and, more importantly, starts real work immediately instead of after a multi-minute scan.

The SPAC candidate set is smaller than the full corpus, but it is thousands of CIKs with unbounded filing histories, it grows monotonically, and the second half of that quote applies regardless of size: today the operator waits out a full scan before the first byte is fetched.

These are ONE change, not two

It is tempting to file "add --limit" and "stream the producer" separately. They are the same change.

--limit is a stop condition on a lazy producer. Applied to a materialized array it is a .slice() — the full scan is already paid, all three copies already exist, and the flag saves only fetches. The memory and time-to-first-fetch costs, which are the actual problems, are untouched. So the producer has to become lazy first, and then --limit is one line of it.

Two constraints found while reviewing

Anyone picking this up should know both up front, because they shape the design:

1. SearchCriteria allows one condition per column and has no OR. So filing_date >= from AND filing_date <= to is not expressible as a query — only one side of a --from/--to range can be pushed down to the database, and the other must be a JS predicate applied to rows already read. ComputeFormsWorklistTask.readPage documents the same limitation for its keyset resume:

SearchCriteria allows one condition per column and has no OR, so the exact keyset predicate (cik, accession) > (lastCik, lastAccession) is not expressible.

The consequence is not cosmetic: --limit must be counted on YIELDED items, never on rows read. With half the range filter running in JS, rows-read and items-yielded diverge arbitrarily, and a limit counted on the former would stop early on a range whose rows mostly fail the predicate — silently downloading fewer documents than asked for.

2. The honest test is a temporal one. Asserting on peak RSS is flaky, and asserting that a limited run fetched N documents passes just as well against a .slice() of a fully materialized array. The assertion that actually pins the memory behaviour is:

the first fetch is issued before the last page of filings is read.

Instrument the repo query and the fetch seam with a shared sequence counter and assert the ordering. That test fails today and is the only one that cannot be satisfied by a materialize-then-trim implementation.

Suggested shape

Mirror ComputeFormsWorklistTask: a paged producer over filings in primary-key order with a last-key resume, emitting batches into the existing FORMS_SWEEP_CONCURRENCY_LIMIT worker pool, with --limit / --from / --to / --cik / form filters evaluated inside the producer. The counters (matched, skipped, downloaded, failed) accumulate across batches rather than being derived from array lengths.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions