Files
alkblobs/tasks/f4-reap-declaration.md
T
glm-5.3-flash 0fe1398a87 fix(pg-lo): F-4 — the orphan_reap_permitted declaration gates the orphan-recovery sweep + the riding reap fixes (review-001, ADR-016)
- PgOpts.orphan_reap_permitted (default false) — the deployment-verified
  declaration (ADR-012 §3's class): the operator's assertion that this
  database hosts no other large-object consumer; read only by pg-lo,
  dormant on the postgres kv engine
- reap_orphans refuses with a named Error::Io self-naming message while
  the declaration is absent (the constructor-declaration refusal's
  existing family member, the fleet: false precedent in sweep.rs:127);
  tested — the refusal runs nothing
- the missing-LO pass: each unlink rides a savepoint; a vanished loid
  (SQLSTATE 42704 — empirically probed) passes the sweep and is not
  counted reaped instead of aborting the one-tx sweep; candidates
  re-validated at delete time, covering both the vanished-loid and the
  READ COMMITTED same-engine-delete race arms (no REPEATABLE READ,
  per the ADR's chosen form)
- reap_orphans_tx takes the candidate list: the stale-candidate race is
  driven deterministically in test (the committed class survives, the
  report is honest, the tx reaches its end)
- bootstrap_ddl adds lo_entries_loid_idx — the claim probe's supporting
  index (WHERE loid = $1 seq-scanned); idempotent, so existing
  databases gain it on next construction; tested (drop + reopen)
- the hostile-schema test carries ADR-016 §3's framing: row isolation
  stands proven; reaping is what the declaration gates (asserted)
- task f4-reap-declaration: status completed, Notes/Summary filled

Verification: cargo test (default), cargo test --all-features
server-less, cargo test --all-features against the live harness
(pglo-poc :15432; 290+125+4, pg-lo's 35 live at default parallelism),
clippy -D warnings on default/all-features/pg-lo+pem-pure variants,
cargo fmt --check, cargo doc --no-deps — all pass.
2026-10-15 14:00:49 +00:00

10 KiB

id, name, status, depends_on, scope, risk, impact, level, tags
id name status depends_on scope risk impact level tags
f4-reap-declaration F-4 — the orphan_reap_permitted constructor declaration + the riding reap fixes (ADR-016) completed
moderate medium component implementation
wave-9
review-001
correctness
pg-lo
fleet

Description

Review-001 F-4, resolved by OQ-13 → ADR-016 (docs/architecture/decisions/016-one-large-object-consumer-per- database.md — the task implements the ADR's Decision section; read it line by line before starting). reap_orphans (src/pg_lo/reap.rs:22/41) validates candidate LOs against this engine's companion rows only, but pg_largeobject is database-level: a second pg-lo engine — or any other large-object consumer — on the same database under a different schema gets its committed LOs lo_unlinked by this engine's reaping sweep. Cross-engine data loss with no error raised, in a configuration that is technically supported today (PgOpts.schema is public; the hostile-schema test demonstrates co-tenancy).

Landing, per the ADR:

  1. orphan_reap_permitted: bool, default false, on the pg-lo constructor options — the operator's explicit assertion "this database hosts no other large-object consumer" (the deployment-verified-invariant pattern, ADR-012 §3's fleet: true class). reap_orphans on an engine constructed with the flag false refuses with a named error (a new error variant or the existing family's honest member — the naming is the task's design point, recorded). No guard probing foreign schemas — the ADR rejected the guard direction on the catalog-ambiguity hole; do not resurrect it.
  2. The riding fixes (unconditional):
    • an index on lo_entries(loid) — the claim probe is WHERE loid = $1 per oid and today seq-scans (the PK is key); created in the schema bootstrap so existing databases gain it on next construction;
    • a loid vanishing between the candidate snapshot and the unlink no longer aborts the sweep transaction (a missing-LO pass — tolerate and continue, the ADR's chosen form over REPEATABLE READ);
    • the READ COMMITTED candidate snapshot no longer races a concurrent same-engine delete into a spurious whole-sweep failure (re-validate candidates at delete time instead of trusting the snapshot).
  3. The hostile-schema test's framing shifts (the ADR's §3): schema isolation of rows is unaffected and the test stays as an isolation test; what the constraint bounds is the reaping method. The test's docs/assertions updated to the declaration-gates-reaping framing.

Tests: the refusal without the declaration (named error), the full reap with the declaration, the vanished-loid tolerance (a loid deleted between snapshot and unlink — the sweep continues and reports honestly), the index's existence, and the race fix's shape as the harness allows.

Live harness

All pg-lo shapes run against the local dockerized postgres container (pglo-poc, postgres:16-alpine on :15432, running for dev/testing):

export ALKBLOBS_PG_HOST=localhost
export ALKBLOBS_PG_PORT=15432
export ALKBLOBS_PG_USER=postgres
export ALKBLOBS_PG_PASSWORD=poc
export ALKBLOBS_PG_DB=blobs

Server-less, the pg tests skip cleanly; acceptance requires green against the live harness.

Acceptance Criteria

  • orphan_reap_permitted on the pg-lo constructor options, default false, documented as the deployment-verified-invariant declaration it is
  • reap_orphans refuses with a named error when the flag is false; the refusal tested; the naming recorded
  • The lo_entries(loid) index exists (bootstrap-created; existing databases gain it)
  • A vanished loid between snapshot and unlink no longer aborts the sweep tx (missing-LO pass; tested)
  • The candidate-snapshot race fix landed (re-validation at delete time; tested as the harness allows)
  • The hostile-schema test's framing updated (isolation works; reaping is what the declaration gates)
  • Gates green: cargo test (default), cargo test --all-features server-less, cargo test --all-features + live harness, clippy --all-targets -- -D warnings across the feature variants, cargo fmt --check

References

  • docs/architecture/decisions/016-one-large-object-consumer-per-database.md (the ADR this implements — OQ-13's resolution)
  • docs/reviews/001-storage-surface-general-review.md (F-4 and its compounding minor arms)
  • docs/plans/implementation.md (§Wave 9 — "the ADR's unconditional riding fixes")
  • src/pg_lo/reap.rs (reap_orphans/reap_orphans_tx)
  • src/pg_lo/mod.rs (the constructor options — where the declaration lands)
  • docs/architecture/decisions/012-pre-decomposition-consistency-rulings.md §3 (the constructor-declaration pattern reused)
  • tasks/store-pg-lo-construction.md, tasks/engine-pg-lo.md (the constructor and schema-bootstrap shapes this extends)

Notes

Filled by implementation agent (2026-10-15):

  • The declaration's home: PgOpts (src/pg_common.rs), shared — per the one-opts-type-per-consumer-story design point (pg-lo's constructor options are PgOpts; the store constructors pass it through unchanged). The field carries the ADR-016 doc in full and is documented as dormant on the postgres kv engine (read only by pg-lo). PgLoBackend stores it at construction and the sweep reads it at its entry; no facade/trait change.
  • The refusal's naming (the task's design point): the existing family's honest member — Error::Io (String) with a named message — a new error variant would be a new failure family, and the family is ADR-pinned (src/error.rs: "new failure families are new ADRs"); a new ADR is not owed here (ADR-016 §5's consequence surface lists no error-family change, and ADR-016 did not decide a variant). The form mirrors the existing constructor-declaration refusal in the family — the fleet: false fleet-liveness-table refusal (src/store/sweep.rs:127, ADR-012 §3's class): Error::Io, self-naming ("orphan reap refused (ADR-016)"), naming the declaration (orphan_reap_permitted = true) and the two-databases alternative. Both strings are asserted in the refusal test.
  • The missing-LO pass mechanism: a savepoint per unlink — the empirical probe settled the discriminant: lo_unlink of a vanished loid raises SQLSTATE 42704 (UNDEFINED_OBJECT), and the failed statement aborts the sweep tx (every subsequent statement dies with it) — exactly why the old shape turned a vanished loid into a whole-sweep failure. Each unlink rides SAVEPOINT orphan_reap_unlink; the 42704 pass rolls back to the savepoint and continues (not counted reaped — the report stays honest); any other unlink failure is a media failure and aborts. This mechanism covers both race arms at once (a loid vanishing between snapshot and unlink, and the same-engine concurrent delete racing the READ COMMITTED snapshot — both present identically at delete time: row unclaimed, LO gone); the claim probe itself remains per-oid at delete time (candidates re-validated, never trusted from the snapshot). No REPEATABLE READ posture, per the ADR's chosen form.
  • Race-fix testability ("as the harness allows"): reap_orphans_tx now takes the candidate list as a parameter (the snapshot fetch stays in reap_orphans), so the stale-candidate race is driven deterministically — the test snapshots, unlinks the orphan concurrently-shape, then runs the sweep body over the stale list — asserting the pass, the honest zero-count, and the tx reaching its end with the committed class intact. No flaky concurrency test was introduced for an equivalent outcome.
  • The index: lo_entries_loid_idx in bootstrap_ddl (CREATE INDEX IF NOT EXISTS … ON lo_entries (loid)) — schema-scoped names, idempotent, so existing databases gain it on next construction; the test drops it by hand and proves the re-open brings it back.
  • The hostile-schema test (src/pg_lo/mod.rs) now carries the ADR-016 §3 framing in its doc and an assertion at the shifted boundary: storage isolates across schemas; reap_orphans on the default (declaration-absent) co-tenant construction refuses.

Summary

Filled on completion (2026-10-15):

ADR-016's Decision section is landed: PgOpts.orphan_reap_permitted (default false) is the pg-lo engine's orphan-reap declaration — the operator's deployment-verified assertion that the database hosts no other large-object consumer — and reap_orphans refuses with a named Error::Io self-naming message while it is absent (the constructor-declaration refusal pattern's existing family member, per the fleet: false precedent at src/store/sweep.rs:127). The ADR's riding fixes all landed: the lo_entries_loid_idx bootstrap index (idempotent, existing databases gain it on next construction), and the savepoint-guarded missing-LO pass (SQLSTATE 42704 on a vanished loid — a concurrent same-engine delete or a foreign consumer's lifecycle — continues the sweep honestly instead of aborting the one-tx sweep; candidates are re-validated at delete time, never trusted from the READ COMMITTED snapshot). The hostile-schema test's framing shifted to the ADR's §3: row isolation is unaffected and still proven; the reaping method is what the declaration gates, asserted at the boundary. Tests: the refusal (named strings asserted, nothing runs), the full reap with the declaration, the stale-candidate pass driven deterministically through the now candidate-parameterized reap_orphans_tx, and the index's existence/gain-on-reconstruction.

Verification (all green, 2026-10-15):

  • cargo test (default: mem + sqlite) — pass
  • cargo test --all-features server-less — pass (pg skips cleanly)
  • cargo test --all-features + live harness (pglo-poc, :15432) — pass at default parallelism (290 + 125 + 4), the pg-lo suite's 35 tests running live
  • cargo clippy --all-targets -- -D warnings (default), --all-features --all-targets, and the pg-lo-pure variant (--no-default-features --features pg-lo,mem --all-targets) — pass
  • cargo fmt --check — pass; cargo doc --no-deps — clean