fix(qwp): preserve and retire schema-rejected QWP batches - #94
Conversation
Add REJECT_AND_CONTINUE with lease-scoped failures, bounded rejection notifications, and durable raw recovery copies before retirement. Preserve rejected ranges synchronously on the I/O thread and reuse existing queue watermark and shutdown cleanup machinery. Cover transactional and recovered groups, dictionary continuity, pool recovery, copy retries, and blocked-copy shutdown. Document compatibility and operational behavior. Validation: full core suite passed with 3,492 tests, zero failures/errors, and seven skipped; git diff --check passed.
Replace Path.of and String.repeat with Java 8-compatible equivalents, preserving the long-message round-trip coverage. Validated on Temurin Java 8: reactor compilation, six archive tests, examples, and Javadoc packaging passed.
Compare Path objects instead of raw strings so equivalent Windows path separators do not fail the recovery notification assertion. Validation: 22 related tests passed locally on Linux; Windows CI confirmation remains pending.
Code review — level 3Base Gates. Compiles clean on JDK 25. Full core suite: 3508 tests, 0 failures, 0 errors, 7 skipped. No submodule pointer moved ( Verdict: approve with comments. No Critical findings; correctness and test gates both pass. 0 Critical / 8 Moderate / 6 Minor. Priority order for this PR: M1, M2, M3 — a stalled slot that notifies nobody will generate a support ticket, and the other two are small fixes that lose diagnostics on failure paths. ModerateM1 · A transactional open-group rejection dispatches no async error, and only the producer thread can unblock itWhen
Sealing is reachable only via At base the Scope, stated accurately: Suggested fix: seal on the I/O thread using M2 ·
|
| Site | Category |
|---|---|
CursorWebSocketSendLoop.java:1922 |
SECURITY_ERROR — ws-upgrade failed |
CursorWebSocketSendLoop.java:1962 |
PROTOCOL_VIOLATION — durable-ack mismatch |
CursorWebSocketSendLoop.java:2128 |
RETRIABLE — to = max(from, publishedFsn) |
CursorWebSocketSendLoop.java:2247 |
PROTOCOL_VIOLATION |
LineSenderServerException.java:68 now prints it unconditionally, so a TLS/auth upgrade failure spanning [0,5000] reads SECURITY_ERROR rejectedFsn=5000 fsn=[0,5000] — asserting frame 5000 was rejected when no NACK occurred. The accessor's javadoc says "Local FSN named by the NACK."
No test asserts the exception message text; the only getRejectedFsn() assertion is SchemaRejectionPoolTest.java:247, on the genuine NACK path.
Suggested fix: pass NO_MESSAGE_SEQUENCE at those four sites, and print rejectedFsn= only when meaningful.
M6 · Stranded .tmp- archive trees in memory mode
For a memory-backed engine the epoch is UUID.randomUUID() per sender instance (QwpWebSocketSender.java:2931). cleanupTemporaryDirectories's match prefix embeds that epoch (RejectedMiniSlotArchive.java:309), so a new instance can never reclaim a temp tree left by an earlier one, and no epoch-agnostic sweep exists — grep -rn '"\.tmp-' over core/src/main returns exactly two hits, and all four cleanupTemporaryDirectories call sites pass the epoch.
Each stranded tree holds rejected.sfa pre-allocated to the full span size plus any dictionary snapshot. Not counted by sf_max_total_bytes, not visible in getDlqBytesWritten() (bumped only on a completed publish, CursorWebSocketSendLoop.java:3872), never deleted.
Disk-backed slots are safe: the epoch is persisted in .slot-epoch, so an intra-epoch retry or a restart under the same epoch cleans up.
Suggested fix: derive a stable epoch for memory mode, or add an age-based sweep of .tmp-* under rejected/ that ignores the epoch.
M7 · Nothing pins the new default policy, and it is duplicated across five sites
REJECT_AND_CONTINUE is independently hardcoded as the default in Sender.java:1093, QuestDBBuilder.java:75, QwpWebSocketSender.java:411, and the SenderPool / QuestDBImpl delegating constructors. Every test that exercises the policy sets it explicitly (BackgroundDrainerEndToEndTest.java:117; SchemaRejectionPoolTest.java:71, 291, 332, 394, 429). Reverting any or all five leaves the full 3508-test suite green.
This is the single line deciding whether the default population's rejected rows are retained or discarded.
Related: the PR description does not mention the default flip at all — it reads as a pure feature-add. The README and design/schema-mismatch-terminal-resolution.md document it properly (design doc phase 2: "Flip the schema default to REJECT_AND_CONTINUE only when this ships"), but the description is what feeds release notes for a client library whose users inherit the new behaviour on upgrade.
Suggested fix: one shared constant, plus one test that builds with no policy call and asserts the effective retire-vs-halt behaviour. Add the default flip and the awaitAckedFsn/getAckedFsn semantic change to the PR description.
M8 · Required cross-repo tandems are absent
Recorded:
gh pr view 94 --json body | rg -i 'questdb/questdb(-enterprise)?|tandem|e2e' -> no match
gh pr list --repo questdb/questdb --head jh_poison_pill_release --state all -> empty
gh pr list --repo questdb/questdb-enterprise --head jh_poison_pill_release --state all -> empty
Both repos are reachable from this machine (gh pr list --state open --limit 2 returns rows for each), so this is a genuine absence, not a permissions gap.
The change trips all three triggers: server-observable wire behaviour (retire-vs-halt on a NACK, post-retirement catch-up realignment, ack clamping) needs an OSS e2e tandem; the SF drainer, pool startup and reconnect pacing are HA-facing and need an Enterprise tandem; and the durability claim — bytes on disk before they are retired — rests on the temp → fsync → rename → fsync → validate → ack protocol surviving a kill -9, which no JVM unit test can express. RejectedArchiveRecoveryTest constructs directories; it does not kill a process.
Not Critical, and the reason matters: the ordering is correct by construction — preserve() fully publishes and validates before engine.acknowledge(range.lastFsn) (CursorWebSocketSendLoop.java:3897), a crash before the ack replays the source range, ff.exists(finalDir) makes re-preservation idempotent, and metadata plus per-frame CRCs make a torn archive detectable rather than silently wrong.
Reverting the whole tryRetireSchemaRange hunk does fail six tests, including CursorWebSocketSendLoopCatchUpAlignmentTest.java:164-167, which drives the real production caller trySendOneForTest() and asserts ackedFsn, getSchemaFramesRetired() and stopFsn() together. The hunk as a whole is well covered; the remaining gaps are per-arm:
- the loop's retry re-entry at
CursorWebSocketSendLoop.java:3573—testPreservationFailureRetriesThenRetiresWithoutTerminalcallstryRetireSchemaRangeForTest()twice by hand and never callsloop.start() - the predecessor-walk terminal arm at
:4498-4504 - the terminal (non-retry) preserve arm at
:3848-3859, anddispatcher == nullat:3828
Minor
SchemaRejectionPoolTest.java:130throwsAssertionError("orphan frames must be retired without sending")from aTestWebSocketServerhandler. That runs on the server'sreadThread, whose lambda catches onlyIOException(TestWebSocketServer.java:754), so the named invariant is unenforced. The harness swallow is pre-existing (CloseDrainTest.java:890,BackgroundDrainerMidDrainCapabilityGapTest.java:582do the same at base); what is new is using the idiom to assert a data-path invariant rather than a latch timeout. Same shape at:274,:376.SenderPoolSfTest.java:2634-2636—Files.delete(...)thenassertFalse(Files.exists(...))cannot fail (deletethrows instead). It replaced an assertion that did depend on production behaviour, and nothing asserts the new eager-probe footprint. Note the probe now createssf_dirat pool construction — itself an untested behaviour change.- Four new test files (
SchemaRejectionStateTest,RejectedMiniSlotArchiveTest,RejectedArchiveRecoveryTest,SchemaRejectionPoolTest) allocate native memory with zeroassertMemoryLeak, against 61/78 in the same package. SchemaRejectionPoolTest.java:410— three-level private-field reflection whereb.flushAndGetSequence()is used four times in the same file (:193,:208,:241,:337). Thefield(...)helper at:99also usesgetDeclaredField, which does not walk superclasses.RejectedMiniSlotArchiveTest.java:43-57hand-rolls temp-dir setup/teardown whereTestUtils.createTmpDir(:154) /removeTmpDirRec(:233) exist. Hardcoded'/'path fragments in theFilesFacadefakes (SchemaPreservationCloseTest.java:125, two inPoisonFrameTest) on a branch that already carries984ed35"compare recovered archive paths portably".- Javadoc continuation lines indented four spaces past the opening
/**on all six new builder methods (QuestDBBuilder.java:172-176, 183-186, 192-196;Sender.java:2144-2148, 2156-2159, 2165-2169). Private test helpers interleaved between@Testmethods (SchemaRejectionStateTest.java:146,SchemaRejectionPoolTest.java:98).
Hypotheses chased and disproved
Recording these so the next review does not re-spend the effort:
- Archived tail frame corrupted by the flag mutation. QWP headers carry no checksum over the flags byte, and
MmapSegment.tryAppendcomputes its frame CRC over the already-mutated scratch bytes (MmapSegment.java:724). - Stale
liveLookupIndex/liveLookupOffsetserving the wrong frame. Every route reachesMmapSegment.liveFrameOffsetthrough the threesynchronizedSegmentRingdelegates (:1302/1307/1312) — full caller inventory checked.rebaseSeqthrows whenframeCount > 0(MmapSegment.java:667), so it only runs on an empty segment whose memo is still(0, HEADER_SIZE). The producer'stryAppendnever touches the memo. - Lost update in
acknowledgedThrough. All three writers are I/O-thread-only (CursorWebSocketSendLoop.java:2299,:4245,SchemaRejectionState.java:145). Single writer. schemaPendingleaking and stalling retirement forever. Theclosed || !schemaInbox.offer(...)short-circuit never enqueues on a closed dispatcher, anddispatchLoop's condition drainsschemaInboxbefore exiting.- Reconnect budget imposed on the running drainer.
failSchemaPacedmirrorsfailPacedexactly — per-attempt backoff capped, retry loop unbounded, counter reset on a real ACK (:4207). No new terminal. transactionalclobbered by the legacyconnectWithCredentialSupplieroverload. The only production caller isSender.java:1705on the new 27-arg overload, andSender.java:1781still sets it post-connect exactly as at base.preparedSchemaFirstFsnguard skipping dictionary catch-up after a recycle.swapClientdoes not resetsentDictCount; the mirror survives viasetWireBaselineWithCatchUp.- Unsafe publication of the plain pool-side schema fields.
beginSchemaLease/endSchemaLease/prepareSchemaPoolSlotare all under the pool'sReentrantLock(SenderPool.java:1286,:1355,:1709,:1351). - Native-memory and fd leaks in the new archive code.
preserve0'smaxPayloadis provably > 0;writeMetadata,validateandreleaseSkippedFrameScratchpair correctly on every path;snapshotTo's fd read is page-cache-coherent with theMAP_SHAREDmapping.
Tradeoffs worth confirming consciously
These are the team's decisions, documented in the README and design doc. Not findings — flagging them only so the release note is deliberate:
- The default schema-mismatch policy flips from halt-and-retain to retire-and-continue. Existing users inherit row discards on upgrade unless they set
.schemaMismatchPolicy(TERMINAL). awaitAckedFsn/getAckedFsnnow return true / advance for locally-retired frames. Code shaped likeif (awaitAckedFsn(fsn, 30_000)) { /* ingested */ }silently changes meaning. The javadoc was updated; the compiler cannot help.- Background orphan-drainer schema reports now carry the orphan queue's local FSN span to the same user handler that receives live-sender reports, where the base code deliberately stripped it (
BackgroundDrainer.java:1111-1117vs the survivingelse ifat:1119-1133).getRejectedPath() != nullis the only discriminator.
Submodule provenance: no pointer moved — core/src/main/c/share/zstd unchanged. Scope is this repo's source only. Admitted split: 8 in-diff, 0 out-of-diff-breakage.
🤖 Generated with Claude Code
[PR Coverage check]😍 pass : 765 / 894 (85.57%) file detail
|
A batch rejected for not matching a table’s structure could previously block all later data, even after restarting the client.
This change reports the rejection and removes the affected batch from the retry queue. With disk buffering enabled, it saves a copy first. Applications can continue by returning the failed sender to the pool and borrowing again, or by rebuilding a standalone sender. The previous stop-on-error behavior remains available as an option.
To restore the previous behavior:
The same option is available on
QuestDB.builder()for pooled senders.Also fixes memory buildup during outages and startup failures caused by damaged saved copies.