test: promote three QA regression anchors (QA-716 / QA-627 / QA-681) - #2545
Merged
Conversation
Adds three integration suites, no product code. - integrationTests/resources/qa716-lingering-write-commit.test.ts (QA-716 / P-493) pins harper#1860: writes staged across three tables in one request transaction, behind a paged secondary-index iterator the handler never drains, must be durable and index-consistent the moment the request is acked. Additive to unitTests/resources/lingeringWriteCommit.test.js, which covers the single-table case against the transaction object in isolation. - integrationTests/security/qa627-cli-user-auth-separation.test.ts (QA-627 / P-409) pins harper#1872, fixed by #1873: CLI transport auth resolves separately from the add_user/alter_user payload, as an atomic pair per source. The banked candidate asserted the pre-fix defect; inverted here to assert the shipped behaviour. - integrationTests/mqtt/qa681-mqtt-shared-subscriptions.test.ts (QA-681 / P-460) pins that $share subscriptions are explicitly REFUSED with a SUBACK reason code rather than accepted as an inert filter, that ordinary fan-out stays complete and duplicate-free alongside them, and that a durable session loses nothing across an abrupt mid-stream drop. Refs #1860 Refs #1872 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD
- QA-681 Q4 polls the durable-session resume on sequence COVERAGE rather than a delivery count. QoS-1 is at-least-once, so a resume may replay a message that was unacked when the transport died; counting deliveries reaches the expected total while a later sequence is still in flight, and the run would then fail a session that recovers everything. - QA-627's CLI home moves under the harness dataRootDir, so instance teardown removes it rather than leaking a tmpdir entry when the before hook throws, and a spawn failure's string `code` (ENOENT and friends) no longer reaches a numeric contract. - Drop a stale fixture comment and several that restated adjacent code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD
- QA-681 Q3 publishes to its own topic. Sharing Events/stream with Q1 left Q1's last row as that topic's current record, and Harper's subscribe path yields it from a detached iterator that can lose the race to SUBACK — landing a Q1 message in Q3's count on a loaded shard. - QA-681 Q4 converges on the dropped client's socket actually being down before publishing past the drop point, so the boundary assertion is deterministic rather than a race with the next publish. - QA-716 Q2 drops its index assertion: search_by_value goes through the same materialization as the base dump, hiding an expired row and dropping one whose base record is gone, so once the dump is empty the index query is empty too whatever the index holds. It could not fail, and the comment now says why rather than leaving a check that reports coverage it does not have. - QA-627 scrubs NODE_OPTIONS and DOTENV_CONFIG_PATH, which can re-inject HARPER_CLI_* from outside the throwaway cwd after the scrub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD
- QA-716 Q3 now asserts the long-transaction monitor actually reached the abandoned iterator (#1860's release-only branch logs it at warn). Without that the arm was blind: dropping the lingering transaction from write supervision, or renaming storage.maxTransactionOpenTime, would leave it sleeping 6s and re-reading writes Q1 had already proved durable. RocksDB only — LMDB never defers a commit and correctly never logs the line. - QA-681 Q3's $share filter now wraps the topic Q3 publishes to. With the filter still naming Events/stream, a regression that returned the right refusal code while leaving an ordinary subscription installed would never be challenged by a publish. - QA-627's target= URL is built literally instead of through URL setters, which percent-encode userinfo while extractTargetCredentials returns it undecoded; a password containing @ would otherwise report a #1873 regression that did not happen. - All three readiness polls use the existing waitForRouteReady helper, which reports last status and error on timeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD
Building the URL by hand was only a partial fix: the credential still reaches `new URL(target)` inside bin/cliCredentials.ts, which percent-encodes userinfo that extractTargetCredentials then returns undecoded, so `:` `;` and `=` in a password break the arm too — not just `@`, which the guard caught. The arm now provisions its own super_user with an alphanumeric password, so it pins auth precedence rather than the shape of whatever HDB_ADMIN_PASSWORD happens to be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces several integration tests as regression anchors, covering MQTT shared-subscription semantics, lingering write commits with abandoned read iterators, and CLI user authentication separation. The reviewer's feedback focuses on improving the robustness of the MQTT integration tests by ensuring proper cleanup of connections in finally blocks to prevent leaks during failures, and resolving a potential Temporal Dead Zone ReferenceError in the connection helper function.
A connect() that throws partway through an arm left the clients established before it open, and Q4 never tore down its deliberately-dropped client at all — so a failure before the drop left a live MQTT socket holding the runner's event loop, which is how a failing shard turns into a hanging one. Each arm now establishes its connections inside the try and endQuiet()s every handle, which already tolerates undefined. The connect() timer handle is hoisted to a declaration ahead of its handlers. No temporal-dead-zone error was reachable — the listeners are attached after the timer is initialized, so neither handler can run before it exists — but declaring it first makes that local rather than an argument about ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD
end(true) already closes the socket without sending a DISCONNECT, so the extra (client as any).stream?.destroy?.() bought nothing and would silently stop working if mqtt.js renamed the property. Removing it is safe to verify rather than assume: the boundary assertion added earlier requires that the dropped client received nothing published after the drop, and it holds across repeated runs with recv-before-drop=15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD
…ined
The post-connect `on('error', () => {})` was mandatory — an unhandled 'error' on an
EventEmitter throws — but discarding it meant a broker crash or socket reset surfaced
only as a downstream waitFor timeout, with the real cause gone. It now logs; Q4
destroys a transport deliberately, so this must not fail the run.
Q3 seeds and fulfills its own bucket. Reading Q1's rows made it fail whenever Q1 failed
and left it unrunnable in isolation, while testing nothing Q1 had not already
established — the same coupling already removed from Q2. No arm in the file shares state
now, so the suite-level bindings are gone with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD
Contributor
|
Reviewed; no blockers found. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What / why
Three QA regression anchors promoted out of the qa-explorer bank as committed integration suites. Test-only: the diff is ten new files — three suites, their three fixture components, and one fixture resource — and zero modifications to anything tracked.
One line per spec, with the originating QA id:
integrationTests/resources/qa716-lingering-write-commit.test.ts(QA-716 / P-493) — pins PR fix(txn): don't drop writes staged while read iterators defer the commit #1860 ("don't drop writes staged while read iterators defer the commit"): writes staged across three tables in one request transaction, behind a paged secondary-index iterator the handler returns without draining or closing, must be durable and index-consistent the moment the request is acked — not eventually.integrationTests/security/qa627-cli-user-auth-separation.test.ts(QA-627 / P-409) — pins CLI: add_user/alter_user unusable in CI/CD — auth creds and new-user payload share username=/password= #1872, fixed by PR fix(cli): separate transport auth from operation payload for add_user/alter_user #1873: the CLI resolves HTTP Basic credentials separately from theadd_user/alter_userpayload, and per source as an atomic pair.integrationTests/mqtt/qa681-mqtt-shared-subscriptions.test.ts(QA-681 / P-460) — pins that MQTT$sharesubscriptions are explicitly refused with a SUBACK reason code rather than accepted as an inert filter, that ordinary fan-out beside them stays complete and duplicate-free, and that a durable session loses nothing across an abrupt mid-stream drop.Why QA-716 is additive to
unitTests/resources/lingeringWriteCommit.test.jsThat unit test shipped with the #1860 fix and covers the basic single-table case against the transaction object in isolation. This anchor drives the same mechanism through the whole request path and combines it with the factors a real fulfillment endpoint hits at once: writes staged across three tables in one request transaction, a paged secondary-index iterator the handler never drains or closes, TTL eviction racing the deferred commit, and 4-way worker concurrency. The fixture's
FulfillPageis where the shape lives — it pulls a page and returns with the cursor simply dropped.Both are RocksDB-only in substance, since LMDB never defers a commit on open read transactions, so the suite is parameterized on
HARPER_STORAGE_ENGINEand the LMDB run is a no-delta control. Q3 asserts that the long-transaction monitor actually reached the abandoned iterator, via #1860's release-only warn line — on RocksDB only, because LMDB correctly never logs it. Without that assertion the arm was blind: dropping the lingering transaction from write supervision, or renamingstorage.maxTransactionOpenTime, would have left it sleeping and then re-reading writes Q1 had already proved durable.QA-627's polarity was inverted, deliberately
The banked candidate asserted the pre-fix defect — that the CLI must 401 because it authenticated as the user being created. Re-run as banked on current
mainit fails 2 of 5 arms, both withexpected the CLI to fail (auth collision) but it exited 0: red there means the fix works. Promoting it as a defect repro would commit a test that only goes green if #1873 is reverted, so the arms assert the shipped contract instead — env-var credentials,auth_*args and target-URL userinfo each winning the auth leg over the payload, the atomic-pair rule, and the legacyusername=/password=fallback surviving for operations where those args genuinely are the credentials.For the human reviewer
$shareparsing at all, so the literal prefix fails resource resolution and the SUBACK refuses. The promotion gate routes it to the human lane for exactly that reason. The question is whether refusing$shareis the contract Harper wants to be held to, or merely today's behaviour — a test that pins it makes it the former.describe_allwith plainusername=/password=is a compatibility anchor, not a fix anchor, and the file says so: that arm passed before fix(cli): separate transport auth from operation payload for add_user/alter_user #1873 too. It fails only if the legacy fallback is dropped outright.searchhides a row the momentexpiresAtpasses, independently of the sweep (resources/Table.ts), so QA-716's expiry assertion says an expired row is no longer returned rather than that a sweep ran.search_by_valuematerializes its hits and drops any whose base record is gone, so once the base dump is empty an index query is empty too, whatever the index actually holds — a check there could not fail. Pinning sweep-time index cleanup needs a raw-index read the operations API does not expose. Worth knowing: the same limit applies to the phantom oracle in the committedintegrationTests/database/eviction-secondary-index.test.ts:310-315, whoseresidual index entries: 0andphantom index entries: 0assertions are incapable of failing for the pure-orphan case they name. That is pre-existing and filed separately, not introduced here.authorizeLocalis never disabled, so the CONTROL arm will fail" (raised as a blocker). Refuted twice over: the harness passes--AUTHENTICATION_AUTHORIZELOCAL=trueas a CLI arg, never asprocess.env, sosecurity/auth.ts's env branch isundefinedand theHARPER_SET_CONFIGvalue fromconfig:decides — and empirically the CONTROL arm returns 401 on every run. That arm exists precisely to catch this if it ever changes.closelistener to the MQTT connect handshake. The 10s cap already converts a silent handshake drop into a clear failure, and acloselistener risks a false rejection in mqtt.js's normal event ordering — a worse trade for a test that must not flake.awaitFixtureReady()helper. Every committed suite inintegrationTests/inlines its own poll; a promotion PR is the wrong place to introduce a new cross-suite API, and three similar blocks beat a premature abstraction.qa649-mqtt-restart-wedge.test.ts,txn-overtime-atomicity.test.ts,overtime-multi-write-atomicity.test.tsandeviction-secondary-index.test.tsall carry one — and each records which fix the anchor pins and what the pre-fix failure looked like, which is the "why" the code cannot express. The inline narration flagged alongside them is gone.add_userignores an extraauth_usernamefield — so asserting it from an integration test would be theatre.unitTests/bin/cliOperations.test.jsalready owns it.Verification
Everything below ran in the worktree on current
main(ee46a6acf) afternpm run build(exit 0), because integration tests run againstdist/.qa716-lingering-write-commit.test.tsqa716-lingering-write-commit.test.ts(HARPER_STORAGE_ENGINE=lmdb)qa627-cli-user-auth-separation.test.tsqa681-mqtt-shared-subscriptions.test.tsnpx prettier --checkandnpx oxlint --format stylish --deny-warningsare clean on all three specs and the fixture resource. oxlint was confirmed armed rather than merely silent, using a non-underscore probe (qaProbeUnusedImport) that fired as expected and was then removed — oxlint'sno-unused-varsexempts underscore-prefixed identifiers, so an_-named probe proves nothing.Unit Test (Node.js v22)leg failed onunitTests/resources/longLivedTransactions.test.js— "names a chain link reachable only through the root under its own native id", asserting/state: [^,]*active/and gettingstate: source-apply. That is pre-existing and unrelated: this diff adds files only underintegrationTests/, every unit script globsunitTests/**,Unit Test (Node.js v24)passed on the same commit, and the identical failure hit main's own CI run for Report the long-lived transaction holder behind a wedged commit #2473, the commit that introduced the test. The cause is the 10-line-per-pass cap inreportLongLivedHolderdeferring a holder past the single tick during whichactiveis still in the state list, so a loaded suite intermittently reports the next state instead.vacuous-assert,abs-path,restart-fixture,bare-sleep,raw-restart,formatorlint.The three fixtures
Each suite pre-installs its own component, so none of them restarts workers.
jsResourcealongside the schema, and its schema carries the three tables the staged writes span:Orders.statusas the indexed attribute under test,Inventoryas a second table in the same transaction, andReservationas a third with an 8-second TTL — 8 rather than 3 becausesearchstops returning a row the momentexpiresAtpasses, so the window is wall-clock time from the write.mqtt; itsEventstable is a topic namespace, and non-retained publishes toEvents/*give a transient stream with no per-key coalescing, which is what makes delivery countable.FirstTableexist only to give the readiness probe a GET-able route.Repairs made before promoting
The banked snapshots carried five defects that would have shipped as coverage they did not have. They were fixed rather than carried in:
SKIPPED (harness)if the MQTT probe failed, so a run that never connected reported coverage it had not taken. They now fail. The same applied to all three suites' readiness polls, which proceeded silently after their 120s deadline.await connect(). A resuming durable session replays its QoS-1 backlog immediately after CONNACK and mqtt.js acknowledges those PUBLISHes before the awaited continuation can install a listener, so the new recovery assertion would have been flaky against a perfectly healthy broker. The collector is now attached synchronously at client creation.harper logintoken in~/.harperdb/credentials.json, a repo-root.env(cliOperations()callsdotenv.config()before resolving auth), an inheritedHARPER_CLI_USERNAME, or a CI runner's OIDC identity would each silently decide what the fallback arms resolved to. The child now runs with a throwaway HOME and cwd and an env scrubbed of all four.username=check could not detect a revert: pre-fix the CLI authenticated as a nonexistent user (401) and post-fix it sends no credentials (also 401), so both polarities matched. It now runs with a complete env pair configured, where the two differ observably.end(true)does not wait for the socket to be down. Each arm now owns its state — Q3 subscribes through a$sharefilter wrapping its own topic — and converges on the transition it depends on.target=URL arm depended on the shape of the ambient admin password. The credential reachesnew URL()insidebin/cliCredentials.ts, which percent-encodes userinfo thatextractTargetCredentialsthen returns undecoded, so@,:,;or=inHDB_ADMIN_PASSWORDwould have reported a fix(cli): separate transport auth from operation payload for add_user/alter_user #1873 regression that never happened. The arm provisions its own super_user with an alphanumeric password instead. That decoding gap is a real pre-existing product bug — atarget=https://user:p%40ss@hostcredential cannot work — and is filed separately rather than worked around silently here.One latent false-green in the banked QA-627 is gone with the inversion: its
NOT-A-WORKAROUNDarm asserted only a non-zero exit, and on currentmainit "passed" because the CLI failed withUser qa627_svc_app already exists— a 409 left by the previous arm, not an auth collision.Refs #1860
Refs #1872
Reviewed and authored by Claude Opus 5.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JZPy6UYZtwxhzLgimHWbGD
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=8 @ ca6d3ff
Human-Review-Need: 3 @ ca6d3ff