fix(lobbying): scraper cursor, stats, and backfill reliability at current data scale - #1
Closed
nesanders wants to merge 2 commits into
Closed
Conversation
…ata scale
Three related Firestore document/field size-limit bugs surfaced when the
weekly incremental scraper was run for the first time against the full
production-scale dataset (300K+ filings):
1. The live weekly cursor (scrapers/lobbying) stored the entire processed-URL
history and summary cache as two fields on one document. That document
exceeded Firestore's 1MB limit partway through a run, silently failing
(and thus skipping) every registrant processed afterward. Moved to
subcollections — one small doc per URL — mirroring the pattern the
backfill cursor already used, with point lookups instead of an in-memory
set/dict.
2. compute_stats() streamed the full lobbyingFilings/lobbyingRegistrants
collections (300K+ docs) in one unbounded query, which timed out
server-side; the client library's automatic stream-retry then crashed on
an internal AttributeError instead of recovering. Replaced with
cursor-paginated batches (50K docs/request) and a manual retry that
re-issues a fresh query rather than resuming a broken stream.
3. Once (2) was fixed, compute_stats() reached a third limit: the
billSummaries_{court} JSON blob itself exceeded Firestore's 1MB
field-size limit for the current session (1,057KB for court 194's ~5,600
bills), with courts 192/193 close behind. Restructured to one small doc
per bill in a bills subcollection instead of one JSON blob per court —
same fix pattern as (1), applied to writer.py, seedLobbyingStats.ts, and
the frontend fetcher in components/db/lobbying.ts.
All three fixes validated end-to-end against dev Firestore at current scale
(373K filings, 25.6K registrants, 11 courts including the previously-failing
194th).
Also includes scripts/firebase-admin/checkLobbyingFreshness.ts, a read-only
diagnostic for checking scraper cursor state and data recency.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
run_backfill() marked a year "complete" after one pass and skipped it forever on every future run. That's wrong for the current (still-accruing) year: a run partway through the year would mark it complete based on whatever existed at that moment, silently missing every disclosure filed afterward — no future backfill run would ever see it again. This is exactly what happened to 2026 in production: marked complete in July with 0 disclosures captured. run_backfill already has a fully correct, granular completeness check — _is_backfill_processed, a per-URL subcollection lookup. The year-level flag only ever bought a coarse fast-path (skip re-listing a year's registrants entirely) and it's what caused the bug. Removed it: every run now always re-lists every requested year (one cheap HTTP request per year) and relies solely on the per-URL cursor for correctness, so no year can ever be skipped wholesale again. Added tests/test_scrape.py with a small in-memory Firestore fake (real enough to simulate write-then-read-back across calls, unlike a plain mock) covering both cursor systems: - Regression test reproducing the exact bug scenario (empty pass, then real data appears for the same year) — fails against the old code with 4/4 backfill tests red, passes with the fix, confirmed by checking out the pre-fix scrape.py and rerunning the suite against it. - Backfill always re-lists every year, per-URL dedup still works, dry-run never touches Firestore, completedYears is never written anywhere. - Weekly-mode cursor sanity checks (prior-year caching, current-year always live, parent doc stays small — all state in subcollections). Also validated live against dev: --mode backfill --year 2005 --limit 2 run twice confirms the year is re-listed both times while already-processed disclosures are correctly not reprocessed (0 new on both runs, as expected since 2005 was already backfilled in July). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Owner
Author
|
Migrating to codeforboston#2221 |
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
Fixes three related Firestore document/field size-limit bugs in the lobbying scraper, all of which only surfaced when the weekly incremental scraper (
--mode weekly) was run for the first time against the full production-scale dataset (300K+ filings, 25K+ registrants). Prior to this, only--mode backfillhad ever actually run.Context on how these were found: running the weekly scraper against dev surfaced two crashes back to back; fixing the second one surfaced a third, closely related issue. All three are variants of the same root cause — a single Firestore document/field has a hard size ceiling, and several places in this pipeline stored unboundedly-growing state in one doc.
1. Weekly cursor doc exceeded Firestore's 1MB document limit
scrapers/lobbyingstored the entire processed-disclosure-URL history and per-registrant summary cache as two fields (processedDiscUrls,summaryDiscCache) on a single document. Since this was the scraper's first-ever weekly run, it had to rebuild that cache from scratch — the document grew past 1MB partway through, and every write after that point failed silently, meaning every registrant processed afterward was skipped for the rest of the run.Fix: moved to subcollections —
scrapers/lobbying/processedUrls/{hash}andscrapers/lobbying/summaryCache/{hash}, one small doc per URL — mirroring the pattern the backfill cursor (scrapers/lobbyingBackfill/processedUrls) already used. Point lookups replace the in-memory set/dict that used to get loaded and saved as one blob each time.2.
compute_stats()crashed on the full-collection streamcompute_stats()readlobbyingFilings/lobbyingRegistrantsvia one unbounded.stream()call each. At current scale (373K+ filings) the query timed out server-side, and the installedgoogle-cloud-firestoreclient's automatic stream-retry hit an internal bug (AttributeError: '_UnaryStreamMultiCallable' object has no attribute '_retry') instead of recovering.Fix: cursor-paginated reads in 50K-doc batches (
_iter_collectioninwriter.py), withretry=Noneto disable the buggy built-in retry and a manual retry loop that re-issues a fresh, small query on failure instead of trying to resume a broken stream. Verified this reads the full collection correctly (exact matching counts) in ~3.5 minutes total, no crash.3.
billSummaries_{court}JSON blob exceeded the 1MB field-size limitOnce (2) was fixed,
compute_stats()reached a third, related limit: the per-courtbillSummaries_{court}document stores adatafield containingJSON.stringify()'d per-bill counts. For the current session (court 194, ~5,600 bills), that string is now 1,057KB — just over Firestore's 1,048,487-byte field limit. Courts 192/193 are close behind (1,015KB / 973KB) and will cross it within the next couple of sessions as filings accumulate.Fix: same pattern as (1) — one small doc per bill in a
billssubcollection (lobbyingMeta/billSummaries_{court}/bills/{billId}) instead of one JSON blob per court. Applied consistently acrosswriter.py(Python scraper),seedLobbyingStats.ts(TS admin script), and the frontend fetcher (components/db/lobbying.ts) so all three writers/readers agree on the new shape.4.
completedYearspermanently skipped a year based on a partial scanFlagged as a separate follow-up in the original PR description, now fixed here too (previously listed under "Not in scope").
run_backfill()marked a year "complete" after one pass and skipped it forever on every future run. That's wrong for the current (still-accruing) year: a run partway through the year would mark it complete based on whatever existed at that moment, silently missing every disclosure filed afterward. This is exactly what happened to 2026 in production — marked complete in July with 0 disclosures captured, meaning no future backfill run would ever see it again.run_backfillalready has a fully correct, granular completeness check —_is_backfill_processed, a per-URL subcollection lookup (same pattern as fix #1). The year-level flag only ever bought a coarse fast-path (skip re-listing a year's registrants entirely), and it's what caused the bug. Removed it: every run now always re-lists every requested year (one cheap HTTP request per year) and relies solely on the per-URL cursor for correctness.Tests: added
tests/test_scrape.pywith a small in-memory Firestore fake (real enough to simulate write-then-read-back across calls) covering both cursor systems, including a regression test that reproduces the exact bug scenario. Verified the regression test actually catches the bug by checking out the pre-fixscrape.pyand confirming 4/4 backfill tests fail against it, then pass again with the fix restored. Also validated live against dev (--mode backfill --year 2005 --limit 2, run twice) — the year is re-listed both times, already-processed disclosures are correctly not reprocessed.Validation
All fixes were validated live against the dev Firestore project (not just unit-level):
--limit 3) against dev after the cursor fix — confirmed it correctly skipped already-processed disclosures and correctly wrote new ones, verified the new subcollections were populated as expected._iter_collectionstandalone against the fulllobbyingFilings(373,004 docs) andlobbyingRegistrants(25,633 docs) collections — exact matching counts, no crash, ~3.5 min total.compute_stats()end-to-end against dev — completed in 273s, wrote all 11 courts (184–194) including the previously-failing 194th, with correct per-bill data verified directly in Firestore.npx tsc --noEmit) is clean except for two pre-existing, unrelated errors infunctions/lib.Not in scope
--mode weeklyis a separate follow-up once this merges.Steps to test/reproduce
cd lobbying-scraper && python3 -m pytest tests/— 53 tests, all green (45 pre-existing + 8 new intest_scrape.py).cd lobbying-scraper && python3 scrape.py --mode weekly --limit 3against a project with existing data — should complete without error and skip previously-processed disclosures.yarn firebase-admin run-script seedLobbyingStats --env dev— should complete without a "too many index entries" or field-size error, and write per-bill docs underlobbyingMeta/billSummaries_{court}/bills/.yarn firebase-admin run-script checkLobbyingFreshness --env dev— new diagnostic script; shows recent fetch timestamps, cursor subcollection sizes, and current-year totals./lobbying/billsin the frontend — bill summaries should still render correctly now that they're read from thebillssubcollection instead of a JSON blob.