Skip to content

Add backfill_doab management command (refs #1151) - #1152

Draft
rdhyee wants to merge 7 commits into
masterfrom
feature/backfill-doab-command
Draft

Add backfill_doab management command (refs #1151)#1152
rdhyee wants to merge 7 commits into
masterfrom
feature/backfill-doab-command

Conversation

@rdhyee

@rdhyee rdhyee commented May 14, 2026

Copy link
Copy Markdown
Member

Closes #1151 once drained. Recovers the ~20.6k active DOAB records the nightly load_doab cron structurally cannot self-heal (3-day rolling window can't reach records modified during a 429 ban).

What's in this PR (3 commits, bisectable)

Commit What
2115772a load_doab: make the shared Retry-After sentinel monotonic + race-free (never-delete). A ban can now only be extended, never shortened or wiped by a racing process. Used by both the nightly harvest and the new backfill.
88480de9 backfill_doab: auto-discovery (Option B) + Codex-hardened reliability — removed transaction.atomic() around add_by_doab (the loader interleaves DB writes with up to ~3 min of HTTP per record; the nightly cron has run without an atomic wrapper for years). Precheck no longer freezes partial commits; 2nd 429 extends the sentinel; stock-pyoai fallback parses RFC 9110 Retry-After.
2ff6226b backfill_doab: distinct exit codes 0/3/4 so the orchestration runner (separate PR EbookFoundation/regluit-provisioning#TBD) can honor the in-command error_rate_halt / gone_rate_halt circuit breakers ACROSS ticks (a naive re-run loop would defeat them). Every Retry-After SKIP path now sys.exit(3) so the runner does not falsely mark .done on the first ban.

Approval status

Code converged through 4 Codex review rounds + 3 orchestration code-review rounds (final: LGTM in both tracks). High-confidence on the code itself; the design calls below want human eyes (see "For Eric to weigh" — see the linked operator notes).

Codex code-review trail (this PR + its load_doab dependency)

Round Outcome
R1 Found 5: precheck freezing partial loads, 2nd-429 doesn't extend sentinel, stock-pyoai uses int() (mishandles HTTP-date), shared-sentinel races, discovery namespace-drift silently empty
R2 4/5 fixed; #4 (sentinel race) partial — clear_sentinel() safe, callers ignored refused-clear → fixed: returns live deadline, 5 call sites honor it
R3 Caller fixes correct; deliberate Retry-After: 0 → 60s non-fix endorsed (over-conservative, aligns with slow-and-gentle); root TOCTOU on clear_sentinel's os.remove → fixed per Codex's own prescription: never delete (an expired sentinel is harmless; next 429 overwrites; monotonic write guarantees correctness)
R4 LGTM — all caller fixes verified, contract satisfied, no caller depended on deletion, no growth/staleness, both repos consistent

Design calls that deserve maintainer eyes (not Codex's domain)

  1. Removing transaction.atomic() around add_by_doab in the regluit loader. Justified by the loader's HTTP-in-DB-transaction problem (cover fetch up to ~120s) + the established prod cron pattern (load_doab_oai has never wrapped add_by_doab in a transaction). The precheck change (commit 88480de9) depends on add_by_doab's idempotency to heal any partial commits — please sanity-check that idempotency claim against the cover/metadata/ISBN follow-up code paths.

  2. load_doab.py sentinel change (commit 2115772a) touches the live nightly harvester's shared helpers. The change is strictly safer (bans can only be extended; no os.remove race), but it is production-cron-affecting and so wants a careful read.

What this enables but does not deliver

  • Orchestration to actually drain the 20.6k: see the companion PR in EbookFoundation/regluit-provisioning (feature/doab-backfill-orchestration) — adds the runner script under shared flock with the nightly harvest, marker-file circuit breaker, deploy_type == 'prod'-gated cron. The exit-code change in this PR is what makes that runner safe.
  • Tests for backfill_doab itself: deliberately out of scope for this PR (the command is straightforward to exercise by hand; the design budget went to Codex hardening + the orchestration). Tracked for follow-up.

Backlinks

🤖 Generated with Claude Code (Opus 4.7, 1M context) — multi-round Codex CLI adversarial review

Slow, resumable, observable per-ID backfill for the 20,608 active DOAB
records found missing from the local DB by the 2026-05-14 set-diff
(#1151). The 4:30 UTC nightly cron's 3-day rolling
window cannot self-heal records lost during prior 429 stretches; this
command reads a static list of missing IDs and processes them via
add_by_doab() with rate-limited, ID-keyed JSON state.

Design highlights (codex-reviewed twice):
- Reuses add_by_doab() and the shared Retry-After sentinel from
  load_doab.py so a 429 hit by either command suppresses the other.
- 4-way per-record outcome taxonomy: ok / gone / present_locally /
  error_review. Unknown exceptions HALT (do not silently mark
  recovered). Known validation errors are terminal but flagged.
- Local existence precheck per record short-circuits DOAB calls for
  IDs the nightly cron may have caught up on during the run.
- transaction.atomic() scoped to local writes only — remote OAI fetch
  happens before the DB transaction opens, not inside it.
- IntegrityError on write triggers re-precheck and promotion to
  present_locally (handles race with nightly cron).
- Numeric ID sort, not lexical. Newest-first by default.
- Per-record atomic state writes (tmp + os.replace). Totals
  recomputed from ids dict on every write — zero increment drift.
- Stop conditions: first 429, max-remote-calls, error-rate-halt,
  gone-rate-halt, retry-after exceeds cap, network/5xx, drained.
- SHA256 checksum of input file enforced on every run (live + dry).
- Pacing: jittered sleep (default 3s ± 20%); 500 remote calls/run.

No tests in this commit (followup); no cron entry yet (lives in
regluit-provisioning, separate change). Snapshot generator for
doab_missing.txt is also a follow-up.

Refs #1151

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@rdhyee rdhyee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Code review findings:

  • High: the command continues processing after a short 429. _handle_rate_limit() returns recovered, the caller retries the record, and then the loop continues to the next pending ID. That violates the stated first-429 stop condition and can keep calling DOAB after the shared sentinel has been written. I would checkpoint the current ID and exit after the courtesy retry, or remove the courtesy retry and always exit on 429.

  • Medium: the transaction still wraps remote work. The command fetches the OAI record before transaction.atomic(), but doab.add_by_doab() can still make cover/bitstream HTTP calls via update_cover_doab() / store_doab_cover() / get_streamdata(). That means the DB transaction can be held during HTTP despite the design note. Either move those remote side effects out of the atomic block, add a loader path that skips cover fetching during backfill, or narrow the transaction around only the actual DB writes.

  • Medium: deleted OAI records are not categorized as gone. A deleted/no-content record gets to add_by_doab(), returns None, and the command marks it error_review. That inflates review errors and bypasses the intended gone-rate halt. The command should detect deleted/no-content OAI headers before calling add_by_doab() and mark those as gone.

I did not run tests; this was a code review against the loader contracts and the PR diff.

Three of the four codex high+medium findings on PR #1152:

1. HIGH: "first 429 of run" stop condition was violated. After
   _handle_rate_limit returned 'recovered', the code did one retry but
   then fell through into the next loop iteration, continuing to make
   DOAB calls after the shared sentinel had been written. Fix: after
   the single courtesy retry, set stop_after_this_record=True and
   break out of the loop after categorizing the result. All 'continue'
   paths within the loop body now also check this flag.

2. MEDIUM: deleted OAI records were inconsistently categorized. A
   record with status="deleted" header (or empty content) reaches
   add_by_doab(), which returns None when record[1] is falsy — and
   that None reached step 7f and was marked error_review instead of
   gone. Fix: in step 7d, after fetch but before add_by_doab(), check
   record[0].isDeleted() or not record[1] and mark gone directly.

3. MEDIUM: transaction.atomic() framing was misleading. The original
   comment claimed the DB tx was "scoped to local writes only" but
   add_by_doab() makes downstream HTTP calls (cover image fetch,
   bitstream API path) — so the tx actually does hold during HTTP.
   Fix: replace the misleading comment with an honest trade-off note.
   Refactoring add_by_doab to support fetch-vs-write phase separation
   is out of scope for this PR.

The 4th finding (status flip) applies only to doab-check.

Refs #1151

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rdhyee

rdhyee commented May 14, 2026

Copy link
Copy Markdown
Member Author

Addressed codex review findings in commit e103d02:

Codex finding Severity How addressed
429-recovered path falls through, continues processing HIGH After single retry, set stop_after_this_record=True, break after categorizing the result. All continue paths within the loop check the flag.
Deleted OAI records marked error_review instead of gone MEDIUM New step in 7d: check record[0].isDeleted() or not record[1] before calling add_by_doab(); mark gone directly.
transaction.atomic() "scoped to local writes only" claim is false MEDIUM Replaced misleading comment with honest trade-off explanation. The wrap does hold a DB tx during downstream HTTP calls inside add_by_doab (cover image, bitstream). Refactoring add_by_doab to support fetch-vs-write phase separation is out of scope for this PR.

The 4th codex finding (status flip) only applies to doab-check; addressed in EbookFoundation/doab-check#19.

Codex pass-2 finding: the stock-pyoai urllib.error.HTTPError(429)
fallback was routing through _handle_rate_limit(), which may sleep
the Retry-After and return 'recovered' — but the caller immediately
saved state and broke without doing the courtesy retry that the
patched-fork RateLimitedError path now does. The current ID also
got no terminal mark.

The patched EbookFoundation/pyoai fork (pinned in
requirements_versioned.pip) is the supported path; the HTTPError
fallback only fires if we somehow load stock pyoai. Make that
fallback unambiguously conservative: write the shared sentinel,
mark the current ID as 'retry', exit cleanly. No sleep, no retry
attempt. The next run will honor the sentinel.

Refs #1151

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rdhyee

rdhyee commented May 14, 2026

Copy link
Copy Markdown
Member Author

Pass-2 codex finding addressed in 3c14882: stock-pyoai HTTPError(429) fallback now writes sentinel + marks 'retry' + exits cleanly. No sleep, no retry attempt — keeps that behavior exclusively in the patched-fork path which already has the stop_after_this_record handling.

@rdhyee rdhyee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LGTM from this review pass. The previous 429 fallback issue is resolved, the updated command compiles, and I do not see remaining blocking findings.

Residual caveat: tests and a small capped smoke run are still important before taking this out of draft / running at production scale.

  • codex

rdhyee and others added 3 commits May 19, 2026 05:29
The cron and the new backfill_doab command co-own the shared Retry-After
sentinel file. Two latent races existed:

- write_block_deadline() blindly overwrote: a short 429 racing a long one
  (or a stale writer) could shorten an active ban and let us hit a still-
  banned endpoint early. Now monotonic - only ever moves the deadline later.

- clear_sentinel() did read-then-unconditional os.remove(): a fresh future
  deadline written by another process between the read and the remove was
  erased, dropping an active ban (an unavoidable TOCTOU). It now NEVER
  deletes the file - an expired deadline is already harmless (every caller
  compares it to now) and the next 429 overwrites it. clear_sentinel()
  returns the still-active deadline (or None); all callers honor that.

Strictly safer for the production nightly harvester: a ban can now only be
extended, never shortened or wiped by a racing process.

Converged through 4 Codex review rounds (final: LGTM).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One-off catch-up for the ~20.6k active DOAB records the nightly load_doab
cron structurally cannot self-heal (3-day rolling window can't recover
records modified during a 429 ban).

- Worklist auto-discovery (Option B): crawl DOAB OAI ListIdentifiers,
  diff vs local Identifier rows, write SHA-pinned .ids/.stale/.orphans;
  a namespace-drift guard refuses to write a worklist if headers were
  seen but zero DOAB IDs recognized.
- Removed transaction.atomic() around add_by_doab: that loader interleaves
  DB writes with up to ~3 min of HTTP per record (online_to_download +
  cover-fetch redirect chain). An atomic() wrapper held an RDS transaction
  open across minutes of network I/O ~20.6k times. The production nightly
  cron has called add_by_doab with no transaction wrapper for years;
  autocommit + add_by_doab idempotency is the loader's established model.
- Precheck no longer freezes a partially-committed load as
  'present_locally': only never-attempted IDs short-circuit; previously-
  attempted IDs re-run the idempotent loader to heal.
- Second 429 after the courtesy wait now extends the shared sentinel.
- Stock-pyoai 429 fallback parses Retry-After per RFC 9110 (delta-seconds
  OR HTTP-date) instead of bare int().
- Removed dead parse_id_line.

Converged through 4 Codex review rounds (final: LGTM). Code-LGTM only:
orchestration to drain the full worklist and tests are separate follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1151)

The runner that drains the full worklist must distinguish "drained" from
"safe to re-invoke" from "stop, a human must inspect" — otherwise a naive
cron loop defeats the in-command error_rate_halt / gone_rate_halt circuit
breakers ACROSS ticks.

  exit 0  drained (or discovery-found-zero)
  exit 3  benign checkpoint — runner re-fires next tick
          (max_remote_calls, 429_recovered_*, retry_after_too_long,
           transient_failure, network_failure, stock_pyoai_429_fallback,
           429_after_wait_still_failing)
  exit 4  circuit-breaker halt — runner freezes via .halted marker
          (error_rate_halt, gone_rate_halt, unknown_http_error_halt,
           unknown_exception_halt)

Also: every Retry-After SKIP path (step 0 discovery ban, step 6 sentinel
ban, step 6 concurrent-writer ban) now sys.exit(3) instead of plain
return. Without this they would exit 0 and the runner would write .done
on the first ban, permanently freezing the backfill as "complete".

Codex-reviewed end-to-end (orchestration round 3: LGTM).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1151)

Today each regluit host (test/dj42/prod) would run its own ~17-min DOAB OAI
ListIdentifiers crawl during discovery. The active set DOAB exposes is
universal — the only host-specific part is the diff against the local DB —
so we can amortize the one expensive operation across the fleet.

Producer side: discover_missing_ids() now emits two new artifacts alongside
the existing per-host worklist/stale/orphan files:

  .active   — raw OAI active set (~125k IDs), sorted by numeric suffix
  .deleted  — raw OAI deleted set, sorted

Consumer side: two new flags, --use-active-file / --use-deleted-file. When
provided, a new discover_via_active_file() reads the precomputed snapshot
from disk and diffs against the local Identifier table without making any
DOAB OAI calls. The per-record load loop downstream is unchanged; the
shared Retry-After sentinel still gates DOAB traffic the moment loading
begins.

Result: one OAI crawl serves all three regluit hosts. doab-check (separate
codebase) can consume the same .active file format with its own diff in a
follow-up.

Codex review (4 rounds) hardened:
- Refuse silently shadowing --use-active-file with a stale local cache;
  operator must be explicit (--refresh-ids + fresh --state-file).
- Mutual-exclusion guard: --use-active-file vs --ids-file at the top of
  handle(); --use-deleted-file requires --use-active-file.
- Producer/consumer ID validation drift fixed: discovery now validates the
  stripped DOAB ID against ID_RE and refuses to emit a snapshot the
  consumer's load_input_ids() would reject (catches DOAB OAI namespace
  drift early, paired with the existing 0-recognized namespace-drift
  guard).
- Multi-file atomic-write ordering: .ids is written LAST in both producer
  and consumer paths, so its presence as the cache_exists marker proves
  all sibling artifacts are fresh and mutually consistent.
- --dry-run --use-active-file no longer writes any files (was seeding a
  real cache from a validation run); the diff is still computed and
  reported.
- Mismatched-snapshot-pair guard: active ∩ deleted must be empty (by
  construction from a single crawl); if --use-deleted-file's set overlaps
  --use-active-file's, refuse — the operator paired files from different
  crawls.

Tests deferred per existing #1151 scope (see ORCHESTRATION_PLAN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rdhyee

rdhyee commented May 20, 2026

Copy link
Copy Markdown
Member Author

2026-05-20 status update after the cross-host snapshot work:

  • Latest head includes cfe054e2, which adds .active / .deleted snapshot emission plus --use-active-file / --use-deleted-file consumer mode. The original PR body is now stale where it says "3 commits" and where it still names the provisioning companion as TBD.
  • Test-machine demo path now works end-to-end: one DOAB OAI crawl on test.unglue.it produced the universal .active / .deleted files plus the local .ids / .stale / .orphans artifacts; reusing .active on the same host produced a byte-identical worklist with zero DOAB calls.
  • Cross-codebase reuse has also been proven outside this PR: the same .active snapshot was consumed by a throwaway doab-check diff script, producing a 14,495-line ids file in seconds, and doab-check's existing backfill_doab successfully loaded one test record from that worklist.
  • This PR may still be simplified before merge. The producer half (emit .active / .deleted) is small and clearly useful. The consumer half (--use-active-file flags) is reusable, but if this remains a one-off backfill we may trim to producer-only and keep cross-host consumption as an operator script.

Operational caveat surfaced by the demo: DOAB ID 20.500.12854/176562 loaded successfully in doab-check but failed through regluit's add_by_doab path. That looks like a pre-existing regluit loader divergence and should be tracked separately rather than blocking the backfill-scope decision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

DOAB harvest gap: ~20.6k active records missing (and ~7.3k stale records served) — coverage audit findings

1 participant