Skip to content

fix(sec): gate SQLite SPAC lock through in-process mutex + auto-resolve oversized dead-letter#173

Open
sroussey wants to merge 2 commits into
claude/wonderful-hypatia-j1anwifrom
claude/wonderful-hypatia-74ff08
Open

fix(sec): gate SQLite SPAC lock through in-process mutex + auto-resolve oversized dead-letter#173
sroussey wants to merge 2 commits into
claude/wonderful-hypatia-j1anwifrom
claude/wonderful-hypatia-74ff08

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

Two follow-ups on PR #170.

Fix 1 (CRITICAL): SQLite withSpacCikLock singleton-connection crash

The SQLite branch in withSpacCikLock issues BEGIN IMMEDIATE on the singleton better-sqlite3 connection returned by getDb(). The existing per-CIK keyed mutex (withInProcessLock) only serializes writers on the same CIK — distinct CIKs race past the mutex and hit BEGIN concurrently, and SQLite throws "cannot start a transaction within a transaction".

Wrapping the SQLite branch body in a process-wide gate keyed by a sentinel value (SQLITE_GLOBAL_LOCK_KEY = 0) forces every SPAC writer to queue at the connection regardless of CIK. The connection-level SQLite database lock is single-writer anyway, so this matches the backend's actual concurrency model. Postgres + in-memory fallback are unchanged.

New test: five parallel recordRegistration calls across CIKs [100, 200, 300, 400, 500] under a real SQLite backend all resolve fulfilled, with zero throws containing "transaction within a transaction". Reverting only the wrap causes the second concurrent BEGIN to throw — the fix is essential.

Fix 2 (HIGH): auto-resolve the redemption-partial-oversized dead-letter

processRedemption8K records a redemption-partial-oversized entry whenever at least one exhibit was dropped over the per-exhibit cap but a non-empty survivor set still ran through extraction. The entry is purely informational — the drop is deterministic (the cap doesn't move between runs) so no retry recovers the dropped exhibit, and yet today the entry sits in pending forever, polluting sec extractor dead-letters redemption with rows no version bump can clear.

Adding markResolved immediately after record lands the entry in resolved so listEligible / listPending exclude it. The attempts counter still increments on each replay (audit trail intact), so operators retain visibility into how often the cap fired for a given accession.

Test plan

  • bun test src/storage/spac/ — 49 tests across 10 files, 0 fail (new SQLite parallel-writer test included).
  • bun test src/sec/forms/miscellaneous-filings/ — 49 tests across 4 files, 0 fail (existing partial-oversized test extended + a new dual-run idempotency test).
  • bun run build — clean.

Generated by Claude Code

claude added 2 commits June 28, 2026 08:33
…oid concurrent BEGIN IMMEDIATE crash)

The SQLite branch issues `BEGIN IMMEDIATE` on the singleton
`better-sqlite3` connection that `getDb()` returns. The pre-existing
per-CIK keyed mutex (`withInProcessLock`) only serializes writers on
the same CIK — distinct CIKs race past the mutex and hit BEGIN
concurrently, and SQLite responds with
"cannot start a transaction within a transaction".

Wrapping the SQLite branch body in a process-wide gate keyed by a
sentinel value (`SQLITE_GLOBAL_LOCK_KEY = 0`) forces every SPAC writer
to queue at the connection regardless of CIK. The connection-level
SQLite database lock is single-writer anyway, so this matches the
backend's actual concurrency model. Postgres and the in-memory
fallback are unchanged (per-CIK serialization is correct there).

Test: five parallel `recordRegistration` calls across CIKs
[100, 200, 300, 400, 500] under a real SQLite backend all resolve
fulfilled, with zero throws containing "transaction within a
transaction". The fix is essential — reverting only the
SQLITE_GLOBAL_LOCK_KEY wrap causes this test to throw on the
second concurrent BEGIN.
… (informational only)

`processRedemption8K` records a `redemption-partial-oversized` dead-letter
when at least one exhibit was dropped over the per-exhibit cap but a non-empty
survivor set still ran through extraction. The entry exists so operators can
triage filings whose largest exhibit was elided.

It is purely informational — the drop is deterministic (the cap doesn't move
between runs) so no retry recovers the dropped exhibit. Today the entry sits
in the `pending` worklist forever and pollutes `sec extractor dead-letters
redemption` output with rows that no extractor-version bump can clear.

This call adds a `markResolved` immediately after the `record` so the entry
lands in the `resolved` state and is excluded from `listEligible`. The
`attempts` counter keeps incrementing on each replay (so the audit trail
of how many times the cap was hit for a given accession is preserved), and
`listPending` / `listEligible` queries never surface it again.

Test: a filing with one in-cap + one oversized exhibit runs through
`processRedemption8K` twice; after each run the entry exists at
status="resolved", `listEligible` filtered to its section returns 0 entries,
and `attempts` increments (1 then 2).
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