Skip to content

fix(audit): track deleteHistory removals with bounded concurrency - #2051

Draft
kriszyp wants to merge 8 commits into
mainfrom
fix/deletehistory-unhandled-rejection
Draft

fix(audit): track deleteHistory removals with bounded concurrency#2051
kriszyp wants to merge 8 commits into
mainfrom
fix/deletehistory-unhandled-rejection

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 2, 2026

Copy link
Copy Markdown
Member

What

Table.deleteHistory() — the LMDB path behind delete_transaction_logs_before — overwrote each iteration's removal promise and awaited only the last one. A non-final rejection therefore surfaced later as an unhandled rejection.

The fix now tracks every audit-entry or tombstone removal immediately, attaches failure handling at creation, permits at most 10 removals in flight, and drains each phase before returning. This keeps storage writes overlapped without restoring the original unbounded pending set.

removeAuditEntry() also joins the delete callback that removes the corresponding primary-store tombstone, instead of firing that promise and forgetting it.

Why

Under Bun, an unhandled rejection here killed Harper a few seconds after the cleanup job had already reported COMPLETE. The delayed failure looked like a native storage crash, but the root cause was detached JavaScript promises in the prune loop.

This mirrors the fix already merged for the sibling scheduleAuditCleanup loop in #1963.

Changes

  • resources/Table.ts
    • Tracks every deleteHistory() removal in a local set.
    • Applies backpressure at 10 in-flight removals and drains before the optional tombstone sweep and before return.
    • Counts only successful audit removals.
    • Keeps tracked promises fulfilled even if failure logging itself throws.
    • Returns the primaryStore.remove(id, version) promise from the delete callback.
  • resources/auditStore.ts
    • Joins tombstone removal with audit-store removal.
    • Contains both synchronous callback throws and asynchronous rejections, while logging tombstone cleanup failures separately.
  • unitTests/resources/auditLog.test.js
    • Covers a mid-loop rejection, success-only counting, continued pruning, warning attribution, and a throwing warning handler.
    • Proves exactly 10 removals can run concurrently, the eleventh waits, and all 11 complete.
    • Covers rejecting and synchronously throwing delete callbacks.
  • DESIGN.md
    • Records the promise-tracking and tombstone-callback invariants.

Verification

  • npm run build — passed.
  • HARPER_STORAGE_ENGINE=lmdb npx mocha unitTests/resources/auditLog.test.js — 21 passing, 5 pending.
  • npm run test:unit:resources — 1,354 passing, 17 pending.
  • HARPER_STORAGE_ENGINE=lmdb npm run test:unit:resources — 1,202 passing, 79 pending.
  • npm run test:unit:main — 4,211 passing; 10 pre-existing globalIsolation.test.js failures caused by this nested worktree resolving mqtt through the parent checkout.
  • npx harper-integration-test-run integrationTests/apiTests/transaction-logs.test.mjs with an isolated loopback pool — 7/7 passing.
  • npm run test:integration:all with an isolated loopback pool — current-version suites completed cleanly; two legacy 5.1 cross-version suites failed before their test bodies because the nested worktree's generated Unix-domain socket path exceeded the kernel limit (listen EINVAL).
  • The concurrency test was verified to fail under the prior sequential cap and pass at 10.
  • The throwing-logger regression test was verified to fail without the terminal catch and pass with it.
  • Pre-push review at the pushed head: Claude + Gemini delta review, carrying forward a full Claude + Gemini + Harper-domain review of the complete PR diff.

Risks & open questions

  • deleteHistory still cannot report partial or total removal failures through DeleteTransactionLogsBeforeResults. A systemic failure can return entries_deleted: 0 with a successful operation response and may emit one warning per failed record. Changing the result schema or abort policy is a separate design decision.
  • The real addDeleteRemoval callback wiring is covered indirectly. Dropping the callback's new return specifically would not fail the current tests.
  • Both deleteHistory regression tests are LMDB-only, matching the code path, and the optional cleanupDeletedRecords phase does not have dedicated concurrency coverage.
  • Joining tombstone removal makes the automatic background cleanup wait for that primary-store write. This is correct but can reduce delete-heavy cleanup throughput.
  • A failed automatic tombstone removal is logged, but the audit entry is still retired; automatic cleanup therefore loses its retry trigger and requires an explicit cleanup_deleted_records: true sweep later.

Refs #1963

Co-Authored-By: GPT-5 Codex noreply@openai.com

🤖 Generated with Claude Code

kriszyp and others added 4 commits August 2, 2026 09:49
…he last

