Deflake deferred cache and await LMDB reload markers - #2208
Conversation
Replace timing-dependent assertions that failed in the Node 24 and 26 CI matrix with condition waits for the observed committed state. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Allow the cache propagation poll to continue through transient non-success responses on loaded CI runners. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
There was a problem hiding this comment.
Code Review
This pull request refactors test files to use a shared waitFor utility instead of hardcoded delays or local helper implementations, improving test reliability. In cache-test.mjs, a hardcoded delay is replaced with waitFor to poll the cache endpoint. In reloadMarker.test.js, the local waitFor implementation is removed in favor of the shared utility. The feedback suggests using optional chaining when checking cacheResponse.data.name within the polling loop to prevent potential TypeErrors from prematurely failing the test.
|
Reviewed; no blockers found. |
Keep retrying when a transient response has no parsed body instead of throwing before waitFor can poll again. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
|
Reviewed I re-ran the decisive mutation to confirm the reverted assertion is genuinely falsifiable again, not just the right shape:
One thing I could not verify: — |
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
The assertion only observed that .then was called on the callback's completion, so a fire-and-forget shape at DatabaseTransaction.ts:648 kept it green. Gate on a completion that settles on a later tick instead, so the test fails unless commit actually awaits it. Co-Authored-By: Claude Opus <noreply@anthropic.com>
A trailing confirmation-exempt write (the audit-only reload marker) made lastEntry falsy, which skipped replication confirmation for every replicable write staged earlier in the same transaction — a caller awaiting replicatedConfirmation would resolve on an empty Promise.all instead of a peer ack. Both engines now scan back to the last write that is confirmable and has a stored entry. Also attach rejection handlers where commit completions are staged: the aggregating Promise.all lands a turn or more later, so a rejection in that window was an unhandled rejection. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Only the null-key audit-only path was covered on LMDB, so dropping commitCompletions chaining from the keyed branch would have stayed green. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Review follow-ups on the transaction commit-callback work, all raised as Low: - Widen lastConfirmableEntry()'s contract comment: the walk skips two kinds of write, not one — explicit opt-outs and writes with no stored entry (a delete) — and pin the second with a put-then-delete case on an `audit: false` table. - Drain staged completions in abort(). A completion staged but never aggregated by commit() previously failed silently (stageCompletion()'s no-op handler had marked it handled), and survived into a reused transaction's next commit(). - Release a still-live native handle in abort(). A write-only transaction takes no read reference, so the read-txn drain released nothing even though save() had created a handle. - Retime the LMDB keyed-write ordering test on the conditional batch instead of a 50 ms timer, so a loaded runner cannot turn it into a silent false pass. - Run the commit-callback rejection test on LMDB too, covering the no-op rejection handler in LMDBTransaction.doWrite. Refs #2178 Co-Authored-By: Claude Opus <noreply@anthropic.com>
The justification comment named abortDueToTimeout(); the abort that detaches the native handle first is abortChainAfterRetries(). The abort-drain test now also asserts the write-only transaction's handle is released, and the added comments are trimmed back to the invariants they record. Co-Authored-By: Claude Opus <noreply@anthropic.com>
A delete on an audited or delete-tracking table stores a null-value tombstone (Table.ts: `if (audit || trackDeletes) updateRecord(id, null, …)`), so getEntry() stays truthy and the confirmation walk stops there. Only a delete on a table with neither is entry-less — which is the shape the reloadMarker test builds. Comment-only; no behavior change. Refs #2178 Co-Authored-By: Claude Opus <noreply@anthropic.com>
The reload-marker assertion was not flaky. Under LMDB,
writeReloadMarker()could resolve before its asynchronous audit-store write completed, so the immediate audit scan correctly exposed a transaction completion-ordering bug. The cache assertion was a separate eventual-propagation race.This keeps the cache test's bounded poll, restores the reload marker's immediate-visibility contract, and applies the asynchronous commit-callback contract consistently across both storage engines.
Refs #2178
What changed
cache-test.mjspolls until the deferred source update is visible. It retries empty and non-success responses but still fails fast on server errors.updateRecord()completion so transaction resolution includes audit persistence.outstandingCommitvariable was removed; it had no effect on overload tracking or transaction behavior.Why this layer
The invariant belongs to the transaction wrapper: when a transaction callback starts asynchronous persistence, the returned commit promise must not resolve until that persistence settles. Polling in the test would hide the broken contract, and awaiting only in
writeReloadMarker()would leave transaction chaining and error propagation inconsistent.Null is not enough to identify a reload marker because Harper also stores root-topic publishes at the null key. The write therefore carries explicit metadata when it stages only a local audit marker.
The default path remains synchronous. Promise aggregation occurs only when a commit callback returns a promise.
Verification
HARPER_STORAGE_ENGINE=lmdb npm run test:unit:resources: changed tests passed within 1,353 passing and 93 pending; only the two existing Node.js 26randomAccessFieldsassertion failures remained.npm run test:unit:resources: changed tests passed within 1,555 passing and 15 pending; the three known Node.js 26 failures remained indatabases.test.js,randomAccessFieldsDirective.test.js, andreplayStructures.test.js.npm run test:integration:all: changed areas passed; the run ended in the unrelated Ollama backend group because Node rejectedjson/systemSchema.jsonwithout a JSON import attribute, cancelling the remaining Ollama cases.npm run build,npm run lint:required,npm run format:write, andgit diff --check: passed.The full local
test:unit:maingate remains unavailable because this checkout's unit bootstrap opens an ambient install database before test setup; it fails during module load in that external database and does not reach the changed tests.Review coverage
The required full no-Claude review ran Gemini. Claude and its domain adjudication leg were pruned by policy, while both Cursor legs were pruned because this branch had reached its two-round cap. Gemini returned eight ungraded finding bullets; source tracing and the executed engine tests did not substantiate them, so no additional code changes were made. In particular, LMDB builds the conditional batch synchronously and preserves the eventual false conflict result, both transaction modules export the tested confirmation seam, and both engines accept the stored null-key publish exercised by the tests.
— GPT-5 Codex
Review-Coverage: authored=unknown; ran=none; rounds=1 @ d92bacc
Human-Review-Need: 4 @ d92bacc