Table.deleteHistory() (the LMDB path behind delete_transaction_logs_before)
stashed each removeAuditEntry()/removeEntry() promise in a single `completion`
variable, overwriting it every iteration and awaiting only the last one. A
rejection from any non-last entry — e.g. a decode failure over a corrupt or
misaligned audit entry — was silently dropped and surfaced later as an
unhandled rejection instead of being caught here. On Bun this killed the
process a few seconds after the operation had already reported COMPLETE, with
no shutdown log line (harper#F-264, 4th independent reproduction).

Mirrors the same fix already applied to the sibling scheduleAuditCleanup loop
in auditStore.ts (d6c64ea, 4ad7fdc, 1f7ddbe) — deleteHistory had the
identical pattern, unpatched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…unting failed removals

From cross-model review (Codex, Gemini, Grok):

- removeAuditEntry() still let the primary-store tombstone removal (the
  addDeleteRemovalCallback callback, which calls primaryStore.remove()) run
  detached — the exact class of bug this change set out to fix, just one level
  deeper. Return the callback's promise and combine it with the audit-store
  removal via Promise.all so a caller that awaits removeAuditEntry() actually
  covers both writes.
- deleteHistory() counted entriesDeleted even when the removal it just caught
  had failed, so a purge that hit a systemic failure (e.g. LMDB map full)
  could report entries_deleted equal to the full backlog and status COMPLETE.
  Only count on success now.
- Regression test rewritten without sinon (AGENTS.md bans new sinon/rewire
  usage in unitTests/resources/*) using a plain property swap instead, and
  keyed on the target entry's actual audit-store key rather than a global call
  counter so an unrelated background scheduleAuditCleanup pass can't perturb
  it. Added an isolated unit test for removeAuditEntry's delete-callback fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t-entry removal

From cross-model review round 2 (Codex, Gemini, domain adjudication):

- Promise.all failed fast on a rejecting delete-callback even when the audit-
  store removal itself succeeded, mis-logging it as "Error removing audit
  entry" and under-counting a removal that actually happened. The delete-
  callback's rejection is now caught and logged separately as an orphaned-
  tombstone warning; only the audit-store removal's own outcome determines
  removeAuditEntry's result and deleteHistory's count, matching the fallback
  cleanupDeletedRecords sweep that already exists for exactly this case.
- Rewrote the isolated removeAuditEntry unit test for that contract, and
  dropped a "real callback wiring" integration test I'd added — it turned out
  the auditStore/primaryStore instance `AuditedTable.primaryStore` exposes in
  this test file isn't reference-equal to the one Table.ts's addDeleteRemoval
  callback actually closes over, so the test was silently asserting nothing.
  Filed as a Finding rather than shipping a misleading green check.
- Moved the first test's orphan-cleanup call into its finally block so a
  failed assertion doesn't leak state into a later test.
- Trimmed DESIGN.md's note (dropped commit-hash/ticket-ID provenance and an
  overclaimed "kills the process on Node" framing) and extended it to cover
  the nested delete-callback hazard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…an throw

From cross-model review round 3 (Codex, Gemini, domain adjudication) — narrowing residuals only, no new behavior change:

- The delete-callback's promise was created and its .catch() attached across
  two separate statements with auditStore.remove() in between. A synchronous
  throw from that remove() call (store closing mid-pass) could unwind past
  the .catch() attachment, leaving the callback's promise without a handler.
  Wrapped the whole capture-and-attach sequence in one try/catch so nothing
  can run between creating the promise and handling it.
- DESIGN.md/comment overstated the recovery story: the orphaned tombstone
  left by a failed callback is only swept by an operator-triggered
  cleanup_deleted_records run, not by the automatic scheduleAuditCleanup
  pass (which has no way to find it once the audit entry is gone). Qualified
  the claim.
- Switched the LMDB-only regression test's rocksdb guard to this.skip()
  instead of a bare return, so a RocksDB CI leg reports "skipped" rather than
  a false "passed" that asserted nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp requested a review from kylebernhardy August 2, 2026 16:52

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request addresses a promise handling issue where audit-entry and tombstone removal loops did not properly await each iteration's promise, leading to unhandled rejections and missed cleanups. The changes ensure that each removal is awaited inline and errors are caught and logged. Additionally, regression tests were added to verify this behavior. The reviewer suggested simplifying the promise wrapping in removeAuditEntry by using new Promise to catch both synchronous throws and asynchronous rejections in a single block, eliminating the redundant try/catch block.

Comment thread resources/auditStore.ts Outdated
@claude

claude Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

…executor

From gemini-code-assist's review comment on PR #2051: use new Promise((resolve) =>
resolve(callback())) instead of a try/catch around Promise.resolve(callback()) —
a synchronous throw from the executor rejects the promise the same way a
rejection would, so one .catch() now covers both paths instead of two separate
warn() call sites. Extended the isolated removeAuditEntry unit test to cover
both a rejecting and a synchronously-throwing delete-callback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review August 2, 2026 17:42
@kriszyp
kriszyp marked this pull request as draft August 2, 2026 17:42
kriszyp and others added 3 commits August 3, 2026 20:18
Keep up to ten audit or tombstone removals in flight while attaching error handling to every write and draining each phase before returning. Add an LMDB regression test that proves the loop is neither serial nor unbounded.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Keep the tracked removal promise fulfilled if its warning handler throws, and pin that secondary rejection path in the LMDB regression test.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Restore the process-wide logger immediately after the removal call and assert the original storage failure was passed to the warning handler.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@kriszyp kriszyp changed the title fix(audit): await each deleteHistory removal inline instead of only the last fix(audit): track deleteHistory removals with bounded concurrency Aug 4, 2026
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.

1 participant