diff --git a/README.md b/README.md index e945c777..1e6b8b49 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,63 @@ try (Sender sender = db.borrowSender()) { You can also let the client flush batches for you with the `auto_flush_rows` / `auto_flush_interval` config keys, e.g. `ws::addr=localhost:9000;auto_flush_rows=10000;auto_flush_interval=1000;`. -**Confirm a batch is durably received.** Over QWP each flush returns a frame sequence number (FSN); `awaitAckedFsn` -blocks until the server has acknowledged it. With `sf_dir`, rows in the store-and-forward log replay after reconnect +**Schema errors and preserved copies.** With QuestDB 10.0.0 or later, QWP schema mismatches use +`SenderError.Policy.REJECT_AND_CONTINUE`: the owning borrowed sender fails, but +returning it and borrowing again lets the slot continue. Close and rebuild a +standalone sender after its error. Preservation runs on the I/O thread. Slow +disk I/O can delay close; if its +shutdown budget expires, cleanup retains the slot lock until that thread exits, +so rebuilding immediately may require a retry. +Other error categories keep their existing policies. Select `.schemaMismatchPolicy(SenderError.Policy.TERMINAL)` on either +builder to retain the old preserve-and-halt behavior. + +With `sf_dir`, rejected frames are copied to `//rejected/` before +retirement. A split flush can also retire valid deferred frames preceding the +bad frame; these are included in the copy. After restart, transaction mode is +unknown, so retirement conservatively includes the whole recovered commit group. +The asynchronous `errorHandler` +receives `error.getRejectedPath()` only after the directory is complete. The +producer exception carries the trigger FSN and affected range, but its path may +be null while copying is pending. Notifications are retained in a separate +256-entry queue per slot; a full queue pauses that slot's retirement. Crashes +and shutdown can still lose queued notifications. + +Use `.dlqDirectory(path)` for a different base directory or a memory-only +sender; copies go under `path//rejected/`. Memory-only senders without an +explicit destination retire without a preserved copy. `.dlqEnabled(false)` disables +preservation and accepts permanent loss of retired rows. These are builder +options. A configured destination is checked at build time; later storage +failures pause retirement and retry the copy while keeping the source frames. +A second schema rejection while an earlier range is pending, or an invalid +retirement range/dictionary, logs an error and falls back to `TERMINAL`. +Preserved payloads use the binary store-and-forward format; `rejection.properties` +contains human-readable error metadata. A source queue namespace, the source +segment's persisted generation token, and the exact FSN range determine the archive +directory. A retry or restart for that same live range removes its exact crashed +staging directory and reuses a structurally valid completed copy. + +Startup does not scan archive directories. If recovery finds an orphan tail, it +checks only that range's deterministic archive path. A completed copy whose +metadata, segment, manifest, watermark, and optional dictionary validate produces +an asynchronous `SenderError` before the tail retires. A missing or damaged copy +is ignored so archive output cannot block live-queue recovery. Unrelated and +legacy `.tmp-*` directories are left untouched. A crash before publication or +after retirement but before callback delivery can still lose the notification. +Copy an archive to a separate working directory before replaying it, because +normal queue cleanup removes drained data. Replay after fixing the schema can +duplicate rows that the server committed before the error. + +Completed copies are never automatically deleted and can contain a full symbol dictionary +each. Quarantining a damaged live slot also moves its archives; use the `DATA_LOSS` +event's quarantine path to locate copies whose reported paths have moved. +Monitor `getDlqBytesWritten()`, `getDlqFilesWritten()` and free disk space +(the counters are available on `QwpWebSocketSender`). TLS does not encrypt these +files at rest. With preservation disabled, a persistent schema problem can +retire data indefinitely; keep your source data and monitor the error handler. + +**Wait for queue progress.** Over QWP each flush returns a frame sequence number (FSN); `awaitAckedFsn` +blocks until that sequence is resolved. Resolution includes server acknowledgements and locally retired rejected +frames, so observe the error handler as well; a successful wait alone does not prove every row was ingested. With `sf_dir`, rows in the store-and-forward log replay after reconnect or a producer-process restart. For periodic host-power-loss checkpoints, also configure `sf_durability=periodic;sf_sync_interval_millis=5000;`. @@ -152,8 +207,8 @@ try (Sender sender = db.borrowSender()) { sender.table("trades").symbol("symbol", t.symbol).doubleColumn("price", t.price).atNow(); } long fsn = sender.flushAndGetSequence(); // publish the batch, get its sequence number - if (sender.awaitAckedFsn(fsn, 30_000)) { // block up to 30s for the server ack - // batch acknowledged by the server + if (sender.awaitAckedFsn(fsn, 30_000)) { // block up to 30s for resolved progress + // queue resolved through fsn; check rejection notifications for ingestion errors } else { // not yet acked within the timeout; it stays buffered and replays on reconnect } diff --git a/core/src/main/java/io/questdb/client/LineSenderServerException.java b/core/src/main/java/io/questdb/client/LineSenderServerException.java index 2f23d9d0..a6f3f9ca 100644 --- a/core/src/main/java/io/questdb/client/LineSenderServerException.java +++ b/core/src/main/java/io/questdb/client/LineSenderServerException.java @@ -65,6 +65,7 @@ private static String buildMessage(SenderError e) { if (status != SenderError.NO_STATUS_BYTE) { sb.append(" (status=0x").append(Integer.toHexString(status & 0xFF)).append(')'); } + sb.append(" rejectedFsn=").append(e.getRejectedFsn()); sb.append(" fsn=[").append(e.getFromFsn()).append(',').append(e.getToFsn()).append(']'); if (e.getTableName() != null) { sb.append(" table=").append(e.getTableName()); diff --git a/core/src/main/java/io/questdb/client/QuestDBBuilder.java b/core/src/main/java/io/questdb/client/QuestDBBuilder.java index e846ad12..ce195817 100644 --- a/core/src/main/java/io/questdb/client/QuestDBBuilder.java +++ b/core/src/main/java/io/questdb/client/QuestDBBuilder.java @@ -72,6 +72,9 @@ public final class QuestDBBuilder { private SenderConnectionListener connectionListener; private BackgroundDrainerListener drainerListener; private SenderErrorHandler errorHandler; + private SenderError.Policy schemaMismatchPolicy = SenderError.Policy.REJECT_AND_CONTINUE; + private boolean dlqEnabled = true; + private String dlqDir; private long housekeeperIntervalMillis = UNSET; private HttpTokenProvider httpTokenProvider; private String config; @@ -166,6 +169,41 @@ public QuestDBBuilder errorHandler(SenderErrorHandler handler) { return this; } + /** + * Select schema-mismatch handling. REJECT_AND_CONTINUE fails the owning + * handle and retires its rejected prefix; the underlying slot continues. + * TERMINAL retains queued frames and halts the slot. + */ + public QuestDBBuilder schemaMismatchPolicy(SenderError.Policy policy) { + if (policy != SenderError.Policy.TERMINAL && policy != SenderError.Policy.REJECT_AND_CONTINUE) { + throw new IllegalArgumentException("schema mismatch policy must be TERMINAL or REJECT_AND_CONTINUE"); + } + schemaMismatchPolicy = policy; + return this; + } + + /** + * Enable preserved copies before schema retirement (default: enabled for disk queues). + * Disabling preservation accepts permanent loss of retired rows. + */ + public QuestDBBuilder dlqEnabled(boolean enabled) { + dlqEnabled = enabled; + return this; + } + + /** + * Set the raw-copy base directory, including for memory-only queues. + * Copies live under directory/slot/rejected and are never automatically deleted. + * The asynchronous error names the completed directory. + */ + public QuestDBBuilder dlqDirectory(String directory) { + if (directory == null || directory.isEmpty()) { + throw new IllegalArgumentException("DLQ directory must not be empty"); + } + dlqDir = directory; + return this; + } + /** * Builds the {@link QuestDB} handle. Validates both connect strings up * front -- so a malformed config fails here even when both pools have @@ -235,10 +273,10 @@ public QuestDB build() { maxLifetimeMillis, housekeeperIntervalMillis, queryCloseTimeoutMillis, - httpTokenProvider, + null, null, httpTokenProvider, errorHandler, connectionListener, - drainerListener + drainerListener, schemaMismatchPolicy, dlqEnabled, dlqDir ); } diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 645d7b25..113211d2 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -275,10 +275,16 @@ static Sender fromEnv() { void atNow(); /** - * Block until the server has acknowledged every frame up to {@code targetFsn}, + * Block until every frame up to {@code targetFsn} is resolved, * or until {@code timeoutMillis} elapses. Pair with {@link #flushAndGetSequence()} * to obtain {@code targetFsn} for a specific flush. *
+ * Resolution includes server acknowledgements, schema-rejected ranges retired by + * {@link SenderError.Policy#REJECT_AND_CONTINUE}, and recovered orphan tails. + * A successful wait is progress, not proof that every row was ingested. A pooled + * sender may wait for an earlier borrow's FSN; an error owned by the current + * borrow still throws. Observe the error handler for earlier rejected ranges. + *
* When {@code request_durable_ack=on} (Enterprise primary replication), {@code targetFsn} * advances after durable upload to object storage, not on the ordinary commit ACK. *
@@ -288,7 +294,7 @@ static Sender fromEnv() { * * @param targetFsn FSN to wait for; typically the return value of {@link #flushAndGetSequence()} * @param timeoutMillis upper bound on the wait; {@code <= 0} returns the current state without blocking - * @return {@code true} if the server has acknowledged up to {@code targetFsn} on return, {@code false} on timeout + * @return {@code true} if the queue has resolved up to {@code targetFsn}, {@code false} on timeout * @throws LineSenderException if the transport has latched a terminal error */ default boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { @@ -503,8 +509,8 @@ default Sender decimalColumn(CharSequence name, CharSequence value) { * @param timeoutMillis upper bound on the wait; {@code <= 0} returns the * current state without blocking (the flush still * happens before the check) - * @return {@code true} if the server has acknowledged every published - * frame on return, {@code false} on timeout + * @return {@code true} if every published frame is resolved on return, + * {@code false} on timeout * @throws LineSenderException if the transport has latched a terminal error */ default boolean drain(long timeoutMillis) { @@ -610,14 +616,16 @@ default Sender geoHashColumn(CharSequence name, CharSequence value) { } /** - * Highest frame sequence number (FSN) the server has acknowledged. + * Highest contiguous resolved frame sequence number (FSN). Includes server + * acknowledgements and locally retired schema-rejected ranges or orphan tails; + * this is queue progress, not a count of successfully ingested rows. * Returns {@code -1} when no batch has been published yet, and on transports that * do not track FSNs (HTTP, TCP, UDP). *
* Snapshot accessor: for a bounded blocking wait, use * {@link #awaitAckedFsn(long, long)}. * - * @return highest acknowledged FSN, or {@code -1} if none or unsupported + * @return highest resolved FSN, or {@code -1} if none or unsupported */ default long getAckedFsn() { return -1L; @@ -1082,6 +1090,9 @@ final class LineSenderBuilder { // Optional user-supplied async error handler. When null, the sender // uses DefaultSenderErrorHandler.INSTANCE (loud-not-silent log). private io.questdb.client.SenderErrorHandler errorHandler; + private SenderError.Policy schemaMismatchPolicy = SenderError.Policy.REJECT_AND_CONTINUE; + private boolean dlqEnabled = true; + private String dlqDir; // Bounded inbox capacity for the async error dispatcher. // PARAMETER_NOT_SET_EXPLICITLY → spec default (256). private int errorInboxCapacity = PARAMETER_NOT_SET_EXPLICITLY; @@ -1714,7 +1725,8 @@ public Sender build() { actualConnectionListenerInboxCapacity, actualMaxFrameRejections, actualPoisonMinEscalationWindowMillis, - actualCatchUpCapGapMinEscalationWindowMillis + actualCatchUpCapGapMinEscalationWindowMillis, + schemaMismatchPolicy, dlqEnabled, dlqDir, transactional ); } catch (UnreplayableSlotException e) { // The one failure build() recovers from. The slot's frames reference ids @@ -2129,11 +2141,47 @@ public LineSenderBuilder enableTls() { return this; } + /** + * Select schema-mismatch handling. REJECT_AND_CONTINUE fails the owning + * handle and retires its rejected prefix; the underlying slot continues. + * TERMINAL retains queued frames and halts the slot. + */ + public LineSenderBuilder schemaMismatchPolicy(SenderError.Policy policy) { + if (policy != SenderError.Policy.TERMINAL && policy != SenderError.Policy.REJECT_AND_CONTINUE) { + throw new IllegalArgumentException("schema mismatch policy must be TERMINAL or REJECT_AND_CONTINUE"); + } + schemaMismatchPolicy = policy; + return this; + } + + /** + * Enable preserved copies before schema retirement (default: enabled for disk queues). + * Disabling preservation accepts permanent loss of retired rows. + */ + public LineSenderBuilder dlqEnabled(boolean enabled) { + dlqEnabled = enabled; + return this; + } + + /** + * Set the raw-copy base directory, including for memory-only queues. + * Copies live under directory/slot/rejected and are never automatically deleted. + * The asynchronous error names the completed directory. + */ + public LineSenderBuilder dlqDirectory(String directory) { + if (directory == null || directory.isEmpty()) { + throw new IllegalArgumentException("DLQ directory must not be empty"); + } + dlqDir = directory; + return this; + } + /** * Sets the async error handler invoked for every server-side rejection. * The handler runs on a dedicated daemon dispatcher thread, never on the * I/O thread or producer thread. Slow handlers do not stall publishing; - * if the bounded inbox fills up, surplus notifications are dropped + * schema rejections use a separate 256-entry queue that pauses retirement when full. + * For other categories, if the bounded inbox fills up, surplus notifications are dropped * (visible via {@code QwpWebSocketSender.getDroppedErrorNotifications()}). * *

WebSocket transport only; setting on other transports throws. diff --git a/core/src/main/java/io/questdb/client/SenderError.java b/core/src/main/java/io/questdb/client/SenderError.java index 3d11995e..93ed50fc 100644 --- a/core/src/main/java/io/questdb/client/SenderError.java +++ b/core/src/main/java/io/questdb/client/SenderError.java @@ -36,15 +36,19 @@ *

    *
  • Asynchronously via {@link SenderErrorHandler} registered on the builder.
  • *
  • Synchronously as the payload of a {@link LineSenderServerException} thrown - * from the next producer-thread API call after a {@link Policy#TERMINAL} error has + * from the next producer-thread API call after a {@link Policy#TERMINAL} or + * owned {@link Policy#REJECT_AND_CONTINUE} error has * been latched.
  • *
* *

The {@code [fromFsn, toFsn]} span is the load-bearing correlation key — join it to * whatever the producer thread logged alongside the published-sequence value returned by - * the sender to identify the rejected data. Background orphan-drainer reports use - * {@link #NO_MESSAGE_SEQUENCE} for both bounds because those FSNs belong to another sender - * engine and must not be joined to the live producer's rows. + * the sender to identify the rejected data. A schema report recovered from a completed + * preserved copy retains the recovered queue's local FSN span; use the archive path to + * identify its queue. Such a report is reconstructed only for the exact still-live orphan + * range whose deterministic archive exists and passes structural validation. Other + * background reports use {@link #NO_MESSAGE_SEQUENCE}. Never join an orphan's FSNs to the + * live producer's rows. * * @see SenderErrorHandler * @see LineSenderServerException @@ -69,6 +73,8 @@ public final class SenderError { private final int serverStatusByte; private final String tableName; private final long toFsn; + private final long rejectedFsn; + private final String rejectedPath; public SenderError( @NotNull Category category, @NotNull Policy appliedPolicy, @@ -96,6 +102,16 @@ private SenderError( long detectedAtNanos, @Nullable String quarantinedPath ) { + this(category, appliedPolicy, serverStatusByte, serverMessage, messageSequence, + fromFsn, toFsn, tableName, detectedAtNanos, quarantinedPath, toFsn, null); + } + + private SenderError(Category category, Policy appliedPolicy, int serverStatusByte, + String serverMessage, long messageSequence, long fromFsn, long toFsn, + String tableName, long detectedAtNanos, String quarantinedPath, + long rejectedFsn, String rejectedPath) { + this.rejectedFsn = rejectedFsn; + this.rejectedPath = rejectedPath; this.category = category; this.appliedPolicy = appliedPolicy; this.serverStatusByte = serverStatusByte; @@ -127,6 +143,37 @@ public static SenderError dataLoss(@NotNull String detail, @NotNull String quara System.nanoTime(), quarantinedPath); } + /** Local FSN named by the NACK, distinct from the full retired span. */ + public long getRejectedFsn() { + return rejectedFsn; + } + + /** Completed preserved-copy directory, or null when this report has no available copy. */ + public @Nullable String getRejectedPath() { + return rejectedPath; + } + + /** Internal copy operation used when a singleton error is resolved to a retirement span. */ + public SenderError withRejectionSpan(long first, long last) { + return new SenderError(category, Policy.REJECT_AND_CONTINUE, serverStatusByte, + serverMessage, messageSequence, first, last, tableName, detectedAtNanos, + quarantinedPath, rejectedFsn, rejectedPath); + } + + /** Returns a new error after the preserved copy has been published. */ + public SenderError withRejectedPath(String path) { + return new SenderError(category, appliedPolicy, serverStatusByte, serverMessage, + messageSequence, fromFsn, toFsn, tableName, detectedAtNanos, + quarantinedPath, rejectedFsn, path); + } + + /** Internal copy operation used when a fail-closed fallback changes policy. */ + public SenderError withAppliedPolicy(Policy policy) { + return new SenderError(category, policy, serverStatusByte, serverMessage, + messageSequence, fromFsn, toFsn, tableName, detectedAtNanos, + quarantinedPath, rejectedFsn, rejectedPath); + } + /** * @return the policy the I/O loop actually applied — RETRIABLE / RETRIABLE_OTHER means * the batch stays in the store-and-forward log and is replayed after a reconnect (no data @@ -145,7 +192,9 @@ public static SenderError dataLoss(@NotNull String detail, @NotNull String quara } /** - * @return wall-clock-independent receipt time on the I/O thread, from {@link System#nanoTime()}. + * @return the value of {@link System#nanoTime()} when the original process received the + * rejection. A report reconstructed from a preserved copy retains that raw value; it cannot + * be compared or ordered against {@code nanoTime()} values from the recovering process. */ public long getDetectedAtNanos() { return detectedAtNanos; @@ -153,16 +202,18 @@ public long getDetectedAtNanos() { /** * @return inclusive lower bound of the FSN span for the rejected batch — correlation key for producer-side logs. - * For {@link Category#DATA_LOSS} and background orphan-drainer reports this is - * {@link #NO_MESSAGE_SEQUENCE} — the span is unknown or does not belong to the live sender. + * For {@link Category#DATA_LOSS} and non-schema background reports this is + * {@link #NO_MESSAGE_SEQUENCE}. Recovered schema reports retain the orphan queue's local span. */ public long getFromFsn() { return fromFsn; } /** - * @return server's per-frame messageSequence as mirrored back in the rejection frame, or - * {@link #NO_MESSAGE_SEQUENCE} for {@link Category#PROTOCOL_VIOLATION} (WS close frames carry no QWP sequence). + * @return the server's per-frame message sequence mirrored in a live rejection, or + * {@link #NO_MESSAGE_SEQUENCE} when no QWP sequence exists. A schema report reconstructed + * from a preserved copy uses its persisted rejected FSN here because the original wire + * sequence is not stored; use {@link #getRejectedFsn()} for that local correlation value. */ public long getMessageSequence() { return messageSequence; @@ -205,8 +256,8 @@ public int getServerStatusByte() { /** * @return inclusive upper bound of the FSN span for the rejected batch. - * For {@link Category#DATA_LOSS} and background orphan-drainer reports this is - * {@link #NO_MESSAGE_SEQUENCE} — the span is unknown or does not belong to the live sender. + * For {@link Category#DATA_LOSS} and non-schema background reports this is + * {@link #NO_MESSAGE_SEQUENCE}. Recovered schema reports retain the orphan queue's local span. */ public long getToFsn() { return toFsn; @@ -302,24 +353,23 @@ public enum Category { } /** - * Policy applied by the client when a category fires. Resolution precedence (highest first): - * builder {@code errorPolicyResolver} → builder per-category {@code errorPolicy} → - * connect-string per-category {@code on_*_error} → connect-string global {@code on_server_error} - * → spec defaults. + * Policy applied by the client. Schema mismatch can be overridden through + * the schemaMismatchPolicy builder setting; other categories use their defaults. + * Reserved on_* connection-string settings do not implement a general resolver. * - *

There is no silent-drop policy by design: the client never discards - * data without telling anyone. A rejected batch is replayed - * ({@link #RETRIABLE} / {@link #RETRIABLE_OTHER}), halts the sender loudly - * with the bytes preserved on disk ({@link #TERMINAL}), or — the one case - * where the bytes can never be sent — is abandoned in place and announced - * as {@link #ABANDONED}, which is precisely what keeps the abandonment - * non-silent. + *

QWP builders default schema mismatches to {@link #REJECT_AND_CONTINUE}: + * retire the affected span after preserving it when configured, notify the + * handler, and fail its owning handle. Other errors replay, halt with bytes + * retained, or report explicit abandonment. Rejection notifications are retained + * while running. On restart, a completed preserved copy reconstructs a notification + * only for its exact still-live recovered orphan range; a crash or shutdown can still + * lose callbacks before publication or after retirement. * *

{@link Category#PROTOCOL_VIOLATION} is forced {@link #TERMINAL}, * {@link Category#UNKNOWN} is forced {@link #RETRIABLE} (fail open: a * status byte from a newer server must degrade to retry, not to a dead * sender), and {@link Category#DATA_LOSS} is forced {@link #ABANDONED}; - * user overrides for these categories are ignored. + * the schema policy override cannot change these categories. */ public enum Policy { /** @@ -358,6 +408,12 @@ public enum Policy { * select it or override it away. It reports a fact about bytes already * abandoned, not a choice about how to react. */ - ABANDONED + ABANDONED, + /** + * Retire the rejected span and continue independent queued work. The owning + * handle fails until returned or rebuilt. Retirement is not server acceptance; + * a preserved copy is available only when export is enabled and completes. + */ + REJECT_AND_CONTINUE } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 41cc0a8c..51dbdb9a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -29,6 +29,7 @@ import io.questdb.client.SenderConnectionEvent; import io.questdb.client.SenderConnectionListener; import io.questdb.client.SenderError; +import io.questdb.client.LineSenderServerException; import io.questdb.client.SenderErrorHandler; import io.questdb.client.SenderProgressHandler; import io.questdb.client.cairo.TableUtils; @@ -39,10 +40,12 @@ import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.line.array.DoubleArray; import io.questdb.client.cutlass.line.array.LongArray; +import io.questdb.client.cutlass.qwp.client.sf.cursor.RejectedMiniSlotArchive; import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer; import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener; import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SchemaRejectionState; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderConnectionListener; import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderErrorHandler; @@ -404,6 +407,14 @@ public class QwpWebSocketSender implements Sender { // explicit flush() triggers the server-side commit. Enables accumulating // arbitrarily large datasets that exceed the server's recv buffer. private boolean transactional; + private SenderError.Policy schemaMismatchPolicy = SenderError.Policy.REJECT_AND_CONTINUE; + private boolean dlqEnabled = true; + private String dlqDir; + private RejectedMiniSlotArchive schemaPreserver; + private SchemaRejectionState schemaRejectionState; + private long schemaLeaseGeneration; + private boolean schemaLeaseStarted; + private LineSenderServerException observedSchemaFailure; // Server-advertised hard cap on QWP ingest payload bytes, captured from // X-QWP-Max-Batch-Size on each successful FOREGROUND handshake (a // background drainer's endpoint cap is irrelevant to the producer's wire). 0 when the server @@ -890,6 +901,36 @@ public static QwpWebSocketSender connectWithCredentialSupplier( int maxFrameRejections, long poisonMinEscalationWindowMillis, long catchUpCapGapMinEscalationWindowMillis + ) { + return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, authorizationHeaderSupplier, requestDurableAck, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs, connectionListener, connectionListenerInboxCapacity, maxFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, + SenderError.Policy.REJECT_AND_CONTINUE, true, null, false); + } + + public static QwpWebSocketSender connectWithCredentialSupplier( + List endpoints, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + Supplier authorizationHeaderSupplier, + boolean requestDurableAck, + CursorSendEngine cursorEngine, + long closeFlushTimeoutMillis, + long reconnectMaxDurationMillis, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + Sender.InitialConnectMode initialConnectMode, + SenderErrorHandler errorHandler, + int errorInboxCapacity, + long durableAckKeepaliveIntervalMillis, + long authTimeoutMs, + int connectTimeoutMs, + SenderConnectionListener connectionListener, + int connectionListenerInboxCapacity, + int maxFrameRejections, + long poisonMinEscalationWindowMillis, + long catchUpCapGapMinEscalationWindowMillis, + SenderError.Policy schemaMismatchPolicy, boolean dlqEnabled, String dlqDir, boolean transactional ) { QwpWebSocketSender sender = new QwpWebSocketSender( endpoints, tlsConfig, @@ -922,6 +963,8 @@ public static QwpWebSocketSender connectWithCredentialSupplier( if (cursorEngine != null) { sender.setCursorEngine(cursorEngine, true); } + sender.setTransactional(transactional); + sender.configureSchemaMismatch(schemaMismatchPolicy, dlqEnabled, dlqDir); sender.ensureConnected(); } catch (Throwable t) { // Preserve t's IDENTITY through the rollback. Sender.build() routes on the @@ -1323,7 +1366,8 @@ private void close0(boolean[] restoreInterrupt) { // SenderError HALTs (server-side rejections like MESSAGE_TOO_BIG, // SCHEMA_MISMATCH HALT) from users who only call close() and // never call flush() afterwards. - Throwable terminalError = null; + boolean schemaFailedOnClose = hasOwnedSchemaFailure(); + Throwable terminalError = schemaFailedOnClose ? releaseFailedSchemaLease() : null; // Snapshot the exact terminal error instance that a user-thread // API call ALREADY caught (via flush()/at()) before close() ran. // If flushPendingRows/drainOnClose below also rethrow the same @@ -1343,7 +1387,7 @@ private void close0(boolean[] restoreInterrupt) { // Only drain when both the engine and the I/O loop are wired // up — close() is also called from createForTesting() teardown // and from connect() rollback paths where one or both may be null. - if (connectionError.get() == null && cursorEngine != null && cursorSendLoop != null) { + if (!schemaFailedOnClose && connectionError.get() == null && cursorEngine != null && cursorSendLoop != null) { // 1) Flush user-thread state into the engine (encoded // rows -> mmap'd / malloc'd ring). After this, the // cursor engine's publishedFsn reflects the final @@ -2166,6 +2210,33 @@ public boolean isDeltaDictEnabledForTest() { return deltaDictEnabled; } + /** Frames resolved locally after schema rejection; these were not accepted by the server. */ + public long getSchemaFramesRetired() { + CursorWebSocketSendLoop loop = cursorSendLoop; + return loop == null ? 0 : loop.getSchemaFramesRetired(); + } + + public long getSchemaRejections() { + CursorWebSocketSendLoop loop = cursorSendLoop; + return loop == null ? 0 : loop.getSchemaRejections(); + } + + public long getDlqWriteFailures() { + CursorWebSocketSendLoop loop = cursorSendLoop; + return loop == null ? 0 : loop.getDlqWriteFailures(); + } + + public long getDlqFilesWritten() { + CursorWebSocketSendLoop loop = cursorSendLoop; + return loop == null ? 0 : loop.getDlqFilesWritten(); + } + + /** Cumulative preserved bytes. Monitor together with destination free space. */ + public long getDlqBytesWritten() { + CursorWebSocketSendLoop loop = cursorSendLoop; + return loop == null ? 0 : loop.getDlqBytesWritten(); + } + /** * Total binary frames whose ACKs have been received and applied. */ @@ -2678,6 +2749,11 @@ public void setCursorSendLoopForTesting(CursorWebSocketSendLoop loop) { progressHandler, SenderProgressDispatcher.DEFAULT_CAPACITY); } loop.setConnectionDispatcher(connectionDispatcher); + if (!schemaLeaseStarted) { + beginSchemaLease(0L); + } + loop.setSchemaRejectionState(schemaRejectionState); + loop.setSchemaMismatchPolicy(schemaMismatchPolicy); loop.setErrorDispatcher(errorDispatcher); loop.setProgressDispatcher(progressDispatcher); } @@ -2738,6 +2814,132 @@ public void setErrorInboxCapacity(int capacity) { this.errorInboxCapacity = capacity; } + /** Internal recovery barrier: local retirement counts as progress, never acceptance. */ + public boolean drainResolved(long timeoutMillis) { + if (closed) { + throw new LineSenderException("Sender is closed"); + } + if (cursorEngine == null) { + return true; + } + long target = cursorEngine.publishedFsn(); + long deadline = System.nanoTime() + Math.max(0L, timeoutMillis) * 1_000_000L; + while (cursorEngine.ackedFsn() < target) { + if (closed) { + throw new LineSenderException("Sender is closed"); + } + cursorEngine.checkDurability(); + if (cursorSendLoop != null) { + cursorSendLoop.checkError(); + } + if (timeoutMillis <= 0 || System.nanoTime() >= deadline) { + return false; + } + java.util.concurrent.locks.LockSupport.parkNanos(50_000L); + } + return true; + } + + /** Internal pool lifecycle: end the initial standalone observation before borrowing. */ + public void prepareSchemaPoolSlot() { + if (schemaLeaseStarted) { + schemaRejectionState.endLease(schemaLeaseGeneration, publishedSchemaFsn()); + } + } + + public void beginSchemaLease(long generation) { + if (schemaMismatchPolicy != SenderError.Policy.REJECT_AND_CONTINUE) { + return; + } + if (schemaRejectionState == null) { + schemaRejectionState = new SchemaRejectionState(); + } + schemaLeaseGeneration = generation; + observedSchemaFailure = null; + schemaRejectionState.beginLease(generation, publishedSchemaFsn() + 1, transactional); + schemaLeaseStarted = true; + } + + public LineSenderServerException endSchemaLease() { + if (schemaLeaseStarted) { + LineSenderServerException failure = schemaRejectionState.endLease( + schemaLeaseGeneration, publishedSchemaFsn()); + return failure == observedSchemaFailure ? null : failure; + } + return null; + } + + public boolean hasOwnedSchemaFailure() { + return schemaLeaseStarted && schemaRejectionState.hasOwnedFailure(schemaLeaseGeneration); + } + + /** Discards only this failed producer's local work; the queue remains usable. */ + public LineSenderServerException releaseFailedSchemaLease() { + LineSenderServerException failure = schemaRejectionState.ownedFailure( + schemaLeaseGeneration, publishedSchemaFsn()); + resetTableBuffersAfterFlush(); + if (activeBuffer != null) { + activeBuffer.reset(); + } + hasDeferredMessages = false; + endSchemaLease(); + return failure == observedSchemaFailure ? null : failure; + } + + /** Pool return must not hide a storage or transport failure behind a lease-local rejection. */ + public void checkSchemaSlotHealth() { + LineSenderException failure = connectionError.get(); + if (failure != null) { + throw failure; + } + if (cursorEngine != null) { + cursorEngine.checkDurability(); + } + if (cursorSendLoop != null) { + cursorSendLoop.checkError(); + } + } + + private long publishedSchemaFsn() { + return cursorEngine == null ? -1L : cursorEngine.publishedFsn(); + } + + private void checkSchemaFailure() { + if (hasOwnedSchemaFailure()) { + LineSenderServerException failure = schemaRejectionState.ownedFailure( + schemaLeaseGeneration, publishedSchemaFsn()); + if (failure != null) { + observedSchemaFailure = failure; + throw failure; + } + } + } + + /** Configure before connecting so recovered data uses the selected policy. */ + public void configureSchemaMismatch(SenderError.Policy policy, boolean preserve, String directory) { + if (policy != SenderError.Policy.TERMINAL && policy != SenderError.Policy.REJECT_AND_CONTINUE) { + throw new IllegalArgumentException("schema mismatch policy must be TERMINAL or REJECT_AND_CONTINUE"); + } + this.schemaMismatchPolicy = policy; + this.dlqEnabled = preserve; + this.dlqDir = directory; + if (policy == SenderError.Policy.REJECT_AND_CONTINUE && preserve + && cursorEngine != null && (cursorEngine.sfDir() != null || directory != null)) { + String source = cursorEngine.sfDir(); + String slotId = source == null ? "memory" : java.nio.file.Paths.get(source).getFileName().toString(); + String destination = directory == null ? source : java.nio.file.Paths.get(directory, slotId).toString(); + String archiveNamespace = RejectedMiniSlotArchive.namespaceForSource(source); + try { + java.nio.file.Files.createDirectories(java.nio.file.Paths.get(destination)); + } catch (java.io.IOException e) { + throw new LineSenderException(e).put("could not create schema preservation destination ").put(destination); + } + RejectedMiniSlotArchive.probeDestination(io.questdb.client.std.FilesFacade.INSTANCE, destination); + schemaPreserver = new RejectedMiniSlotArchive(io.questdb.client.std.FilesFacade.INSTANCE, + destination, archiveNamespace); + } + } + public void setTransactional(boolean transactional) { this.transactional = transactional; } @@ -2881,6 +3083,7 @@ public synchronized void startOrphanDrainers( poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis); ref[0] = drainer; + drainer.configureSchemaMismatch(schemaMismatchPolicy, dlqEnabled, dlqDir, errorHandler); drainerPool.submit(drainer); } } @@ -3647,6 +3850,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx, CursorWebSocketSendLo } private void checkConnectionError() { + checkSchemaFailure(); LineSenderException error = connectionError.get(); if (error != null) { // Refresh the stack so subsequent public API calls point at the @@ -4078,6 +4282,12 @@ private void ensureConnected() { if (errorDispatcher == null) { errorDispatcher = new SenderErrorDispatcher(errorHandler, errorInboxCapacity); } + if (!schemaLeaseStarted) { + beginSchemaLease(0L); + } + cursorSendLoop.setSchemaRejectionState(schemaRejectionState); + cursorSendLoop.setSchemaMismatchPolicy(schemaMismatchPolicy); + cursorSendLoop.setRejectionArchive(schemaPreserver); cursorSendLoop.setErrorDispatcher(errorDispatcher); // Symmetric progress dispatcher: lazy-allocated mirror of the // error path. Wired before start() for the same reason -- the @@ -4104,12 +4314,26 @@ private void ensureConnected() { // frees the mirror via its loopNeverRan path; it also closes the shared // client, so the client.close() below is a safe idempotent no-op. if (cursorSendLoop != null) { - cursorSendLoop.close(); - cursorSendLoop = null; + try { + cursorSendLoop.close(); + cursorSendLoop = null; + } catch (Throwable closeFailure) { + if (closeFailure != t) t.addSuppressed(closeFailure); + } } if (client != null) { - client.close(); - client = null; + try { + client.close(); + client = null; + } catch (Throwable closeFailure) { + if (closeFailure != t) t.addSuppressed(closeFailure); + } + } + if (t instanceof UnreplayableSlotException) { + // Preserve typed failures from live-queue recovery. + // Sender.build() must receive this type to quarantine the slot; + // wrapping it would make every build retry fail on the same bytes. + throw (UnreplayableSlotException) t; } Endpoint ep = currentEndpoint(); LineSenderException ex = new LineSenderException(t); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java index facd872b..f4fd8b68 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java @@ -229,6 +229,10 @@ public final class BackgroundDrainer implements Runnable { // LOG -- a NOP for apps without an slf4j binding -- which is exactly the // silence this sink exists to break. private volatile SenderErrorHandler errorSink; + private volatile SenderErrorHandler schemaErrorSink; + private SenderError.Policy schemaMismatchPolicy = SenderError.Policy.TERMINAL; + private boolean schemaPreservationEnabled; + private String schemaPreservationDirectory; private volatile String lastErrorMessage; /** * Optional observer for durable-ack-unavailable transients and the @@ -930,6 +934,8 @@ public void run() { // per wire session. Closed by the finally, after loop.close(), so errors // dispatched during the loop's shutdown still reach the sink. SenderErrorDispatcher loopErrorDispatcher = null; + RejectedMiniSlotArchive schemaPreserver = null; + SchemaRejectionState schemaRejectionState = null; try { // Scanner results are only snapshots. Serialize adoption against // a producer's close -> quarantine rename -> fresh-slot recreate @@ -1051,37 +1057,35 @@ public void run() { return; } engineForTesting = engine; + if (schemaMismatchPolicy == SenderError.Policy.REJECT_AND_CONTINUE) { + schemaRejectionState = new SchemaRejectionState(); + if (schemaPreservationEnabled) { + String slotId = java.nio.file.Paths.get(slotPath).getFileName().toString(); + String destination = schemaPreservationDirectory == null + ? slotPath + : java.nio.file.Paths.get(schemaPreservationDirectory, slotId).toString(); + try { + java.nio.file.Files.createDirectories(java.nio.file.Paths.get(destination)); + } catch (java.io.IOException e) { + throw new SfOperationalException( + "could not create schema preservation destination " + destination, e); + } + RejectedMiniSlotArchive.probeDestination( + io.questdb.client.std.FilesFacade.INSTANCE, destination); + schemaPreserver = new RejectedMiniSlotArchive( + io.questdb.client.std.FilesFacade.INSTANCE, destination, + RejectedMiniSlotArchive.namespaceForSource(slotPath)); + } + } if (logicalSlotLock != null) { logicalSlotLock.close(); logicalSlotLock = null; } - // A recovered deferred-only tail is an aborted transaction and can - // be retired locally once everything below it is already ACKed. - // Do this before opening a socket: auth/upgrade failures must not - // quarantine a slot that has no wire-visible work left. - engine.retireRecoveredOrphanTailIfReady(); - long target = engine.publishedFsn(); - if (engine.ackedFsn() >= target) { - LOG.info("orphan slot already drained: {} (acked={} target={})", - slotPath, engine.ackedFsn(), target); - outcome = DrainOutcome.SUCCESS; - return; - } - // Seed the progress watermark from what a previous run already durably acked, so only acks - // THIS drain earns count as progress. Seeding from the -1 field default would make the first - // poll of a partially-drained slot read as progress and hand back a budget the initial connect - // had legitimately spent. - ackProgressWatermark = engine.ackedFsn(); - client = connectWithDurableAckRetry(); - if (client == null) { - // outcome already set (FAILED or STOPPED); markFailed sentinel - // already dropped on the FAILED path. - return; - } // Read the sink once: like `listener` it is volatile because the pool // applies it at submit time and it is consumed on the drainer thread. SenderErrorHandler sink = errorSink; - if (sink != null) { + SenderErrorHandler schemaSink = schemaErrorSink; + if (sink != null || schemaSink != null) { // The I/O thread must never run the sink inline -- it is caller-supplied // code and may block -- so it reaches the sink through the same bounded, // drop-oldest, off-thread arm the foreground sender uses. @@ -1096,7 +1100,15 @@ public void run() { // (RETRIABLE / RETRIABLE_OTHER) has no such owner and is forwarded verbatim. loopErrorDispatcher = new SenderErrorDispatcher( err -> { - if (err.getAppliedPolicy() != SenderError.Policy.TERMINAL) { + if (err.getAppliedPolicy() == SenderError.Policy.REJECT_AND_CONTINUE) { + if (schemaSink != null) { + // A preserved schema report carries orphan-slot-local FSNs and the + // ready archive path. Keep both intact: this dispatcher is already + // the asynchronous delivery boundary, so another bounded hop could + // drop the report after retirement has made replay impossible. + schemaSink.onError(err); + } + } else if (err.getAppliedPolicy() != SenderError.Policy.TERMINAL && sink != null) { // This sink belongs to the live sender, while err's FSNs belong to the orphan // engine being drained. Strip that foreign correlation span before forwarding; // otherwise an operator can join it to unrelated live rows with the same FSNs. @@ -1115,6 +1127,41 @@ public void run() { SenderErrorDispatcher.DEFAULT_CAPACITY, "qdb-sf-drainer-error-dispatcher"); } + // A recovered deferred-only tail is an aborted transaction and can + // be retired locally once everything below it is already ACKed. + // Do this before opening a socket: auth/upgrade failures must not + // quarantine a slot that has no wire-visible work left. + if (schemaPreserver != null && engine.recoveredOrphanTipFsn() >= 0 + && engine.ackedFsn() >= engine.recoveredCommitBoundaryFsn()) { + SenderError recovered = schemaPreserver.findRecoveredOrphanReport( + engine, engine.recoveredCommitBoundaryFsn() + 1L, engine.recoveredOrphanTipFsn()); + if (recovered != null && (loopErrorDispatcher == null + || !loopErrorDispatcher.tryOfferSchema(recovered))) { + lastErrorMessage = "could not retain recovered schema report before orphan retirement"; + LOG.warn("drainer slot {}: {}", slotPath, lastErrorMessage); + outcome = DrainOutcome.FAILED; + return; + } + } + engine.retireRecoveredOrphanTailIfReady(); + long target = engine.publishedFsn(); + if (engine.ackedFsn() >= target) { + LOG.info("orphan slot already drained: {} (acked={} target={})", + slotPath, engine.ackedFsn(), target); + outcome = DrainOutcome.SUCCESS; + return; + } + // Seed the progress watermark from what a previous run already durably acked, so only acks + // THIS drain earns count as progress. Seeding from the -1 field default would make the first + // poll of a partially-drained slot read as progress and hand back a budget the initial connect + // had legitimately spent. + ackProgressWatermark = engine.ackedFsn(); + client = connectWithDurableAckRetry(); + if (client == null) { + // outcome already set (FAILED or STOPPED); markFailed sentinel + // already dropped on the FAILED path. + return; + } // One iteration per wire session. Re-entered on either of the two // RECOVERABLE mid-drain terminals the recycle branch below tests // for -- a durable-ack CAPABILITY gap, or a 401/403 against a @@ -1152,6 +1199,9 @@ public void run() { // problem once SF fills. Null when no sink is installed, which // setErrorDispatcher accepts and dispatchError treats as before. loop.setErrorDispatcher(loopErrorDispatcher); + loop.setSchemaRejectionState(schemaRejectionState); + loop.setSchemaMismatchPolicy(schemaMismatchPolicy); + loop.setRejectionArchive(schemaPreserver); loop.start(); while (!stopRequestedOrInterrupted()) { @@ -1278,6 +1328,10 @@ public void run() { lastErrorMessage = t.getMessage(); outcome = DrainOutcome.FAILED; throw t; + } catch (SfOperationalException t) { + lastErrorMessage = t.getMessage(); + LOG.error("drainer storage temporarily unavailable for slot {}: {}", slotPath, lastErrorMessage, t); + outcome = DrainOutcome.FAILED; } catch (Throwable t) { String msg = t.getMessage(); if (slotPath != null) { @@ -1383,14 +1437,13 @@ public void run() { if (engine != null) { // Failed-stop hand-off: delegateEngineClose() makes the I/O // thread run engine.close() strictly after its last engine - // access, releasing the slot lock as soon as the stuck wire - // call resolves — deferred teardown, never abandoned. The + // access, releasing the slot lock once blocked network or + // preservation I/O completes — deferred teardown, never abandoned. The // false return covers the race where the thread exited // between the failed close() and now: then it is safe (and // necessary) to close the engine here. - if (ioThreadStopped || !loop.delegateEngineClose()) { + if (ioThreadStopped || loop == null || !loop.delegateEngineClose()) { try { - // engine.close() releases the slot lock too. engine.close(); } catch (Throwable ignored) { } @@ -1416,6 +1469,26 @@ public void setErrorSink(SenderErrorHandler errorSink) { this.errorSink = errorSink; } + /** Configures schema rejection before this drainer is submitted. */ + public void configureSchemaMismatch( + SenderError.Policy policy, + boolean preserve, + String directory, + SenderErrorHandler effectiveHandler + ) { + if (policy != SenderError.Policy.TERMINAL + && policy != SenderError.Policy.REJECT_AND_CONTINUE) { + throw new IllegalArgumentException( + "schema mismatch policy must be TERMINAL or REJECT_AND_CONTINUE"); + } + this.schemaMismatchPolicy = policy; + this.schemaPreservationEnabled = preserve; + this.schemaPreservationDirectory = directory; + this.schemaErrorSink = effectiveHandler != null + ? effectiveHandler + : DefaultSenderErrorHandler.INSTANCE; + } + /** * Plug an observer for durable-ack-related events. {@code null} clears * any previously installed listener. See {@link BackgroundDrainerListener} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java index 66a0635e..a82e2c9e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java @@ -887,6 +887,30 @@ public boolean acknowledge(long seq) { return ring.acknowledge(seq); } + /** + * Reads the QWP header flags for a currently live frame. Returns {@code -1} + * when the FSN is outside the live ring or the payload is not a valid QWP + * message. The ring monitor protects the mapped bytes from trim/unmap for + * the duration of this bounded header read. + */ + public int liveQwpFrameFlags(long fsn) { + return ring.liveQwpFrameFlags(fsn); + } + + /** Returns the payload length for a currently live frame, or {@code -1}. */ + public int liveFramePayloadLength(long fsn) { + return ring.liveFramePayloadLength(fsn); + } + + /** + * Copies one currently live frame payload into caller-owned native memory. + * The copy runs under the ring monitor, so trim cannot hide or unmap the + * segment midway through it and the I/O cursor's single pin is untouched. + */ + public boolean copyLiveFrame(long fsn, long dstAddr, int dstCapacity) { + return ring.copyLiveFrame(fsn, dstAddr, dstCapacity); + } + /** * I/O thread accessor: the current active mmap'd segment. */ diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java index 6643bf03..25f09742 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java @@ -236,6 +236,8 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { * Throttle "reconnect attempt N failed" WARN logs to one per 5 s. */ private static final long RECONNECT_LOG_THROTTLE_NANOS = 5_000_000_000L; + private static final long SCHEMA_PRESERVE_RETRY_INITIAL_NANOS = 100_000_000L; + private static final long SCHEMA_PRESERVE_RETRY_MAX_NANOS = 5_000_000_000L; // Test seam: when true, recovery mirror seeding throws immediately AFTER // ensureSentDictCapacity has grown (and therefore taken ownership of) the mirror, // standing in for the copyRecoveredSymbolSuffix-adjacent failure that leaves a @@ -317,6 +319,18 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { // by category. Includes both retriable and terminal outcomes — i.e. every // server-side rejection observed regardless of how the loop reacted. private final AtomicLong totalServerErrors = new AtomicLong(); + private final AtomicLong schemaFramesRetired = new AtomicLong(); + private final AtomicLong schemaRejections = new AtomicLong(); + private final AtomicLong dlqFilesWritten = new AtomicLong(); + private final AtomicLong dlqBytesWritten = new AtomicLong(); + private final AtomicLong dlqWriteFailures = new AtomicLong(); + private volatile SenderError.Policy schemaMismatchPolicy = SenderError.Policy.TERMINAL; + private volatile SchemaRejectionState schemaRejectionState; + private volatile RejectedMiniSlotArchive schemaPreserver; + private SenderError preservedSchemaNotification; + private long preparedSchemaFirstFsn = -1L; + private long preparedSchemaLastFsn = -1L; + private int schemaPreserveFailures; // Delta symbol dictionary catch-up state (see swapClient). // ALWAYS active -- in memory mode, in disk mode, and (critically) even when the // per-slot persisted dictionary failed to open. sentDictCount is this loop's model @@ -348,6 +362,10 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { // freed. private boolean sentDictBytesOwned; private int sentDictCount; + // Cold-path scratch used only when a locally retired range carried symbol + // deltas that later frames still reference. Reused across retirements. + private long skippedFrameScratchAddr; + private int skippedFrameScratchCapacity; // True when replay frames can start above dictionary id zero and therefore // depend on a catch-up on a fresh connection. Delta-enabled live engines // always have this dependency. A recovered delta slot whose dictionary @@ -508,6 +526,8 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { // and by the I/O thread afterwards -- never concurrently. private long orphanSkipStartFsn = -1L; private long orphanSkipTipFsn = -1L; + private boolean recoveredOrphanReportLookedUp; + private SenderError recoveredOrphanReport; // Poison-frame detector state (I/O thread only). poisonFsn is the FSN of the // frame implicated by the most recent server-active rejection: the NACK-named // frame, or the OK-level head-of-line frame (highestOkFsn+1) for a @@ -557,6 +577,9 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { // advance neither), so replay cannot launder the counter. Pacing only -- // this counter NEVER escalates to a terminal (Invariant B). private int zeroProgressRecycles; + // Schema retirement is local progress, not acceptance. Keep a separate + // reconnect dose until a real server ACK arrives. + private int schemaRecyclesWithoutAck; private long progressAtLastExemptRecycle = Long.MIN_VALUE; // Poison-frame detector threshold for this loop. Constructor-configured // (connect-string key max_frame_rejections); defaults to @@ -1402,6 +1425,7 @@ public synchronized void close() { releaseSentDictBytes(); } if (loopNeverRan) { + releaseSkippedFrameScratch(); freeCatchUpFrameBuffer(); } } @@ -1559,6 +1583,44 @@ public void setErrorDispatcher(SenderErrorDispatcher dispatcher) { this.errorDispatcher = dispatcher; } + public void setSchemaRejectionState(SchemaRejectionState state) { + if (state != null) { + state.setEngine(engine); + } + this.schemaRejectionState = state; + } + + public void setSchemaMismatchPolicy(SenderError.Policy policy) { + if (policy != SenderError.Policy.TERMINAL && policy != SenderError.Policy.REJECT_AND_CONTINUE) { + throw new IllegalArgumentException("schema mismatch policy must be TERMINAL or REJECT_AND_CONTINUE"); + } + this.schemaMismatchPolicy = policy; + } + + public void setRejectionArchive(RejectedMiniSlotArchive preserver) { + this.schemaPreserver = preserver; + } + + public long getDlqWriteFailures() { + return dlqWriteFailures.get(); + } + + public long getDlqFilesWritten() { + return dlqFilesWritten.get(); + } + + public long getDlqBytesWritten() { + return dlqBytesWritten.get(); + } + + public long getSchemaFramesRetired() { + return schemaFramesRetired.get(); + } + + public long getSchemaRejections() { + return schemaRejections.get(); + } + /** * Plug an async-delivery sink for ack-watermark advances. Same lifecycle * contract as {@link #setErrorDispatcher} — set once before @@ -2228,7 +2290,7 @@ private void drainPendingDurable() { releasePendingEntry(pendingDurable.pollFirst()); } if (highest != Long.MIN_VALUE) { - long fsn = fsnAtZero + highest; + long fsn = clampAckBeforeSchemaStop(fsnAtZero + highest); if (engine.acknowledge(fsn)) { totalDurableTrimAdvances.incrementAndGet(); dispatchProgress(fsn); @@ -2236,6 +2298,15 @@ private void drainPendingDurable() { } } + private long clampAckBeforeSchemaStop(long fsn) { + SchemaRejectionState state = schemaRejectionState; + if (state == null) { + return fsn; + } + long stop = state.stopFsn(); + return stop >= 0 && fsn >= stop ? stop - 1L : fsn; + } + /** * Stash a wireSeq + per-table seqTxns from the current OK frame for * later durable-ack confirmation. {@link #response} must hold the OK @@ -2306,6 +2377,18 @@ private void failPaced(Throwable initial) { connectLoop(initial, "reconnect", dose); } + private void failSchemaPaced(Throwable initial) { + int level = schemaRecyclesWithoutAck++; + long dose = reconnectInitialBackoffMillis; + if (dose > 0) { + dose <<= Math.min(level, 6); + if (reconnectMaxBackoffMillis > 0 && dose > reconnectMaxBackoffMillis) { + dose = reconnectMaxBackoffMillis; + } + } + connectLoop(initial, "reconnect", dose); + } + /** * Recycle path for strike-exempt wire events: orderly closes * (NORMAL_CLOSURE / GOING_AWAY), non-orderly closes before any send on @@ -2457,6 +2540,7 @@ private void ioLoop() { if (sentDictBytesAddr != 0) { releaseSentDictBytes(); } + releaseSkippedFrameScratch(); freeCatchUpFrameBuffer(); shutdownLatch.countDown(); Runnable closeCallback = delegatedClose; @@ -2915,6 +2999,69 @@ private void releaseSentDictBytes() { sentDictCount = 0; } + /** + * Extends the reconnect dictionary mirror with deltas carried only by a + * range that is about to be skipped locally. Call before acknowledging the + * range: once trim hides it, successor frames may be impossible to replay. + * This is a rejection cold path and performs no work during normal sends. + */ + void catchUpSkippedRange(long firstFsn, long lastFsn) { + if (firstFsn < 0 || lastFsn < firstFsn) { + throw new IllegalArgumentException("invalid skipped range [first=" + + firstFsn + ", last=" + lastFsn + ']'); + } + for (long fsn = firstFsn; fsn <= lastFsn; fsn++) { + int payloadLen = engine.liveFramePayloadLength(fsn); + if (payloadLen < 0) { + throw new LineSenderException("store-and-forward frame disappeared before retirement [fsn=" + + fsn + ']'); + } + ensureSkippedFrameScratch(payloadLen); + if (!engine.copyLiveFrame(fsn, skippedFrameScratchAddr, skippedFrameScratchCapacity)) { + throw new LineSenderException("store-and-forward frame disappeared before retirement [fsn=" + + fsn + ']'); + } + int deltaStart = frameDeltaStart(skippedFrameScratchAddr, payloadLen); + if (deltaStart > sentDictCount) { + throw new LineSenderException("skipped store-and-forward frame has a symbol dictionary gap [fsn=" + + fsn + ", deltaStart=" + deltaStart + ", dictionarySize=" + sentDictCount + ']'); + } + if (deltaStart >= 0) { + accumulateSentDict(skippedFrameScratchAddr, payloadLen, deltaStart); + } + if (fsn == Long.MAX_VALUE) { + break; + } + } + } + + @TestOnly + public void catchUpSkippedRangeForTest(long firstFsn, long lastFsn) { + catchUpSkippedRange(firstFsn, lastFsn); + } + + private void ensureSkippedFrameScratch(int required) { + if (required <= skippedFrameScratchCapacity) { + return; + } + skippedFrameScratchAddr = skippedFrameScratchAddr == 0 + ? Unsafe.malloc(required, MemoryTag.NATIVE_DEFAULT) + : Unsafe.realloc( + skippedFrameScratchAddr, + skippedFrameScratchCapacity, + required, + MemoryTag.NATIVE_DEFAULT); + skippedFrameScratchCapacity = required; + } + + private void releaseSkippedFrameScratch() { + if (skippedFrameScratchAddr != 0) { + Unsafe.free(skippedFrameScratchAddr, skippedFrameScratchCapacity, MemoryTag.NATIVE_DEFAULT); + } + skippedFrameScratchAddr = 0; + skippedFrameScratchCapacity = 0; + } + /** * Decodes the varint at {@code [p, limit)} and returns {@code (value << 3) | bytes}, * or {@code -1} when it is truncated or runs past a canonical length. @@ -3269,6 +3416,20 @@ public int sentDictCount() { return sentDictCount; } + /** I/O-thread cold-path snapshot used by preserved rejection copies. */ + public byte[] snapshotSentDictionary() { + byte[] snapshot = new byte[sentDictBytesLen]; + if (sentDictBytesLen > 0) { + Unsafe.getUnsafe().copyMemory( + null, sentDictBytesAddr, snapshot, Unsafe.BYTE_OFFSET, sentDictBytesLen); + } + return snapshot; + } + + public int sentDictionaryCount() { + return sentDictCount; + } + @TestOnly public int zeroProgressRecycles() { return zeroProgressRecycles; @@ -3353,6 +3514,11 @@ public boolean trySendOneForTest() { return trySendOne(); } + @TestOnly + public boolean tryRetireSchemaRangeForTest() { + return tryRetireSchemaRange(); + } + private void ensureCatchUpFrameCapacity(int required) { if (catchUpFrameCapacity >= required) { return; @@ -3396,6 +3562,32 @@ private boolean tryReceiveAcks() { * scheduling fairness. */ private boolean trySendOne() { + SchemaRejectionState rejectionState = schemaRejectionState; + if (rejectionState != null) { + long stopFsn = rejectionState.stopFsn(); + if (stopFsn >= 0 && fsnAtZero + nextWireSeq >= stopFsn) { + if (!tryRetireSchemaRange()) { + return false; + } + if (nextWireSeq > 0) { + fail(new LineSenderException( + "recycling connection after retiring schema-rejected range")); + return false; + } + try { + positionCursorForStart(); + } catch (CatchUpSendException e) { + // Match the recovered-orphan re-anchor path below. The + // retired range changed the FSN/wire-sequence mapping, so + // a failed dictionary catch-up must recycle through the + // normal catch-up policy instead of escaping ioLoop as an + // unrelated generic reconnect failure. + fail(isCatchUpCapGap(e) ? e : e.getCause()); + return false; + } + return true; + } + } if (orphanSkipTipFsn >= 0 && fsnAtZero + nextWireSeq >= orphanSkipStartFsn) { // The send cursor reached the orphaned deferred tail. Its frames // belong to an aborted transaction and must never be transmitted @@ -3592,14 +3784,130 @@ private boolean tryRetireOrphanTail() { if (orphanSkipTipFsn < 0) { return true; } + if (engine.ackedFsn() < orphanSkipStartFsn - 1L) { + return false; + } + RejectedMiniSlotArchive archive = schemaPreserver; + if (archive != null) { + if (!recoveredOrphanReportLookedUp) { + recoveredOrphanReport = archive.findRecoveredOrphanReport( + engine, orphanSkipStartFsn, orphanSkipTipFsn); + recoveredOrphanReportLookedUp = true; + } + if (recoveredOrphanReport != null) { + SenderErrorDispatcher dispatcher = errorDispatcher; + if (dispatcher == null || !dispatcher.tryOfferSchema(recoveredOrphanReport)) { + return false; + } + } + } if (!engine.retireRecoveredOrphanTailIfReady()) { return false; } orphanSkipStartFsn = -1L; orphanSkipTipFsn = -1L; + recoveredOrphanReport = null; + return true; + } + + private boolean tryRetireSchemaRange() { + SchemaRejectionState state = schemaRejectionState; + if (state == null) { + return true; + } + SchemaRejectionState.Range range = state.sealedRange(); + if (range == null || engine.ackedFsn() < range.firstFsn - 1L) { + return false; + } + SenderErrorDispatcher dispatcher = errorDispatcher; + if (dispatcher == null) { + return false; + } + SenderError notification = preservedSchemaNotification != null + ? preservedSchemaNotification + : range.error; + RejectedMiniSlotArchive preserver = schemaPreserver; + try { + prepareSkippedRange(range); + } catch (LineSenderException e) { + LOG.error("could not retain dictionary coverage for schema-rejected range [{}, {}]; " + + "keeping queued bytes and stopping the sender", + range.firstFsn, range.lastFsn, e); + recordFatal(e); + dispatchError(range.error.withAppliedPolicy(SenderError.Policy.TERMINAL)); + return false; + } + if (preserver != null && preservedSchemaNotification == null) { + byte[] dictionary = snapshotSentDictionary(); + final RejectedMiniSlotArchive.Result result; + try { + result = preserver.preserve(engine, range.error, + dictionary.length == 0 ? null : dictionary, sentDictCount); + } catch (LineSenderException | IllegalArgumentException | ArithmeticException e) { + LineSenderException fatal = e instanceof LineSenderException + ? (LineSenderException) e + : new LineSenderException("invalid schema-rejected preservation range", e); + LOG.error("could not preserve schema-rejected store-and-forward range [{}, {}]; " + + "keeping queued bytes and stopping the sender", + range.firstFsn, range.lastFsn, e); + recordFatal(fatal); + dispatchError(range.error.withAppliedPolicy(SenderError.Policy.TERMINAL)); + return false; + } catch (RuntimeException e) { + long failures = dlqWriteFailures.incrementAndGet(); + schemaPreserveFailures++; + long delay = SCHEMA_PRESERVE_RETRY_INITIAL_NANOS + << Math.min(schemaPreserveFailures - 1, 6); + LOG.warn("could not preserve schema-rejected store-and-forward range [{}, {}]; " + + "keeping source bytes stopped and retrying (failure {})", + range.firstFsn, range.lastFsn, failures, e); + parkWhileRunning(Math.min(delay, SCHEMA_PRESERVE_RETRY_MAX_NANOS)); + return false; + } + if (!result.reused) { + dlqFilesWritten.incrementAndGet(); + dlqBytesWritten.addAndGet(result.bytesWritten); + } + notification = range.error.withRejectedPath(result.path); + preservedSchemaNotification = notification; + schemaPreserveFailures = 0; + if (!running) { + // close() may have stopped the loop while the synchronous copy + // was blocked in storage. Keep the source range mapped and let + // the existing delegated I/O-thread cleanup release the engine. + return false; + } + } + if (!dispatcher.tryOfferSchema(notification)) { + return false; + } + engine.acknowledge(range.lastFsn); + dispatchProgress(range.lastFsn); + schemaFramesRetired.addAndGet(range.lastFsn - range.firstFsn + 1L); + preservedSchemaNotification = null; + preparedSchemaFirstFsn = -1L; + preparedSchemaLastFsn = -1L; + state.completeRetirement(range.lastFsn); return true; } + private void prepareSkippedRange(SchemaRejectionState.Range range) { + if (preparedSchemaFirstFsn == range.firstFsn && preparedSchemaLastFsn == range.lastFsn) { + return; + } + catchUpSkippedRange(range.firstFsn, range.lastFsn); + preparedSchemaFirstFsn = range.firstFsn; + preparedSchemaLastFsn = range.lastFsn; + } + + private void parkWhileRunning(long nanos) { + long deadline = System.nanoTime() + nanos; + long remaining; + while (running && (remaining = deadline - System.nanoTime()) > 0L) { + LockSupport.parkNanos(remaining); + } + } + /** * Determines whether an oversized symbol-dictionary catch-up entry is always * retriable or may become terminal after the orphan settle budget. @@ -3881,6 +4189,7 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) { wireSeq, highestSent); } totalAcks.incrementAndGet(); + schemaRecyclesWithoutAck = 0; long okFsn = fsnAtZero + capped; if (okFsn > highestOkFsn) { highestOkFsn = okFsn; @@ -3913,8 +4222,9 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) { drainPendingDurable(); return; } - if (engine.acknowledge(fsnAtZero + capped)) { - dispatchProgress(fsnAtZero + capped); + long ackFsn = clampAckBeforeSchemaStop(fsnAtZero + capped); + if (engine.acknowledge(ackFsn)) { + dispatchProgress(ackFsn); } return; } @@ -4009,6 +4319,12 @@ private void handlePreSendRejection(long wireSeq, byte status, String tableName = response.getTableEntryCount() == 1 ? response.getTableName(0) : null; + // REJECT_AND_CONTINUE is legal only for an exact data frame sent + // on this connection. A pre-send NACK has no retirement target; + // fail closed while preserving every queued byte. + if (policy == SenderError.Policy.REJECT_AND_CONTINUE) { + policy = SenderError.Policy.TERMINAL; + } SenderError err = new SenderError( category, policy, @@ -4059,7 +4375,9 @@ private void handlePreSendRejection(long wireSeq, byte status, private void handleServerRejection(long wireSeq) { byte status = response.getStatus(); SenderError.Category category = classify(status); - SenderError.Policy policy = defaultPolicyFor(category); + SenderError.Policy policy = category == SenderError.Category.SCHEMA_MISMATCH + ? schemaMismatchPolicy + : defaultPolicyFor(category); // Same sanity clamp as the success branch above: do not trust a // rejection wireSeq beyond what we've actually sent. The clamped // value is only used to attribute an FSN to the error report -- @@ -4135,6 +4453,74 @@ private void handleServerRejection(long wireSeq) { ); totalServerErrors.incrementAndGet(); + if (policy == SenderError.Policy.REJECT_AND_CONTINUE) { + // Retirement requires an exact data sequence sent on this + // connection. Never feed the reporting clamp into data loss. + if (wireSeq < 0 || wireSeq > highestSent || fsn <= engine.ackedFsn()) { + SenderError terminal = new SenderError( + category, SenderError.Policy.TERMINAL, status & 0xff, + response.getErrorMessage(), wireSeq, fsn, fsn, + tableName, System.nanoTime()); + recordFatal(new LineSenderServerException(terminal)); + dispatchError(terminal); + return; + } + SchemaRejectionState state = schemaRejectionState; + if (state == null) { + SenderError terminal = err.withAppliedPolicy(SenderError.Policy.TERMINAL); + recordFatal(new LineSenderServerException(terminal)); + dispatchError(terminal); + return; + } + long floor = engine.ackedFsn() + 1L; + long first = floor; + // Walk forward once so each segment's cold lookup cache can advance + // linearly, even for a rejected prefix containing many small frames. + for (long predecessor = floor; predecessor < fsn; predecessor++) { + int flags = engine.liveQwpFrameFlags(predecessor); + if (flags < 0) { + SenderError terminal = err.withAppliedPolicy(SenderError.Policy.TERMINAL); + recordFatal(new LineSenderServerException(terminal)); + dispatchError(terminal); + return; + } + if ((flags & QwpConstants.FLAG_DEFER_COMMIT) == 0) { + first = predecessor + 1L; + } + } + final boolean installed; + try { + installed = state.reject(fsn, first, err); + } catch (IllegalStateException e) { + LOG.error("could not resolve schema-rejected store-and-forward range at fsn {}; " + + "keeping queued bytes and stopping the sender", fsn, e); + recordFatal(new LineSenderException( + "could not resolve schema-rejected store-and-forward range at fsn " + fsn, e)); + SenderError terminal = new SenderError( + category, SenderError.Policy.TERMINAL, status & 0xff, + response.getErrorMessage(), wireSeq, fsn, fsn, + tableName, System.nanoTime()); + dispatchError(terminal); + return; + } + if (!installed) { + LOG.error("received a schema rejection at fsn {} while another schema-rejected " + + "range is pending retirement; keeping queued bytes and stopping the sender", + fsn); + SenderError terminal = new SenderError( + category, SenderError.Policy.TERMINAL, status & 0xff, + response.getErrorMessage(), wireSeq, fsn, fsn, + tableName, System.nanoTime()); + recordFatal(new LineSenderServerException(terminal)); + dispatchError(terminal); + return; + } + schemaRejections.incrementAndGet(); + failSchemaPaced(new LineSenderException( + "recycling connection after schema rejection at fsn " + fsn)); + return; + } + if (policy == SenderError.Policy.TERMINAL) { // Terminal: stash the typed payload BEFORE dispatching to the // handler. The spec requires signal.terminalError to be latched diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/DefaultSenderErrorHandler.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/DefaultSenderErrorHandler.java index 47d15754..5d6b2f23 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/DefaultSenderErrorHandler.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/DefaultSenderErrorHandler.java @@ -59,7 +59,7 @@ public void onError(SenderError e) { // Single template; SLF4J fans out the levels so the call site stays // identical and the message format is reviewable in one place. String fmt = "server rejected batch [category={}, policy={}, status=0x{}, " - + "fsn=[{},{}], table={}, seq={}, msg={}]"; + + "fsn=[{},{}], table={}, seq={}, msg={}, preserved={}]"; Object[] args = new Object[]{ e.getCategory(), e.getAppliedPolicy(), @@ -68,10 +68,12 @@ public void onError(SenderError e) { e.getToFsn(), e.getTableName() == null ? "(multi)" : e.getTableName(), e.getMessageSequence(), - e.getServerMessage() + e.getServerMessage(), + e.getRejectedPath() }; if (e.getAppliedPolicy() == SenderError.Policy.TERMINAL - || e.getAppliedPolicy() == SenderError.Policy.ABANDONED) { + || e.getAppliedPolicy() == SenderError.Policy.ABANDONED + || e.getAppliedPolicy() == SenderError.Policy.REJECT_AND_CONTINUE) { LOG.error(fmt, args); } else { LOG.warn(fmt, args); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java index 779c9900..41c6893e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java @@ -24,6 +24,7 @@ package io.questdb.client.cutlass.qwp.client.sf.cursor; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; import io.questdb.client.std.Crc32c; import io.questdb.client.std.Files; import io.questdb.client.std.FilesFacade; @@ -35,7 +36,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.security.SecureRandom; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; /** * One mmap-backed SF segment file. The user thread (the single producer) @@ -48,7 +51,7 @@ * On-disk layout — header and frame format: *

  *   [u32 magic 'SF01'] [u8 ver=1] [u8 flags]   [u16 reserved=0]
- *   [u64 baseSeq]      [u64 createdMicros]                        24-byte header
+ *   [u64 baseSeq]      [u64 generationToken]                     24-byte header
  *   frame, frame, ...                                              each frame:
  *                                                                  [u32 crc32c]
  *                                                                  [u32 payloadLen]
@@ -75,6 +78,11 @@ public final class MmapSegment implements QuietCloseable {
     // soft downgrade (see syncPublished) and must not spam the log once per
     // barrier when RLIMIT_MEMLOCK or the platform says no.
     private static final AtomicBoolean MLOCK_REFUSAL_WARNED = new AtomicBoolean();
+    // Consecutive values cannot repeat within one JVM before 64-bit wrap. A
+    // cryptographically random starting point makes a collision with a token
+    // persisted by another process a 1-in-2^64 event for any fixed token.
+    private static final AtomicLong NEXT_GENERATION_TOKEN =
+            new AtomicLong(new SecureRandom().nextLong());
     private static final int RECOVERY_BUFFER_SIZE = 64 * 1024;
 
     private final FilesFacade filesFacade;
@@ -106,6 +114,12 @@ public final class MmapSegment implements QuietCloseable {
     // ring monitor. volatile is the cheapest correct fix.
     private volatile long frameCount;
     private long mmapAddress;
+    // Cold live-frame lookups normally walk forward by FSN (archive/rejection
+    // scans). Remember one validated frame so each lookup does not rescan the
+    // immutable published prefix from HEADER_SIZE. These fields are accessed
+    // under SegmentRing's monitor; the producer never touches them.
+    private long liveLookupIndex;
+    private long liveLookupOffset = HEADER_SIZE;
     // publishedCursor: written by producer, read by consumer (I/O thread). Volatile
     // because the consumer must see writes in publication order — once the
     // producer bumps publishedCursor, every byte before it is fully written.
@@ -241,7 +255,7 @@ static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long
             Unsafe.getUnsafe().putByte(addr + 5, manifestRequired ? MANIFEST_REQUIRED_FLAG : (byte) 0); // flags
             Unsafe.getUnsafe().putShort(addr + 6, (short) 0); // reserved
             Unsafe.getUnsafe().putLong(addr + 8, baseSeq);
-            Unsafe.getUnsafe().putLong(addr + 16, Os.currentTimeMicros());
+            Unsafe.getUnsafe().putLong(addr + 16, nextGenerationToken());
             return new MmapSegment(ff, displayPath, fd, addr, sizeBytes, baseSeq,
                     HEADER_SIZE, 0, false, 0L);
         } catch (Throwable t) {
@@ -279,7 +293,7 @@ public static MmapSegment createInMemory(long baseSeq, long sizeBytes) {
             Unsafe.getUnsafe().putByte(addr + 5, (byte) 0);
             Unsafe.getUnsafe().putShort(addr + 6, (short) 0);
             Unsafe.getUnsafe().putLong(addr + 8, baseSeq);
-            Unsafe.getUnsafe().putLong(addr + 16, Os.currentTimeMicros());
+            Unsafe.getUnsafe().putLong(addr + 16, nextGenerationToken());
             return new MmapSegment(null, null, -1, addr, sizeBytes, baseSeq,
                     HEADER_SIZE, 0, true, 0L);
         } catch (Throwable t) {
@@ -752,6 +766,81 @@ public long frameCount() {
         return frameCount;
     }
 
+    /** Immutable, opaque segment generation token stored in the segment header. */
+    public long generationToken() {
+        return Unsafe.getUnsafe().getLong(mmapAddress + 16);
+    }
+
+    private static long nextGenerationToken() {
+        return NEXT_GENERATION_TOKEN.getAndIncrement();
+    }
+
+    int liveFramePayloadLength(long fsn) {
+        long offset = liveFrameOffset(fsn);
+        return offset < 0 ? -1 : Unsafe.getUnsafe().getInt(mmapAddress + offset + 4);
+    }
+
+    boolean copyLiveFrame(long fsn, long dstAddr, int dstCapacity) {
+        long offset = liveFrameOffset(fsn);
+        if (offset < 0) {
+            return false;
+        }
+        int payloadLen = Unsafe.getUnsafe().getInt(mmapAddress + offset + 4);
+        if (payloadLen > dstCapacity) {
+            throw new IllegalArgumentException("destination is too small [required="
+                    + payloadLen + ", capacity=" + dstCapacity + ']');
+        }
+        if (payloadLen > 0) {
+            Unsafe.getUnsafe().copyMemory(mmapAddress + offset + FRAME_HEADER_SIZE, dstAddr, payloadLen);
+        }
+        return true;
+    }
+
+    int liveQwpFrameFlags(long fsn) {
+        long offset = liveFrameOffset(fsn);
+        if (offset < 0) {
+            return -1;
+        }
+        int payloadLen = Unsafe.getUnsafe().getInt(mmapAddress + offset + 4);
+        long payload = mmapAddress + offset + FRAME_HEADER_SIZE;
+        if (payloadLen < QwpConstants.HEADER_SIZE
+                || Unsafe.getUnsafe().getInt(payload) != QwpConstants.MAGIC_MESSAGE) {
+            return -1;
+        }
+        return Unsafe.getUnsafe().getByte(payload + QwpConstants.HEADER_OFFSET_FLAGS) & 0xff;
+    }
+
+    private long liveFrameOffset(long fsn) {
+        long index = fsn - baseSeq;
+        long frames = frameCount;
+        if (index < 0 || index >= frames) {
+            return -1L;
+        }
+        long published = publishedCursor;
+        long i = 0;
+        long offset = HEADER_SIZE;
+        if (index >= liveLookupIndex) {
+            i = liveLookupIndex;
+            offset = liveLookupOffset;
+        }
+        for (; i <= index; i++) {
+            if (offset + FRAME_HEADER_SIZE > published) {
+                return -1L;
+            }
+            int payloadLen = Unsafe.getUnsafe().getInt(mmapAddress + offset + 4);
+            if (payloadLen < 0 || payloadLen > published - offset - FRAME_HEADER_SIZE) {
+                return -1L;
+            }
+            if (i == index) {
+                liveLookupIndex = i;
+                liveLookupOffset = offset;
+                return offset;
+            }
+            offset += FRAME_HEADER_SIZE + payloadLen;
+        }
+        return -1L;
+    }
+
     /**
      * Bytes between the last valid frame and the file end that look like an
      * attempted-but-invalid frame write — set by {@link #openExisting} when
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/RejectedMiniSlotArchive.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/RejectedMiniSlotArchive.java
new file mode 100644
index 00000000..168fef81
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/RejectedMiniSlotArchive.java
@@ -0,0 +1,332 @@
+/*******************************************************************************
+ * Copyright (c) 2014-2026 QuestDB
+ * Licensed under the Apache License, Version 2.0.
+ ******************************************************************************/
+
+package io.questdb.client.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
+import io.questdb.client.std.FilesFacade;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Properties;
+import java.util.UUID;
+
+/** Writes immutable rejected ranges in the existing SFA replay format. */
+public final class RejectedMiniSlotArchive {
+    public static final String METADATA_FILE_NAME = "rejection.properties";
+    public static final String SEGMENT_FILE_NAME = "rejected.sfa";
+    private static final int MODE_OWNER_ONLY = 448; // 0700
+
+    private final String directory;
+    private final FilesFacade ff;
+    private final String namespace;
+    // A published directory awaiting its parent fsync. Retry only that barrier.
+    private Result pending;
+
+    public RejectedMiniSlotArchive(FilesFacade ff, String directory) {
+        this(ff, directory, namespaceForSource(directory));
+    }
+
+    public RejectedMiniSlotArchive(FilesFacade ff, String directory, String namespace) {
+        this.ff = ff;
+        this.directory = directory;
+        this.namespace = UUID.fromString(namespace).toString();
+    }
+
+    /** Stable for a disk queue path; unique for each memory-only queue. */
+    public static String namespaceForSource(String source) {
+        if (source == null) return UUID.randomUUID().toString();
+        String path = Paths.get(source).toAbsolutePath().normalize().toString();
+        return UUID.nameUUIDFromBytes(path.getBytes(StandardCharsets.UTF_8)).toString();
+    }
+
+    /** Returns a structurally valid report for exactly this live range, if one was published. */
+    public SenderError findRecoveredOrphanReport(CursorSendEngine engine, long fromFsn, long toFsn) {
+        if (fromFsn < 0 || toFsn < fromFsn) return null;
+        try {
+            PathsForRange paths = paths(engine, fromFsn, toFsn);
+            removeKnownDirectory(paths.temp);
+            return readReport(Paths.get(paths.completed), fromFsn, toFsn);
+        } catch (IOException | RuntimeException ignored) {
+            // Archive output must never prevent live-queue recovery.
+            return null;
+        }
+    }
+
+    /** I/O-thread only. Source frames remain live until this returns successfully. */
+    public Result preserve(CursorSendEngine engine, SenderError error,
+                           byte[] dictionaryEntries, int dictionaryCount) {
+        if (pending == null) pending = write(engine, error, dictionaryEntries, dictionaryCount);
+        if (ff.fsyncDir(directory + "/rejected") != 0) {
+            throw new SfOperationalException("could not sync rejection archive parent " + directory);
+        }
+        Result result = pending;
+        pending = null;
+        return result;
+    }
+
+    private Result write(CursorSendEngine engine, SenderError error,
+                         byte[] dictionaryEntries, int dictionaryCount) {
+        if ((dictionaryEntries == null) != (dictionaryCount == 0)) {
+            throw new IllegalArgumentException("dictionary snapshot bytes/count mismatch");
+        }
+        long from = error.getFromFsn();
+        long to = error.getToFsn();
+        if (from < 0 || to < from || error.getRejectedFsn() < from || error.getRejectedFsn() > to) {
+            throw new IllegalArgumentException("invalid rejection span");
+        }
+        String rejectedRoot = directory + "/rejected";
+        ensureDirectory(rejectedRoot);
+        if (ff.fsyncDir(directory) != 0) {
+            throw new SfOperationalException("could not sync archive destination " + directory);
+        }
+        PathsForRange paths = paths(engine, from, to);
+        try {
+            if (readReport(Paths.get(paths.completed), from, to) != null) {
+                return new Result(paths.completed, 0, true);
+            }
+        } catch (IOException | RuntimeException ignored) {
+            // Replace only this exact range identity while its source is still live.
+        }
+        removeKnownDirectory(paths.completed);
+        removeKnownDirectory(paths.temp);
+        if (ff.exists(paths.completed) || ff.exists(paths.temp)
+                || ff.mkdir(paths.temp, MODE_OWNER_ONLY) != 0) {
+            throw new SfOperationalException("could not create archive staging directory " + paths.temp);
+        }
+
+        boolean published = false;
+        try {
+            int maxPayload = 0;
+            long totalSize = MmapSegment.HEADER_SIZE;
+            for (long fsn = from; fsn <= to; fsn++) {
+                int len = engine.liveFramePayloadLength(fsn);
+                if (len < QwpConstants.HEADER_SIZE) {
+                    throw new SfOperationalException("rejection frame is no longer live [fsn=" + fsn + ']');
+                }
+                totalSize = Math.addExact(totalSize, MmapSegment.FRAME_HEADER_SIZE + (long) len);
+                maxPayload = Math.max(maxPayload, len);
+                if (fsn == Long.MAX_VALUE) break;
+            }
+            copyFrames(engine, paths.temp, from, to, totalSize, maxPayload);
+            try (SfManifest ignored = SfManifest.create(ff, paths.temp, from, from)) {
+                // create() durably writes the sole boundary record.
+            }
+            try (AckWatermark watermark = AckWatermark.open(ff, paths.temp)) {
+                if (watermark == null) throw new SfOperationalException("could not create rejection ack watermark");
+                watermark.write(from - 1L);
+                watermark.sync();
+            }
+            if (dictionaryEntries != null) writeDictionary(paths.temp, dictionaryEntries, dictionaryCount);
+            writeMetadata(paths.temp, error, dictionaryEntries != null);
+            Result result = new Result(paths.completed,
+                    occupiedBytes(paths.temp, dictionaryEntries != null), false);
+            if (ff.fsyncDir(paths.temp) != 0 || ff.rename(paths.temp, paths.completed) != 0) {
+                throw new SfOperationalException("could not publish rejected mini-slot " + paths.completed);
+            }
+            published = true;
+            return result;
+        } finally {
+            if (!published) removeKnownDirectory(paths.temp);
+        }
+    }
+
+    private void copyFrames(CursorSendEngine engine, String target, long from, long to,
+                            long totalSize, int maxPayload) {
+        long scratch = Unsafe.malloc(maxPayload, MemoryTag.NATIVE_DEFAULT);
+        try (MmapSegment segment = MmapSegment.create(
+                ff, target + '/' + SEGMENT_FILE_NAME, from, totalSize, true)) {
+            for (long fsn = from; fsn <= to; fsn++) {
+                int len = engine.liveFramePayloadLength(fsn);
+                if (len < 0 || len > maxPayload || !engine.copyLiveFrame(fsn, scratch, maxPayload)) {
+                    throw new SfOperationalException("rejection frame disappeared during copy [fsn=" + fsn + ']');
+                }
+                if (fsn == to) {
+                    long flags = scratch + QwpConstants.HEADER_OFFSET_FLAGS;
+                    Unsafe.getUnsafe().putByte(flags, (byte) (Unsafe.getUnsafe().getByte(flags)
+                            & ~QwpConstants.FLAG_DEFER_COMMIT));
+                }
+                if (segment.tryAppend(scratch, len) < 0) {
+                    throw new SfOperationalException("rejection segment sizing changed during copy");
+                }
+                if (fsn == Long.MAX_VALUE) break;
+            }
+            segment.syncPublished();
+        } finally {
+            Unsafe.free(scratch, maxPayload, MemoryTag.NATIVE_DEFAULT);
+        }
+    }
+
+    private void writeDictionary(String target, byte[] entries, int count) {
+        long address = Unsafe.malloc(entries.length, MemoryTag.NATIVE_DEFAULT);
+        try (PersistedSymbolDict dictionary = PersistedSymbolDict.openClean(ff, target)) {
+            if (dictionary == null) throw new SfOperationalException("could not create rejected mini-slot dictionary");
+            Unsafe.getUnsafe().copyMemory(entries, Unsafe.BYTE_OFFSET, null, address, entries.length);
+            dictionary.appendRawEntries(address, entries.length, count);
+        } finally {
+            Unsafe.free(address, entries.length, MemoryTag.NATIVE_DEFAULT);
+        }
+    }
+
+    private SenderError readReport(Path archive, long fromFsn, long toFsn) throws IOException {
+        LinkOption[] noFollow = {LinkOption.NOFOLLOW_LINKS};
+        Path metadataFile = archive.resolve(METADATA_FILE_NAME);
+        if (!java.nio.file.Files.isDirectory(archive, noFollow)
+                || !java.nio.file.Files.isRegularFile(metadataFile, noFollow)
+                || java.nio.file.Files.size(metadataFile) > 64 * 1024L) return null;
+        Properties metadata = new Properties();
+        try (InputStream input = java.nio.file.Files.newInputStream(metadataFile)) {
+            metadata.load(input);
+        }
+        if (!"1".equals(metadata.getProperty("version"))
+                || Long.parseLong(metadata.getProperty("fromFsn")) != fromFsn
+                || Long.parseLong(metadata.getProperty("toFsn")) != toFsn
+                || !SenderError.Category.SCHEMA_MISMATCH.name().equals(metadata.getProperty("category"))
+                || !SenderError.Policy.REJECT_AND_CONTINUE.name().equals(metadata.getProperty("policy"))) return null;
+        long rejectedFsn = Long.parseLong(metadata.getProperty("rejectedFsn"));
+        if (rejectedFsn < fromFsn || rejectedFsn > toFsn) return null;
+        String dictionary = metadata.getProperty("dictionary");
+        if (!("true".equals(dictionary) || "false".equals(dictionary))) return null;
+        String dir = archive.toString();
+        if (Boolean.parseBoolean(dictionary) != ff.exists(dir + '/' + PersistedSymbolDict.FILE_NAME)) return null;
+        try (MmapSegment segment = MmapSegment.openExisting(ff, dir + '/' + SEGMENT_FILE_NAME);
+             SfManifest manifest = SfManifest.open(ff, dir);
+             AckWatermark watermark = AckWatermark.open(ff, dir)) {
+            if (segment.baseSeq() != fromFsn || segment.frameCount() != toFsn - fromFsn + 1L
+                    || manifest == null || manifest.headBase() != fromFsn || manifest.activeBase() != fromFsn
+                    || watermark == null || watermark.read() != fromFsn - 1L) return null;
+        }
+        if (Boolean.parseBoolean(dictionary)) {
+            try (PersistedSymbolDict persisted = PersistedSymbolDict.open(ff, dir)) {
+                if (persisted == null) return null;
+            }
+        }
+        String detected = metadata.getProperty("detectedAtNanos");
+        return new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                SenderError.Policy.REJECT_AND_CONTINUE,
+                Integer.parseInt(metadata.getProperty("status")), metadata.getProperty("message"),
+                rejectedFsn, rejectedFsn, rejectedFsn, metadata.getProperty("table"),
+                detected == null ? 0L : Long.parseLong(detected))
+                .withRejectionSpan(fromFsn, toFsn).withRejectedPath(dir);
+    }
+
+    private void writeMetadata(String dir, SenderError error, boolean dictionary) {
+        Properties metadata = new Properties();
+        metadata.setProperty("version", "1");
+        metadata.setProperty("fromFsn", Long.toString(error.getFromFsn()));
+        metadata.setProperty("toFsn", Long.toString(error.getToFsn()));
+        metadata.setProperty("rejectedFsn", Long.toString(error.getRejectedFsn()));
+        metadata.setProperty("status", Integer.toString(error.getServerStatusByte()));
+        metadata.setProperty("detectedAtNanos", Long.toString(error.getDetectedAtNanos()));
+        metadata.setProperty("category", error.getCategory().name());
+        metadata.setProperty("policy", error.getAppliedPolicy().name());
+        metadata.setProperty("dictionary", Boolean.toString(dictionary));
+        if (error.getTableName() != null) metadata.setProperty("table", error.getTableName());
+        if (error.getServerMessage() != null) metadata.setProperty("message", error.getServerMessage());
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        try {
+            metadata.store(output, "Preserved schema rejection; replay a working copy after fixing the schema");
+        } catch (IOException e) {
+            throw new SfOperationalException("could not encode rejection metadata", e);
+        }
+        byte[] bytes = output.toByteArray();
+        long address = Unsafe.malloc(bytes.length, MemoryTag.NATIVE_DEFAULT);
+        int fd = -1;
+        try {
+            Unsafe.getUnsafe().copyMemory(bytes, Unsafe.BYTE_OFFSET, null, address, bytes.length);
+            fd = ff.openRWExclusive(dir + '/' + METADATA_FILE_NAME);
+            if (fd < 0 || !ff.allocate(fd, bytes.length)
+                    || ff.write(fd, address, bytes.length, 0) != bytes.length || ff.fsync(fd) != 0) {
+                throw new SfOperationalException("could not write rejection metadata " + dir);
+            }
+        } finally {
+            if (fd >= 0) ff.close(fd);
+            Unsafe.free(address, bytes.length, MemoryTag.NATIVE_DEFAULT);
+        }
+    }
+
+    private PathsForRange paths(CursorSendEngine engine, long fromFsn, long toFsn) {
+        MmapSegment source = engine.findSegmentContaining(fromFsn);
+        if (source == null) throw new SfOperationalException("rejection range is no longer live");
+        String name = namespace + "-seg-" + source.generationToken() + "-fsn-" + fromFsn + '-' + toFsn;
+        String root = directory + "/rejected/";
+        return new PathsForRange(root + name, root + ".tmp-" + name);
+    }
+
+    private void removeKnownDirectory(String dir) {
+        Path path = Paths.get(dir);
+        if (!java.nio.file.Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) return;
+        String[] names = {SEGMENT_FILE_NAME, SfManifest.FILE_NAME, AckWatermark.FILE_NAME,
+                PersistedSymbolDict.FILE_NAME, METADATA_FILE_NAME};
+        for (String name : names) ff.remove(dir + '/' + name);
+        // Unknown contents keep the directory in place rather than broadening deletion scope.
+        ff.remove(dir);
+    }
+
+    private void ensureDirectory(String dir) {
+        if (!ff.exists(dir) && ff.mkdir(dir, MODE_OWNER_ONLY) != 0) {
+            throw new SfOperationalException("could not create directory " + dir);
+        }
+    }
+
+    private long occupiedBytes(String dir, boolean dictionary) {
+        long n = ff.length(dir + '/' + SEGMENT_FILE_NAME) + ff.length(dir + '/' + SfManifest.FILE_NAME)
+                + ff.length(dir + '/' + AckWatermark.FILE_NAME) + ff.length(dir + '/' + METADATA_FILE_NAME);
+        return dictionary ? n + ff.length(dir + '/' + PersistedSymbolDict.FILE_NAME) : n;
+    }
+
+    public static void probeDestination(FilesFacade ff, String slotDir) {
+        probeDirectory(ff, slotDir + "/rejected");
+    }
+
+    public static void probeDirectory(FilesFacade ff, String directory) {
+        if (!ff.exists(directory) && ff.mkdir(directory, MODE_OWNER_ONLY) != 0) {
+            throw new SfOperationalException("could not create schema preservation destination " + directory);
+        }
+        String probe = directory + "/.probe-" + UUID.randomUUID();
+        int fd = ff.openRWExclusive(probe);
+        try {
+            if (fd < 0 || ff.fsync(fd) != 0) {
+                throw new SfOperationalException("schema preservation destination is not writable " + directory);
+            }
+        } finally {
+            if (fd >= 0) ff.close(fd);
+            ff.remove(probe);
+        }
+        if (ff.fsyncDir(directory) != 0) {
+            throw new SfOperationalException("could not sync schema preservation destination " + directory);
+        }
+    }
+
+    public static final class Result {
+        public final long bytesWritten;
+        public final String path;
+        public final boolean reused;
+
+        Result(String path, long bytesWritten, boolean reused) {
+            this.path = path;
+            this.bytesWritten = bytesWritten;
+            this.reused = reused;
+        }
+    }
+
+    private static final class PathsForRange {
+        final String completed;
+        final String temp;
+
+        PathsForRange(String completed, String temp) {
+            this.completed = completed;
+            this.temp = temp;
+        }
+    }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SchemaRejectionState.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SchemaRejectionState.java
new file mode 100644
index 00000000..213bc472
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SchemaRejectionState.java
@@ -0,0 +1,223 @@
+/*******************************************************************************
+ * Copyright (c) 2014-2026 QuestDB
+ * Licensed under the Apache License, Version 2.0.
+ ******************************************************************************/
+
+package io.questdb.client.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.LineSenderServerException;
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
+
+/** Current borrow and one pending retirement. Returned borrows have no observation history. */
+public final class SchemaRejectionState {
+    private Lease current;
+    private Pending pending;
+    private CursorSendEngine engine;
+    private volatile long failedGeneration = -1L;
+    private volatile long stopFsn = -1L;
+
+    public void setEngine(CursorSendEngine engine) {
+        this.engine = engine;
+    }
+
+    public synchronized void beginLease(long generation, long firstFsn, boolean transactional) {
+        if (current != null) {
+            if (current.active) {
+                throw new IllegalStateException("previous lease is still active");
+            }
+            if (current.transactional != transactional) {
+                throw new IllegalStateException("transaction mode must remain fixed for a sender");
+            }
+        }
+        failedGeneration = -1L;
+        current = new Lease(generation, firstFsn, transactional);
+    }
+
+    /**
+     * Ends a lease and seals an open transactional rejection. The caller is
+     * the sole producer and must exclude further publication before taking
+     * {@code publishedFsn}.
+     */
+    public synchronized LineSenderServerException endLease(long generation, long publishedFsn) {
+        Lease lease = current;
+        if (lease == null || lease.generation != generation || !lease.active) {
+            return null;
+        }
+        lease.endFsn = publishedFsn;
+        sealIfNeeded(lease, publishedFsn);
+        // Pool return must close normal transactions. A failed open tail may
+        // return only after sealing the range which prevents its resurrection
+        // by the next borrow's commit. Check before giving up producer ownership.
+        if (engine != null && lease.transactional && publishedFsn >= lease.firstFsn
+                && publishedFsn > engine.ackedFsn()
+                && (pending == null || pending.lastFsn < publishedFsn)) {
+            int flags = engine.liveQwpFrameFlags(publishedFsn);
+            // ACK/trim can race this cold lookup. An already resolved closer
+            // needs no longer to be present in the ring.
+            if ((flags < 0 && publishedFsn > engine.ackedFsn())
+                    || (flags >= 0 && (flags & QwpConstants.FLAG_DEFER_COMMIT) != 0)) {
+                throw new IllegalStateException("returned transaction has no commit or rejection boundary");
+            }
+        }
+        lease.active = false;
+        if (failedGeneration == generation) {
+            failedGeneration = -1L;
+        }
+        return lease.failure;
+    }
+
+    /**
+     * Returns this generation's immutable owned failure. For an open
+     * transaction, the first producer observation seals its end at the supplied
+     * publication snapshot. Calls on one sender are single-producer by contract.
+     */
+    public synchronized LineSenderServerException ownedFailure(long generation, long publishedFsn) {
+        Lease lease = current;
+        if (lease == null || !lease.active || lease.generation != generation || lease.rawError == null) {
+            return null;
+        }
+        sealIfNeeded(lease, publishedFsn);
+        return lease.failure;
+    }
+
+    public boolean hasOwnedFailure(long generation) {
+        return failedGeneration == generation;
+    }
+
+    /** I/O-thread install. Returns false while an earlier retirement is pending. */
+    public synchronized boolean reject(long rejectedFsn, long spanStart, SenderError rawError) {
+        if (pending != null) {
+            return false;
+        }
+        Lease owner = current != null && current.active && rejectedFsn >= current.firstFsn ? current : null;
+        if (owner == null) {
+            // Transaction mode is not persisted. A recovered deferred group must
+            // therefore be treated conservatively as transactional, regardless of
+            // the new producer's settings. Bound it by the recovered namespace so
+            // a new producer's closer cannot become part of the old transaction.
+            long recoveredTip = engine != null && engine.wasRecoveredFromDisk()
+                    ? Math.max(engine.recoveredCommitBoundaryFsn(), engine.recoveredOrphanTipFsn())
+                    : -1L;
+            boolean recovered = rejectedFsn <= recoveredTip;
+            owner = new Lease(-1L, spanStart, recovered || (current != null && current.transactional));
+            owner.active = false;
+            owner.endFsn = recovered ? recoveredTip : current == null ? rejectedFsn
+                    : current.active ? current.firstFsn - 1L : current.endFsn;
+        } else if (owner.generation >= 0 && owner.rawError == null) {
+            owner.rawError = rawError;
+            failedGeneration = owner.generation;
+        }
+        long end = rejectedFsn;
+        if (owner.transactional) {
+            long tip = owner.active && engine != null ? engine.publishedFsn() : owner.endFsn;
+            long closer = firstCommitFsn(rejectedFsn, tip);
+            end = closer >= 0 ? closer : owner.active ? -1L : tip;
+        }
+        pending = new Pending(spanStart, end, owner, rawError);
+        stopFsn = spanStart;
+        if (end >= spanStart) {
+            finishFailure(end);
+        }
+        return true;
+    }
+
+    public long stopFsn() {
+        return stopFsn;
+    }
+
+    public synchronized Range sealedRange() {
+        if (pending == null || pending.error == null) {
+            return null;
+        }
+        return new Range(pending.firstFsn, pending.lastFsn, pending.error);
+    }
+
+    public synchronized void completeRetirement(long lastFsn) {
+        if (pending == null || pending.lastFsn != lastFsn) {
+            throw new IllegalStateException("retirement range changed");
+        }
+        pending = null;
+        stopFsn = -1L;
+    }
+
+    private void sealIfNeeded(Lease lease, long publishedFsn) {
+        if (pending == null || pending.owner != lease || pending.error != null) {
+            return;
+        }
+        long end = pending.lastFsn;
+        if (lease.transactional) {
+            long closer = firstCommitFsn(pending.rawError.getRejectedFsn(), publishedFsn);
+            end = closer >= 0 ? closer : publishedFsn;
+        }
+        finishFailure(end);
+    }
+
+    private long firstCommitFsn(long first, long last) {
+        if (engine == null) {
+            return -1L;
+        }
+        for (long fsn = first; fsn <= last; fsn++) {
+            int flags = engine.liveQwpFrameFlags(fsn);
+            if (flags < 0) {
+                throw new IllegalStateException("missing frame while resolving transaction at FSN " + fsn);
+            }
+            if ((flags & QwpConstants.FLAG_DEFER_COMMIT) == 0) {
+                return fsn;
+            }
+        }
+        return -1L;
+    }
+
+    private void finishFailure(long lastFsn) {
+        pending.lastFsn = lastFsn;
+        pending.error = pending.rawError.withRejectionSpan(pending.firstFsn, lastFsn);
+        Lease lease = pending.owner;
+        if (lease.failure == null && lease.generation >= 0) {
+            lease.failure = new LineSenderServerException(pending.error);
+        }
+    }
+
+    public static final class Range {
+        public final SenderError error;
+        public final long firstFsn;
+        public final long lastFsn;
+
+        private Range(long firstFsn, long lastFsn, SenderError error) {
+            this.firstFsn = firstFsn;
+            this.lastFsn = lastFsn;
+            this.error = error;
+        }
+    }
+
+    private static final class Pending {
+        private final long firstFsn;
+        private long lastFsn;
+        private final Lease owner;
+        private final SenderError rawError;
+        private SenderError error;
+
+        private Pending(long firstFsn, long lastFsn, Lease owner, SenderError rawError) {
+            this.firstFsn = firstFsn;
+            this.lastFsn = lastFsn;
+            this.owner = owner;
+            this.rawError = rawError;
+        }
+    }
+
+    private static final class Lease {
+        private final long firstFsn;
+        private final long generation;
+        private final boolean transactional;
+        private boolean active = true;
+        private long endFsn = -1L;
+        private LineSenderServerException failure;
+        private SenderError rawError;
+
+        private Lease(long generation, long firstFsn, boolean transactional) {
+            this.generation = generation;
+            this.firstFsn = firstFsn;
+            this.transactional = transactional;
+        }
+    }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java
index 6fd7f5aa..9bc9429e 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java
@@ -1299,6 +1299,21 @@ synchronized MmapSegment pinSegmentContaining(long fsn) {
         return segment;
     }
 
+    synchronized int liveFramePayloadLength(long fsn) {
+        MmapSegment segment = findSegmentContaining0(fsn);
+        return segment == null ? -1 : segment.liveFramePayloadLength(fsn);
+    }
+
+    synchronized boolean copyLiveFrame(long fsn, long dstAddr, int dstCapacity) {
+        MmapSegment segment = findSegmentContaining0(fsn);
+        return segment != null && segment.copyLiveFrame(fsn, dstAddr, dstCapacity);
+    }
+
+    synchronized int liveQwpFrameFlags(long fsn) {
+        MmapSegment segment = findSegmentContaining0(fsn);
+        return segment == null ? -1 : segment.liveQwpFrameFlags(fsn);
+    }
+
     /**
      * Oldest sealed segment, or {@code null} if the sealed list is empty.
      * Used by the I/O loop's "current was trimmed out from under us"
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java
index fb796712..976acf44 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java
@@ -32,6 +32,8 @@
 import org.slf4j.LoggerFactory;
 
 import java.util.concurrent.LinkedBlockingDeque;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
@@ -104,6 +106,9 @@ public final class SenderErrorDispatcher implements QuietCloseable {
     // the sole producer, the dispatcher is the sole consumer; close() also
     // enqueues POISON, but only once and under `lock`.
     private final LinkedBlockingDeque inbox;
+    private final ArrayBlockingQueue schemaInbox = new ArrayBlockingQueue<>(DEFAULT_CAPACITY);
+    // Includes the callback currently executing; ordinary deque overflow cannot evict these.
+    private final AtomicInteger schemaPending = new AtomicInteger();
     // Threads are started lazily under this monitor; takes the same role as
     // SegmentManager.start() — first offer() that observes a null thread
     // wins the race to spawn it.
@@ -162,7 +167,7 @@ public void close() {
             //noinspection ResultOfMethodCallIgnored
             inbox.offer(POISON);
             Thread t = dispatcherThread;
-            if (t != null) {
+            if (t != null && t != Thread.currentThread()) {
                 long deadline = System.nanoTime() + DRAIN_DEADLINE_NANOS;
                 long remainingMillis;
                 while ((remainingMillis = (deadline - System.nanoTime()) / 1_000_000L) > 0) {
@@ -306,11 +311,38 @@ public boolean offer(SenderError error) {
         return true;
     }
 
+    /** Retains a schema notification without dropping; false leaves retirement pending. */
+    public boolean tryOfferSchema(SenderError error) {
+        if (closed || error == null) {
+            return false;
+        }
+        int count;
+        do {
+            count = schemaPending.get();
+            if (count >= DEFAULT_CAPACITY) {
+                return false;
+            }
+        } while (!schemaPending.compareAndSet(count, count + 1));
+        if (closed || !schemaInbox.offer(error)) {
+            schemaPending.decrementAndGet();
+            return false;
+        }
+        startDispatcherIfNeeded();
+        return true;
+    }
+
+    public int getPendingSchemaNotifications() {
+        return schemaPending.get();
+    }
+
     private void dispatchLoop() {
-        while (!closed || !inbox.isEmpty()) {
-            SenderError err;
+        while (!closed || !inbox.isEmpty() || !schemaInbox.isEmpty()) {
+            SenderError err = schemaInbox.poll();
+            boolean schema = err != null;
             try {
-                err = inbox.poll(100, TimeUnit.MILLISECONDS);
+                if (err == null) {
+                    err = inbox.poll(10, TimeUnit.MILLISECONDS);
+                }
             } catch (InterruptedException e) {
                 if (closed) {
                     return;
@@ -344,6 +376,10 @@ private void dispatchLoop() {
                 h.onError(err);
             } catch (Throwable t) {
                 LOG.error("SenderErrorHandler threw on {}: {}", err, t.getMessage(), t);
+            } finally {
+                if (schema) {
+                    schemaPending.decrementAndGet();
+                }
             }
         }
     }
diff --git a/core/src/main/java/io/questdb/client/impl/PooledSender.java b/core/src/main/java/io/questdb/client/impl/PooledSender.java
index 7b4e5f80..f108844f 100644
--- a/core/src/main/java/io/questdb/client/impl/PooledSender.java
+++ b/core/src/main/java/io/questdb/client/impl/PooledSender.java
@@ -25,6 +25,8 @@
 package io.questdb.client.impl;
 
 import io.questdb.client.Sender;
+import io.questdb.client.LineSenderServerException;
+import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
 import io.questdb.client.cutlass.line.array.DoubleArray;
 import io.questdb.client.cutlass.line.array.LongArray;
 import io.questdb.client.std.Decimal128;
@@ -154,6 +156,23 @@ public void close() {
         if (generation != slot.generation()) {
             return;
         }
+        Sender delegate = slot.live(generation);
+        QwpWebSocketSender qwp = delegate instanceof QwpWebSocketSender ? (QwpWebSocketSender) delegate : null;
+        if (qwp != null && qwp.hasOwnedSchemaFailure()) {
+            LineSenderServerException failure;
+            try {
+                failure = qwp.releaseFailedSchemaLease();
+                qwp.checkSchemaSlotHealth();
+            } catch (RuntimeException | Error operationalFailure) {
+                slot.pool().discardBroken(this);
+                throw operationalFailure;
+            }
+            slot.pool().giveBack(this);
+            if (failure != null) {
+                throw failure;
+            }
+            return;
+        }
         // Track normal completion rather than catching a specific throwable
         // type. flush() can exit abnormally with an Error (AssertionError
         // under -ea, OutOfMemoryError, ...) as well as a RuntimeException;
@@ -169,6 +188,16 @@ public void close() {
             slot.live(generation).flush();
             flushed = true;
         } finally {
+            if (!flushed && qwp != null && qwp.hasOwnedSchemaFailure()) {
+                try {
+                    qwp.releaseFailedSchemaLease();
+                    qwp.checkSchemaSlotHealth();
+                    flushed = true;
+                } catch (RuntimeException | Error operationalFailure) {
+                    slot.pool().discardBroken(this);
+                    throw operationalFailure;
+                }
+            }
             if (flushed) {
                 slot.pool().giveBack(this);
             } else {
diff --git a/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java b/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java
index 574d6b59..4a2b8a73 100644
--- a/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java
+++ b/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java
@@ -148,6 +148,29 @@ public QuestDBImpl(
             SenderErrorHandler errorHandler,
             SenderConnectionListener connectionListener,
             BackgroundDrainerListener drainerListener
+    ) {
+        this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax, acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis, housekeeperIntervalMillis, queryCloseTimeoutMillis, senderFactory, connectHook, tokenProvider, errorHandler, connectionListener, drainerListener, io.questdb.client.SenderError.Policy.REJECT_AND_CONTINUE, true, null);
+    }
+
+    public QuestDBImpl(
+            String ingestConfig,
+            String queryConfig,
+            int senderMin,
+            int senderMax,
+            int queryMin,
+            int queryMax,
+            long acquireTimeoutMillis,
+            long idleTimeoutMillis,
+            long maxLifetimeMillis,
+            long housekeeperIntervalMillis,
+            long queryCloseTimeoutMillis,
+            IntFunction senderFactory,
+            Consumer connectHook,
+            HttpTokenProvider tokenProvider,
+            SenderErrorHandler errorHandler,
+            SenderConnectionListener connectionListener,
+            BackgroundDrainerListener drainerListener,
+            io.questdb.client.SenderError.Policy schemaMismatchPolicy, boolean dlqEnabled, String dlqDir
     ) {
         SenderPool builtSenderPool = null;
         QueryClientPool builtQueryPool = null;
@@ -160,7 +183,8 @@ public QuestDBImpl(
                     // build() never blocks on a slow / reachable-but-not-acking
                     // server; the housekeeper drives it via runStartupRecoveryStep().
                     true,
-                    errorHandler, connectionListener, drainerListener, tokenProvider);
+                    errorHandler, connectionListener, drainerListener, tokenProvider,
+                    schemaMismatchPolicy, dlqEnabled, dlqDir);
             builtQueryPool = new QueryClientPool(
                     queryConfig, queryMin, queryMax, acquireTimeoutMillis,
                     idleTimeoutMillis, maxLifetimeMillis, connectHook, null, tokenProvider);
diff --git a/core/src/main/java/io/questdb/client/impl/SenderPool.java b/core/src/main/java/io/questdb/client/impl/SenderPool.java
index 912d3b44..2f1acf31 100644
--- a/core/src/main/java/io/questdb/client/impl/SenderPool.java
+++ b/core/src/main/java/io/questdb/client/impl/SenderPool.java
@@ -34,6 +34,7 @@
 import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderErrorHandler;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLockContentionException;
 import io.questdb.client.std.Files;
@@ -154,6 +155,9 @@ public final class SenderPool implements AutoCloseable {
     private final SenderConnectionListener connectionListener;
     private final BackgroundDrainerListener drainerListener;
     private final SenderErrorHandler errorHandler;
+    private final SenderError.Policy schemaMismatchPolicy;
+    private final boolean dlqEnabled;
+    private final String dlqDir;
     private final long idleTimeoutMillis;
     private final HttpTokenProvider tokenProvider;
     // Delivery channel for recovery-delegate errors that pass the
@@ -483,6 +487,18 @@ public static SenderPool createWithRecoveryControlsForTesting(
                 drainerListener, null, null, null, tokenProvider, null);
     }
 
+    SenderPool(String configurationString, int minSize, int maxSize,
+               long acquireTimeoutMillis, long idleTimeoutMillis, long maxLifetimeMillis,
+               IntFunction senderFactory, boolean deferStartupRecovery,
+               SenderErrorHandler errorHandler, SenderConnectionListener connectionListener,
+               BackgroundDrainerListener drainerListener, HttpTokenProvider tokenProvider,
+               SenderError.Policy schemaMismatchPolicy, boolean dlqEnabled, String dlqDir) {
+        this(configurationString, minSize, maxSize, acquireTimeoutMillis, idleTimeoutMillis,
+                maxLifetimeMillis, senderFactory, deferStartupRecovery, errorHandler,
+                connectionListener, drainerListener, null, null, null, tokenProvider, null,
+                schemaMismatchPolicy, dlqEnabled, dlqDir);
+    }
+
     private SenderPool(
             String configurationString,
             int minSize,
@@ -500,10 +516,35 @@ private SenderPool(
             Runnable recoveryWaiter,
             HttpTokenProvider tokenProvider,
             Runnable beforeFailedRecoveryJoinHook
+    ) {
+        this(configurationString, minSize, maxSize, acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis, senderFactory, deferStartupRecovery, errorHandler, connectionListener, drainerListener, postFactoryHook, recoveryThreadFactory, recoveryWaiter, tokenProvider, beforeFailedRecoveryJoinHook, SenderError.Policy.REJECT_AND_CONTINUE, true, null);
+    }
+
+    private SenderPool(
+            String configurationString,
+            int minSize,
+            int maxSize,
+            long acquireTimeoutMillis,
+            long idleTimeoutMillis,
+            long maxLifetimeMillis,
+            IntFunction senderFactory,
+            boolean deferStartupRecovery,
+            SenderErrorHandler errorHandler,
+            SenderConnectionListener connectionListener,
+            BackgroundDrainerListener drainerListener,
+            Runnable postFactoryHook,
+            ThreadFactory recoveryThreadFactory,
+            Runnable recoveryWaiter,
+            HttpTokenProvider tokenProvider,
+            Runnable beforeFailedRecoveryJoinHook,
+            SenderError.Policy schemaMismatchPolicy, boolean dlqEnabled, String dlqDir
     ) {
         if (minSize < 0 || maxSize < 1 || minSize > maxSize) {
             throw new IllegalArgumentException("invalid pool sizing: min=" + minSize + ", max=" + maxSize);
         }
+        this.schemaMismatchPolicy = schemaMismatchPolicy;
+        this.dlqEnabled = dlqEnabled;
+        this.dlqDir = dlqDir;
         this.errorHandler = errorHandler;
         this.connectionListener = connectionListener;
         this.drainerListener = drainerListener;
@@ -544,9 +585,21 @@ private SenderPool(
         this.storeAndForward = probe.isStoreAndForwardEnabled();
         this.slotBaseId = this.storeAndForward ? probe.getConfiguredSenderId() : null;
         this.sfDir = this.storeAndForward ? probe.getConfiguredSfDir() : null;
+        if (schemaMismatchPolicy == SenderError.Policy.REJECT_AND_CONTINUE
+                && dlqEnabled && (dlqDir != null || sfDir != null)) {
+            String destination = dlqDir != null ? dlqDir : sfDir;
+            try {
+                java.nio.file.Files.createDirectories(java.nio.file.Paths.get(destination));
+            } catch (java.io.IOException e) {
+                throw new io.questdb.client.cutlass.line.LineSenderException(e)
+                        .put("could not create schema preservation destination ").put(destination);
+            }
+            io.questdb.client.cutlass.qwp.client.sf.cursor.RejectedMiniSlotArchive.probeDirectory(
+                    io.questdb.client.std.FilesFacade.INSTANCE, destination);
+        }
         this.slotInUse = this.storeAndForward ? new boolean[maxSize] : null;
-        this.recoveryErrorDispatcher = (errorHandler != null && this.storeAndForward)
-                ? new SenderErrorDispatcher(errorHandler, SenderErrorDispatcher.DEFAULT_CAPACITY,
+        this.recoveryErrorDispatcher = this.storeAndForward
+                ? new SenderErrorDispatcher(errorHandler != null ? errorHandler : DefaultSenderErrorHandler.INSTANCE, SenderErrorDispatcher.DEFAULT_CAPACITY,
                         "qdb-sf-pool-recovery-errors")
                 : null;
         // Pre-warm minSize connections. Pre-warm runs single-threaded in the
@@ -1123,7 +1176,9 @@ private RecoveryDrainOutcome drainCandidateSlotForRecovery(int slotIndex, String
                 // on a timeout: a server that fails to ack within the budget
                 // will very likely do the same for every remaining slot -- the
                 // same reasoning as the build-failure case above.
-                if (!recoverer.delegate().drain(remainingMillis)) {
+                if (!(recoverer.delegate() instanceof QwpWebSocketSender
+                        ? ((QwpWebSocketSender) recoverer.delegate()).drainResolved(remainingMillis)
+                        : recoverer.delegate().drain(remainingMillis))) {
                     if (warnSlotOnce(slotIndex)) {
                         LOG.warn("startup SF recovery: drain did not ack slot {} "
                                 + "within {}ms; deferring this and remaining slots",
@@ -1228,6 +1283,7 @@ public PooledSender borrow() {
                     // wrapper handed out can be told apart from any prior,
                     // now-stale borrow of the same slot.
                     s.bumpGeneration();
+                    s.beginSchemaLease();
                     return new PooledSender(s, s.generation());
                 }
                 if (all.size() + inFlightCreations + closingSlots + leakedSlots + recoveringSlots < maxSize) {
@@ -1296,6 +1352,7 @@ public PooledSender borrow() {
                     }
                     all.add(created);
                     created.bumpGeneration();
+                    created.beginSchemaLease();
                     inFlightCreations--;
                     creationFinished.signalAll();
                     return new PooledSender(created, created.generation());
@@ -1649,11 +1706,15 @@ public void giveBack(PooledSender ps) {
                     // twice and hand it to two borrowers writing into one delegate.
                     return;
                 }
+                io.questdb.client.LineSenderServerException schemaFailure = s.endSchemaLease();
                 s.bumpGeneration();
                 s.markIdleAt(System.currentTimeMillis());
                 assert !available.contains(s) : "slot already present in available deque on giveBack";
                 available.addLast(s);
                 slotReleased.signal();
+                if (schemaFailure != null) {
+                    throw schemaFailure;
+                }
                 return;
             }
         } finally {
@@ -2039,7 +2100,11 @@ private Sender.LineSenderBuilder applyRecoveryCallbacks(Sender.LineSenderBuilder
             builder.errorHandler(new SenderErrorHandler() {
                 @Override
                 public void onError(SenderError error) {
-                    if (isRecoveryEventUserRelevant(error)) {
+                    if (error.getAppliedPolicy() == SenderError.Policy.REJECT_AND_CONTINUE) {
+                        // Already on the delegate's reliable dispatcher: a second lossy hop
+                        // would defeat the schema FIFO's delivery guarantee.
+                        (errorHandler != null ? errorHandler : DefaultSenderErrorHandler.INSTANCE).onError(error);
+                    } else if (isRecoveryEventUserRelevant(error)) {
                         recoveryErrorDispatcher.offer(error);
                     }
                 }
@@ -2049,6 +2114,10 @@ public void onError(SenderError error) {
     }
 
     private Sender.LineSenderBuilder applyTokenProvider(Sender.LineSenderBuilder builder) {
+        builder.schemaMismatchPolicy(schemaMismatchPolicy).dlqEnabled(dlqEnabled);
+        if (dlqDir != null) {
+            builder.dlqDirectory(dlqDir);
+        }
         if (tokenProvider != null) {
             builder.httpTokenProvider(tokenProvider);
         }
diff --git a/core/src/main/java/io/questdb/client/impl/SenderSlot.java b/core/src/main/java/io/questdb/client/impl/SenderSlot.java
index 5d9ed6ff..b2bf3f84 100644
--- a/core/src/main/java/io/questdb/client/impl/SenderSlot.java
+++ b/core/src/main/java/io/questdb/client/impl/SenderSlot.java
@@ -25,6 +25,7 @@
 package io.questdb.client.impl;
 
 import io.questdb.client.Sender;
+import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
 
 /**
  * One reusable {@link SenderPool} slot: owns a real {@link Sender} delegate, its
@@ -68,6 +69,9 @@ final class SenderSlot {
         this.slotIndex = slotIndex;
         this.createdAtMillis = System.currentTimeMillis();
         this.idleSinceMillis = this.createdAtMillis;
+        if (delegate instanceof QwpWebSocketSender) {
+            ((QwpWebSocketSender) delegate).prepareSchemaPoolSlot();
+        }
     }
 
     /**
@@ -79,6 +83,19 @@ void bumpGeneration() {
         generation++;
     }
 
+    void beginSchemaLease() {
+        if (delegate instanceof QwpWebSocketSender) {
+            ((QwpWebSocketSender) delegate).beginSchemaLease(generation);
+        }
+    }
+
+    io.questdb.client.LineSenderServerException endSchemaLease() {
+        if (delegate instanceof QwpWebSocketSender) {
+            return ((QwpWebSocketSender) delegate).endSchemaLease();
+        }
+        return null;
+    }
+
     long createdAtMillis() {
         return createdAtMillis;
     }
diff --git a/core/src/test/java/io/questdb/client/test/SenderErrorTest.java b/core/src/test/java/io/questdb/client/test/SenderErrorTest.java
index 10e91d13..416880d9 100644
--- a/core/src/test/java/io/questdb/client/test/SenderErrorTest.java
+++ b/core/src/test/java/io/questdb/client/test/SenderErrorTest.java
@@ -57,11 +57,12 @@ public void testAllCategoriesEnumerable() {
     @Test
     public void testAllPoliciesEnumerable() {
         SenderError.Policy[] policies = SenderError.Policy.values();
-        Assert.assertEquals(4, policies.length);
+        Assert.assertEquals(5, policies.length);
         Assert.assertEquals(SenderError.Policy.RETRIABLE, SenderError.Policy.valueOf("RETRIABLE"));
         Assert.assertEquals(SenderError.Policy.RETRIABLE_OTHER, SenderError.Policy.valueOf("RETRIABLE_OTHER"));
         Assert.assertEquals(SenderError.Policy.TERMINAL, SenderError.Policy.valueOf("TERMINAL"));
         Assert.assertEquals(SenderError.Policy.ABANDONED, SenderError.Policy.valueOf("ABANDONED"));
+        Assert.assertEquals(SenderError.Policy.REJECT_AND_CONTINUE, SenderError.Policy.valueOf("REJECT_AND_CONTINUE"));
     }
 
     @Test
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RejectedArchiveRecoveryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RejectedArchiveRecoveryTest.java
new file mode 100644
index 00000000..a0ee4294
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RejectedArchiveRecoveryTest.java
@@ -0,0 +1,195 @@
+/*******************************************************************************
+ * Copyright (c) 2014-2026 QuestDB
+ * Licensed under the Apache License, Version 2.0.
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client;
+
+import io.questdb.client.Sender;
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWatermark;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.RejectedMiniSlotArchive;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
+import io.questdb.client.std.FilesFacade;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.*;
+
+public class RejectedArchiveRecoveryTest {
+    @Rule
+    public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build();
+
+    @Test(timeout = 30_000)
+    public void testDamagedDrainedArchiveDoesNotBlockRepeatedBuilds() throws Exception {
+        assertRepeatedBuilds(RejectedMiniSlotArchive.METADATA_FILE_NAME, false);
+    }
+
+    @Test(timeout = 30_000)
+    public void testDamagedOverlappingArchiveDoesNotAffectQueue() throws Exception {
+        assertRepeatedBuilds(RejectedMiniSlotArchive.METADATA_FILE_NAME, true);
+    }
+
+    @Test(timeout = 30_000)
+    public void testPublishedArchiveReconstructsCallbackBeforeOrphanRetirement() throws Exception {
+        assertRepeatedBuilds(null, true);
+    }
+
+    @Test(timeout = 30_000)
+    public void testDamagedOverlappingArchiveSegmentDoesNotAffectQueue() throws Exception {
+        assertRepeatedBuilds(RejectedMiniSlotArchive.SEGMENT_FILE_NAME, true);
+    }
+
+    @Test(timeout = 30_000)
+    public void testMismatchedArchiveBoundaryDoesNotAffectQueue() throws Exception {
+        assertRepeatedBuilds(AckWatermark.FILE_NAME, true);
+    }
+
+    private void assertRepeatedBuilds(String damagedFile, boolean overlaps) throws Exception {
+        boolean damaged = damagedFile != null;
+        Path base = temp.newFolder().toPath();
+        Path slot = Files.createDirectory(base.resolve("saved"));
+        // Residue from the original PR and a crashed writer is output only.
+        Path oldEpoch = slot.resolve(".slot-epoch");
+        Files.write(oldEpoch, new byte[]{0});
+        Path staging = Files.createDirectories(slot.resolve("rejected/.tmp-legacy-writer"));
+        Path oldMetadata = staging.resolve("rejection-meta.bin");
+        Files.write(oldMetadata, new byte[]{1});
+        String archive;
+        try (CursorSendEngine engine = new CursorSendEngine(slot.toString(), 4096)) {
+            // Keep fixture construction independent of the manager's ACK-persistence tick.
+            engine.getManagerForTesting().close();
+            append(engine, false);
+            archive = preserve(engine, slot, 0);
+            assertTrue(engine.acknowledge(0));
+            append(engine, true); // Uncommitted orphan tail at FSN 1.
+            if (overlaps) archive = preserve(engine, slot, 1);
+        }
+        // FSN 0 is the drained prefix of this fixture. acknowledge() only
+        // advances the live ring; a partially drained close need not persist
+        // that watermark. Write it explicitly so the orphan can retire during
+        // build(), rather than after replay ACKs on the I/O thread.
+        try (AckWatermark watermark = AckWatermark.open(slot.toString())) {
+            assertNotNull(watermark);
+            watermark.write(0);
+            watermark.sync();
+        }
+        Path archiveFile = Paths.get(archive, damaged ? damagedFile : RejectedMiniSlotArchive.METADATA_FILE_NAME);
+        if (damaged) {
+            if (RejectedMiniSlotArchive.SEGMENT_FILE_NAME.equals(damagedFile)) {
+                // Keep the valid file size while corrupting the segment header.
+                byte[] archiveBytes = Files.readAllBytes(archiveFile);
+                archiveBytes[0] ^= 1;
+                Files.write(archiveFile, archiveBytes);
+            } else if (AckWatermark.FILE_NAME.equals(damagedFile)) {
+                try (AckWatermark archiveWatermark = AckWatermark.open(archive)) {
+                    assertNotNull(archiveWatermark);
+                    archiveWatermark.write(1); // Structurally valid, but expected boundary is 0.
+                    archiveWatermark.sync();
+                }
+            } else {
+                Files.write(archiveFile, new byte[]{0});
+            }
+        }
+        byte[] archiveBytes = Files.readAllBytes(archiveFile);
+        AtomicInteger quarantines = new AtomicInteger();
+        AtomicInteger schemaReports = new AtomicInteger();
+        CountDownLatch schemaReported = new CountDownLatch(1);
+        AtomicReference recoveredReport = new AtomicReference<>();
+        Map sequences = new ConcurrentHashMap<>();
+        try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+            @Override
+            public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+                long sequence = sequences.merge(client, 1L, Long::sum) - 1;
+                try {
+                    client.sendBinary(QwpWireTestUtils.buildAck(sequence));
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        })) {
+            server.start();
+            assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+            List failures = new ArrayList<>();
+            for (int attempt = 0; attempt < 3; attempt++) {
+                try (Sender sender = Sender.builder("ws::addr=localhost:" + server.getPort()
+                                + ";sf_dir=" + base + ";close_flush_timeout_millis=0;")
+                        .senderId("saved").errorHandler(error -> {
+                            if (error.getCategory() == SenderError.Category.DATA_LOSS) quarantines.incrementAndGet();
+                            if (error.getCategory() == SenderError.Category.SCHEMA_MISMATCH) {
+                                recoveredReport.set(error);
+                                schemaReports.incrementAndGet();
+                                schemaReported.countDown();
+                            }
+                        }).build()) {
+                    assertEquals("archives must never quarantine a live queue", 0,
+                            quarantines.get());
+                    sender.table("healthy").longColumn("value", attempt).atNow();
+                    long target = sender.flushAndGetSequence();
+                    assertTrue("new rows must drain after recovery", sender.awaitAckedFsn(target, 5_000));
+
+                } catch (RuntimeException e) {
+                    failures.add(e);
+                }
+            }
+            assertTrue("all three builds must succeed: " + failures, failures.isEmpty());
+        }
+        Path quarantined = base.resolve("saved.unreplayable-0");
+        assertEquals(0, quarantines.get());
+        if (overlaps && !damaged) {
+            assertTrue("published archive report must precede orphan retirement",
+                    schemaReported.await(5, TimeUnit.SECONDS));
+            assertEquals(1, schemaReports.get());
+            SenderError report = recoveredReport.get();
+            assertNotNull(report);
+            assertEquals(1, report.getFromFsn());
+            assertEquals(1, report.getToFsn());
+            assertEquals(1, report.getRejectedFsn());
+            assertEquals(archive, report.getRejectedPath());
+        } else {
+            assertEquals(1, schemaReported.getCount());
+            assertEquals(0, schemaReports.get());
+        }
+        assertFalse(Files.exists(quarantined));
+        assertArrayEquals(archiveBytes, Files.readAllBytes(archiveFile));
+        assertArrayEquals(new byte[]{0}, Files.readAllBytes(oldEpoch));
+        assertArrayEquals(new byte[]{1}, Files.readAllBytes(oldMetadata));
+    }
+
+    private static String preserve(CursorSendEngine engine, Path slot, long fsn) {
+        SenderError error = new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                SenderError.Policy.REJECT_AND_CONTINUE, 3, "schema rejected", fsn, fsn, fsn, null, 1);
+        return new RejectedMiniSlotArchive(FilesFacade.INSTANCE, slot.toString()).preserve(engine, error, null, 0).path;
+    }
+
+    private static void append(CursorSendEngine engine, boolean deferred) {
+        long frame = Unsafe.malloc(QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+        try {
+            Unsafe.getUnsafe().setMemory(frame, QwpConstants.HEADER_SIZE, (byte) 0);
+            Unsafe.getUnsafe().putInt(frame, QwpConstants.MAGIC_MESSAGE);
+            Unsafe.getUnsafe().putByte(frame + QwpConstants.HEADER_OFFSET_FLAGS,
+                    (byte) (deferred ? QwpConstants.FLAG_DEFER_COMMIT : 0));
+            engine.appendBlocking(frame, QwpConstants.HEADER_SIZE);
+        } finally {
+            Unsafe.free(frame, QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+        }
+    }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/BackgroundDrainerEndToEndTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/BackgroundDrainerEndToEndTest.java
index 0a147ca1..8e406541 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/BackgroundDrainerEndToEndTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/BackgroundDrainerEndToEndTest.java
@@ -25,6 +25,8 @@
 package io.questdb.client.test.cutlass.qwp.client.sf;
 
 import io.questdb.client.Sender;
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
 import io.questdb.client.std.Files;
 import io.questdb.client.test.cutlass.qwp.client.QwpWireTestUtils;
@@ -42,8 +44,10 @@
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * End-to-end coverage of the background drainer adopting an orphan slot.
@@ -73,6 +77,68 @@ public void tearDown() {
         if (sfDir != null) rmDirRec(sfDir);
     }
 
+    @Test
+    public void testDrainerPreservesAndReportsSchemaRejectedSpan() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+                silent.start();
+                Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+                String ghostConfig = "ws::addr=localhost:" + silent.getPort()
+                        + ";sf_dir=" + sfDir
+                        + ";sender_id=ghost;close_flush_timeout_millis=0;";
+                try (Sender ghost = Sender.fromConfig(ghostConfig)) {
+                    ghost.table("bad").stringColumn("value", "wrong").atNow();
+                    Assert.assertEquals(0L, ghost.flushAndGetSequence());
+                }
+            }
+
+            CountDownLatch reported = new CountDownLatch(1);
+            AtomicReference captured = new AtomicReference<>();
+            AtomicLong nextSequence = new AtomicLong();
+            try (TestWebSocketServer rejecting = new TestWebSocketServer(
+                    new TestWebSocketServer.WebSocketServerHandler() {
+                @Override
+                public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+                    long sequence = nextSequence.getAndIncrement();
+                    try {
+                        client.sendBinary(QwpWireTestUtils.buildNack(
+                                sequence, WebSocketResponse.STATUS_SCHEMA_MISMATCH));
+                    } catch (IOException e) {
+                        throw new RuntimeException(e);
+                    }
+                }
+            })) {
+                rejecting.start();
+                Assert.assertTrue(rejecting.awaitStart(5, TimeUnit.SECONDS));
+                String primaryConfig = "ws::addr=localhost:" + rejecting.getPort()
+                        + ";sf_dir=" + sfDir
+                        + ";sender_id=primary;drain_orphans=true;max_background_drainers=1;";
+                try (Sender ignored = Sender.builder(primaryConfig)
+                        .schemaMismatchPolicy(SenderError.Policy.REJECT_AND_CONTINUE)
+                        .dlqEnabled(true)
+                        .errorHandler(error -> {
+                            if (error.getAppliedPolicy() == SenderError.Policy.REJECT_AND_CONTINUE) {
+                                captured.set(error);
+                                reported.countDown();
+                            }
+                        })
+                        .build()) {
+                    Assert.assertTrue("schema rejection callback", reported.await(10, TimeUnit.SECONDS));
+                }
+            }
+
+            SenderError error = captured.get();
+            Assert.assertNotNull(error);
+            Assert.assertEquals(SenderError.Category.SCHEMA_MISMATCH, error.getCategory());
+            Assert.assertEquals(0L, error.getRejectedFsn());
+            Assert.assertEquals(0L, error.getFromFsn());
+            Assert.assertEquals(0L, error.getToFsn());
+            Assert.assertNotNull("archive must be published before callback", error.getRejectedPath());
+            Assert.assertTrue(java.nio.file.Files.isDirectory(Paths.get(error.getRejectedPath())));
+            Assert.assertFalse(Files.exists(sfDir + "/ghost/" + OrphanScanner.FAILED_SENTINEL_NAME));
+        });
+    }
+
     @Test
     public void testDrainerEmptiesOrphanSlotAgainstAckServer() throws Exception {
         TestUtils.assertMemoryLeak(() -> {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java
index a8bd96c9..6bb9d14b 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java
@@ -24,12 +24,16 @@
 
 package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
 
+import io.questdb.client.SenderError;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWatermark;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.RejectedMiniSlotArchive;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
 import io.questdb.client.std.Files;
+import io.questdb.client.std.FilesFacade;
 import io.questdb.client.std.MemoryTag;
 import io.questdb.client.std.Unsafe;
 import io.questdb.client.test.tools.TestUtils;
@@ -40,6 +44,7 @@
 
 import java.nio.file.Paths;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 
 public class BackgroundDrainerSetupFailureTest {
 
@@ -58,6 +63,82 @@ public void tearDown() {
         removeRecursive(slotPath);
     }
 
+    @Test
+    public void testPreservedOrphanReportsBeforeRetirementWithoutConnecting() throws Exception {
+        assertOrphanRetiresOffline(true);
+    }
+
+    @Test
+    public void testUnreportedOrphanRetiresWithoutConnectingWhenPreservationEnabled() throws Exception {
+        assertOrphanRetiresOffline(false);
+    }
+
+    private void assertOrphanRetiresOffline(boolean archive) throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            String archivedPath = null;
+            try (CursorSendEngine original = new CursorSendEngine(slotPath, SEGMENT_BYTES)) {
+                long frame = Unsafe.malloc(QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+                try {
+                    Unsafe.getUnsafe().setMemory(frame, QwpConstants.HEADER_SIZE, (byte) 0);
+                    Unsafe.getUnsafe().putInt(frame, QwpConstants.MAGIC_MESSAGE);
+                    Unsafe.getUnsafe().putByte(frame + QwpConstants.HEADER_OFFSET_FLAGS,
+                            QwpConstants.FLAG_DEFER_COMMIT);
+                    original.appendBlocking(frame, QwpConstants.HEADER_SIZE);
+                } finally {
+                    Unsafe.free(frame, QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+                }
+                if (archive) {
+                    SenderError error = new SenderError(
+                            SenderError.Category.SCHEMA_MISMATCH,
+                            SenderError.Policy.REJECT_AND_CONTINUE, 3, "bad schema", 0, 0, 0, null, 1);
+                    archivedPath = new RejectedMiniSlotArchive(FilesFacade.INSTANCE, slotPath).preserve(original, error, null, 0).path;
+                }
+            }
+            AtomicReference report = new AtomicReference<>();
+            AtomicReference callbackThread = new AtomicReference<>();
+            BackgroundDrainer drainer = new BackgroundDrainer(slotPath, SEGMENT_BYTES, Long.MAX_VALUE,
+                    () -> { throw new AssertionError("orphan-only retirement must not connect"); },
+                    5_000L, 1L, 10L, true, 200L);
+            drainer.configureSchemaMismatch(SenderError.Policy.REJECT_AND_CONTINUE, true, null,
+                    e -> { report.set(e); callbackThread.set(Thread.currentThread()); });
+            drainer.run();
+            Assert.assertEquals(BackgroundDrainer.DrainOutcome.SUCCESS, drainer.outcome());
+            Assert.assertFalse(OrphanScanner.isCandidateOrphan(slotPath));
+            if (archive) {
+                SenderError recovered = report.get();
+                Assert.assertNotNull(recovered);
+                Assert.assertEquals(0, recovered.getFromFsn());
+                Assert.assertEquals(0, recovered.getToFsn());
+                Assert.assertEquals(archivedPath, recovered.getRejectedPath());
+                Assert.assertNotEquals(Thread.currentThread(), callbackThread.get());
+                Assert.assertTrue(java.nio.file.Files.isDirectory(Paths.get(archivedPath)));
+            } else {
+                Assert.assertNull(report.get());
+            }
+        });
+    }
+
+    @Test
+    public void testPreservationDestinationFailureDoesNotQuarantine() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            seedUnackedFrame();
+            String blocked = slotPath + "/blocked-destination";
+            java.nio.file.Files.createFile(Paths.get(blocked));
+            BackgroundDrainer drainer = new BackgroundDrainer(slotPath, SEGMENT_BYTES,
+                    Long.MAX_VALUE, () -> { throw new AssertionError("must fail before connect"); },
+                    5_000L, 1L, 10L, true, 200L);
+            drainer.configureSchemaMismatch(SenderError.Policy.REJECT_AND_CONTINUE,
+                    true, blocked, null);
+            drainer.run();
+            Assert.assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+            Assert.assertFalse(Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+            Assert.assertTrue("storage outage must leave source recoverable", OrphanScanner.isCandidateOrphan(slotPath));
+            try (CursorSendEngine ignored = new CursorSendEngine(slotPath, SEGMENT_BYTES)) {
+                Assert.assertTrue(ignored.publishedFsn() >= 0);
+            }
+        });
+    }
+
     @Test
     public void testConnectErrorPropagatesWithoutQuarantine() throws Exception {
         TestUtils.assertMemoryLeak(() -> {
@@ -243,7 +324,7 @@ public void testSealedResidueFirstSightHealsAndDoesNotQuarantine() throws Except
             // client's reseal-after-recovery did.
             int fd = Files.openRW(p0Path);
             Assert.assertTrue("openRW must succeed", fd >= 0);
-            long junk = Unsafe.malloc(12, MemoryTag.NATIVE_DEFAULT);
+            long junk = Unsafe.malloc(QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
             try {
                 for (int i = 0; i < 3; i++) {
                     Unsafe.getUnsafe().putInt(junk + i * 4L, 0xCAFEBABE);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java
index ec828aae..5f0328ab 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java
@@ -25,6 +25,7 @@
 package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
 
 import io.questdb.client.DefaultHttpClientConfiguration;
+import io.questdb.client.SenderError;
 import io.questdb.client.cutlass.http.client.WebSocketClient;
 import io.questdb.client.cutlass.line.LineSenderException;
 import io.questdb.client.cutlass.qwp.client.QwpRoleMismatchException;
@@ -32,6 +33,8 @@
 import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SchemaRejectionState;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher;
 import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
 import io.questdb.client.network.PlainSocketFactory;
 import io.questdb.client.std.Files;
@@ -129,6 +132,48 @@ public void testCatchUpFrameAckDoesNotAdvanceTrimWatermark() throws Exception {
         });
     }
 
+    @Test
+    public void testDiskSuccessorDictionarySurvivesSkippedDeltaCarrier() throws Exception {
+        assertSkippedDeltaCarrierKeepsSuccessorReplayable(false);
+    }
+
+    @Test
+    public void testMemorySuccessorDictionarySurvivesSkippedDeltaCarrier() throws Exception {
+        assertSkippedDeltaCarrierKeepsSuccessorReplayable(true);
+    }
+
+    @Test
+    public void testSealedSchemaRangeStopsAndSelfAcknowledges() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            CatchUpCapturingClient client = new CatchUpCapturingClient(0);
+            try (CursorSendEngine engine = new CursorSendEngine(null, 16_384)) {
+                appendDeltaDictFrame(engine, 0, 'a');
+                appendDeltaDictFrame(engine, 1, 'b');
+                SchemaRejectionState state = new SchemaRejectionState();
+                state.beginLease(1, 0, false);
+                SenderError error = new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                        SenderError.Policy.REJECT_AND_CONTINUE, 7, "mismatch", 1,
+                        1, 1, "tab", System.nanoTime());
+                assertTrue(state.reject(1, 0, error));
+                SenderErrorDispatcher dispatcher = new SenderErrorDispatcher(ignored -> { });
+                CursorWebSocketSendLoop loop = newLoop(engine, client);
+                try {
+                    loop.setSchemaRejectionState(state);
+                    loop.setErrorDispatcher(dispatcher);
+                    loop.positionCursorForStartForTest();
+                    assertTrue(loop.trySendOneForTest());
+                    assertEquals(1, engine.ackedFsn());
+                    assertEquals(2, loop.getSchemaFramesRetired());
+                    assertEquals(-1, state.stopFsn());
+                    assertEquals(Arrays.asList("a", "b"), readMirrorSymbols(loop));
+                } finally {
+                    loop.close();
+                    dispatcher.close();
+                }
+            }
+        });
+    }
+
     @Test
     public void testSplitCatchUpFramesAcksDoNotAdvanceTrimWatermark() throws Exception {
         // A small advertised cap splits the dictionary across several catch-up
@@ -1198,6 +1243,37 @@ private void assertUnrelatedReconnectStateRestartsCapGapEpisode(boolean roleReje
         });
     }
 
+    private void assertSkippedDeltaCarrierKeepsSuccessorReplayable(boolean memory) throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            CatchUpCapturingClient client = new CatchUpCapturingClient(0);
+            try (CursorSendEngine closeableEngine = memory
+                    ? new CursorSendEngine(null, 16_384)
+                    : newEngine()) {
+                appendDeltaDictFrame(closeableEngine, 0, 'a'); // carrier to retire
+                appendDeltaDictFrame(closeableEngine, 1, 'b'); // surviving successor
+                assertEquals(QwpConstants.FLAG_DELTA_SYMBOL_DICT & 0xff,
+                        closeableEngine.liveQwpFrameFlags(0));
+                assertEquals(-1, closeableEngine.liveQwpFrameFlags(2));
+                CursorWebSocketSendLoop loop = newLoop(closeableEngine, client);
+                try {
+                    loop.catchUpSkippedRangeForTest(0, 0);
+                    assertEquals(Arrays.asList("a"), readMirrorSymbols(loop));
+
+                    // The successor starts at id 1. Folding it after the skipped
+                    // carrier is the same contiguity check trySendOne applies and
+                    // proves the carrier's symbol was not lost from catch-up state.
+                    loop.catchUpSkippedRangeForTest(1, 1);
+                    assertEquals(Arrays.asList("a", "b"), readMirrorSymbols(loop));
+
+                    invokeSetWireBaselineWithCatchUp(loop, 2L);
+                    assertCatchUpReassembles(client, "a", "b");
+                } finally {
+                    loop.close();
+                }
+            }
+        });
+    }
+
     // Builds a QWP delta frame [12-byte header][deltaStart varint][deltaCount
     // varint][ [len varint][utf8] ... ] for the given symbols. accumulateSentDict
     // skips the header, so its content is irrelevant; the caller frees the frame.
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java
index 1f446a11..4834763f 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java
@@ -29,24 +29,32 @@
 import io.questdb.client.SenderError;
 import io.questdb.client.cutlass.http.client.WebSocketClient;
 import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
+import io.questdb.client.cutlass.line.LineSenderException;
 import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
 import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SchemaRejectionState;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.RejectedMiniSlotArchive;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher;
 import io.questdb.client.network.PlainSocketFactory;
-import io.questdb.client.std.Files;
 import io.questdb.client.std.MemoryTag;
 import io.questdb.client.std.Unsafe;
+import io.questdb.client.test.tools.DelegatingFilesFacade;
 import io.questdb.client.test.tools.TestUtils;
-import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 
 import java.nio.charset.StandardCharsets;
-import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
@@ -76,32 +84,13 @@ public class CursorWebSocketSendLoopPoisonFrameTest {
 
     private String tmpDir;
 
-    @Before
-    public void setUp() {
-        tmpDir = Paths.get(System.getProperty("java.io.tmpdir"),
-                "qdb-cursor-poison-" + System.nanoTime()).toString();
-        assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT));
-    }
+    @Rule
+    public final TemporaryFolder temp = new TemporaryFolder();
 
-    @After
-    public void tearDown() {
-        if (tmpDir == null) return;
-        long find = Files.findFirst(tmpDir);
-        if (find > 0) {
-            try {
-                int rc = 1;
-                while (rc > 0) {
-                    String name = Files.utf8ToString(Files.findName(find));
-                    if (name != null && !".".equals(name) && !"..".equals(name)) {
-                        Files.remove(tmpDir + "/" + name);
-                    }
-                    rc = Files.findNext(find);
-                }
-            } finally {
-                Files.findClose(find);
-            }
-        }
-        Files.remove(tmpDir);
+    @Before
+    public void setUp() throws Exception {
+        // Preservation tests create nested archives; the rule cleans those too.
+        tmpDir = temp.newFolder("slot").getAbsolutePath();
     }
 
     @Test
@@ -155,6 +144,227 @@ public void testDurableModeDetectorFiresDespiteReplayReOks() throws Exception {
         });
     }
 
+    @Test
+    public void testSecondSchemaNackWhileRetirementPendingFailsClosedAfterDurableReplayOk() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            List clients = new ArrayList<>();
+            try (CursorSendEngine engine = newEngine()) {
+                appendFrames(engine, 2);
+                SchemaRejectionState state = new SchemaRejectionState();
+                state.setEngine(engine);
+                state.beginLease(1L, 0L, false);
+                SenderError first = new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                        SenderError.Policy.REJECT_AND_CONTINUE, 3, "first mismatch", 1L,
+                        1L, 1L, null, System.nanoTime());
+                assertTrue(state.reject(1L, 1L, first));
+                try (CursorWebSocketSendLoop loop = newDurableLoop(engine, clients)) {
+                    loop.setSchemaMismatchPolicy(SenderError.Policy.REJECT_AND_CONTINUE);
+                    loop.setSchemaRejectionState(state);
+
+                    assertEquals("retirement must wait for the durable predecessor", -1L, engine.ackedFsn());
+
+                    setSentCount(loop, 2);
+                    deliverOk(loop, 0L, names("trades"), txns(7L));
+                    assertEquals("an OK without its durable ACK must not release the predecessor",
+                            -1L, engine.ackedFsn());
+                    deliverSchemaNack(loop, 1L, "second mismatch");
+
+                    try {
+                        loop.checkError();
+                        fail("a second schema rejection cannot replace an unresolved retirement range");
+                    } catch (LineSenderServerException e) {
+                        assertEquals(SenderError.Category.SCHEMA_MISMATCH,
+                                e.getServerError().getCategory());
+                    }
+                    assertEquals("fail-closed fallback must preserve every source frame",
+                            -1L, engine.ackedFsn());
+                    assertEquals(1L, state.stopFsn());
+                }
+            } finally {
+                closeAll(clients);
+            }
+        });
+    }
+
+    @Test
+    public void testSkippedRangeFailureLatchesInsteadOfReconnect() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            List clients = new ArrayList<>();
+            try (CursorSendEngine engine = newEngine()) {
+                appendFrames(engine, 1);
+                SchemaRejectionState state = new SchemaRejectionState();
+                state.setEngine(engine);
+                state.beginLease(1L, 0L, false);
+                SenderError error = new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                        SenderError.Policy.REJECT_AND_CONTINUE, 3, "missing range frame", 1L,
+                        1L, 1L, null, System.nanoTime());
+                assertTrue(state.reject(1L, 1L, error));
+                assertTrue(engine.acknowledge(0L));
+                AtomicReference reported = new AtomicReference<>();
+                try (CursorWebSocketSendLoop loop = newDurableLoop(engine, clients);
+                     io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher dispatcher =
+                             new io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher(reported::set)) {
+                    loop.setSchemaRejectionState(state);
+                    loop.setErrorDispatcher(dispatcher);
+                    assertFalse(loop.tryRetireSchemaRangeForTest());
+
+                    try {
+                        loop.checkError();
+                        fail("a skipped-range inconsistency must latch terminal");
+                    } catch (LineSenderException e) {
+                        assertTrue(e.getMessage().contains("frame disappeared before retirement"));
+                    }
+                    assertEquals("the inconsistent range must remain unacknowledged",
+                            0L, engine.ackedFsn());
+                    long deadline = System.nanoTime() + 5_000_000_000L;
+                    while (reported.get() == null && System.nanoTime() < deadline) {
+                        Thread.yield();
+                    }
+                    assertEquals("the callback must describe the fail-closed policy actually applied",
+                            SenderError.Policy.TERMINAL, reported.get().getAppliedPolicy());
+                    assertEquals(1L, reported.get().getFromFsn());
+                    assertEquals(1L, reported.get().getToFsn());
+                }
+            } finally {
+                closeAll(clients);
+            }
+        });
+    }
+
+    @Test
+    public void testPreservationFailureRetriesThenRetiresWithoutTerminal() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            try (CursorSendEngine engine = newEngine()) {
+                appendFrames(engine, 1);
+                SchemaRejectionState state = new SchemaRejectionState();
+                state.setEngine(engine);
+                state.beginLease(1L, 0L, false);
+                SenderError error = new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                        SenderError.Policy.REJECT_AND_CONTINUE, 3, "mismatch", 0L,
+                        0L, 0L, null, System.nanoTime());
+                assertTrue(state.reject(0L, 0L, error));
+                FailFirstTemporaryMkdirFacade ff = new FailFirstTemporaryMkdirFacade();
+                AtomicReference reported = new AtomicReference<>();
+                RejectedMiniSlotArchive preserver = new RejectedMiniSlotArchive(
+                        ff, tmpDir);
+                try (SenderErrorDispatcher dispatcher = new SenderErrorDispatcher(reported::set);
+                     CursorWebSocketSendLoop loop = newDurableLoop(engine, new ArrayList<>())) {
+                    loop.setSchemaRejectionState(state);
+                    loop.setRejectionArchive(preserver);
+                    loop.setErrorDispatcher(dispatcher);
+
+                    assertFalse(loop.tryRetireSchemaRangeForTest());
+                    assertEquals(1L, loop.getDlqWriteFailures());
+                    assertEquals(-1L, engine.ackedFsn());
+                    assertEquals(null, loop.getTerminalError());
+                    assertEquals(null, reported.get());
+
+                    long successDeadline = System.nanoTime() + 5_000_000_000L;
+                    assertTrue(loop.tryRetireSchemaRangeForTest());
+                    assertEquals(0L, engine.ackedFsn());
+                    assertEquals(null, loop.getTerminalError());
+                    while (reported.get() == null && System.nanoTime() < successDeadline) Thread.yield();
+                    assertEquals(SenderError.Policy.REJECT_AND_CONTINUE,
+                            reported.get().getAppliedPolicy());
+                    assertTrue(reported.get().getRejectedPath() != null);
+                }
+            }
+        });
+    }
+
+    @Test
+    public void testFullSchemaNotificationFifoDoesNotRepeatPreservation() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            try (CursorSendEngine engine = newEngine()) {
+                appendFrames(engine, 1);
+                SchemaRejectionState state = new SchemaRejectionState();
+                state.setEngine(engine);
+                state.beginLease(1L, 0L, false);
+                SenderError error = new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                        SenderError.Policy.REJECT_AND_CONTINUE, 3, "mismatch", 0L,
+                        0L, 0L, null, System.nanoTime());
+                assertTrue(state.reject(0L, 0L, error));
+                CountingPreserveFacade ff = new CountingPreserveFacade(tmpDir + "/rejected");
+                        CountDownLatch handlerEntered = new CountDownLatch(1);
+                CountDownLatch releaseHandler = new CountDownLatch(1);
+                try (SenderErrorDispatcher dispatcher = new SenderErrorDispatcher(ignored -> {
+                    handlerEntered.countDown();
+                    try {
+                        releaseHandler.await();
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                }); CursorWebSocketSendLoop loop = newDurableLoop(engine, new ArrayList<>())) {
+                    assertTrue(dispatcher.tryOfferSchema(error));
+                    assertTrue(handlerEntered.await(5, TimeUnit.SECONDS));
+                    for (int i = 1; i < SenderErrorDispatcher.DEFAULT_CAPACITY; i++) {
+                        assertTrue(dispatcher.tryOfferSchema(error));
+                    }
+                    loop.setSchemaRejectionState(state);
+                    loop.setRejectionArchive(new RejectedMiniSlotArchive(ff, tmpDir));
+                    loop.setErrorDispatcher(dispatcher);
+
+                    assertFalse(loop.tryRetireSchemaRangeForTest());
+                    int syncsAfterPreserve = ff.rejectedRootSyncs;
+                    assertTrue(syncsAfterPreserve > 0);
+                    assertFalse(loop.tryRetireSchemaRangeForTest());
+                    assertEquals("a cached notification must avoid archive validation and fsync",
+                            syncsAfterPreserve, ff.rejectedRootSyncs);
+                    assertEquals(-1L, engine.ackedFsn());
+
+                    releaseHandler.countDown();
+                    long deadline = System.nanoTime() + 5_000_000_000L;
+                    while (dispatcher.getPendingSchemaNotifications()
+                            >= SenderErrorDispatcher.DEFAULT_CAPACITY
+                            && System.nanoTime() < deadline) {
+                        Thread.yield();
+                    }
+                    assertTrue(loop.tryRetireSchemaRangeForTest());
+                    assertEquals(0L, engine.ackedFsn());
+                } finally {
+                    releaseHandler.countDown();
+                }
+            }
+        });
+    }
+
+    @Test
+    public void testTransactionalCloserScanFailureFromNackLatchesWithoutReconnect() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            List clients = new ArrayList<>();
+            try (CursorSendEngine engine = newEngine()) {
+                appendDeferredFrame(engine);
+                SchemaRejectionState state = new SchemaRejectionState();
+                state.beginLease(1L, 0L, true);
+                state.endLease(1L, 1L); // Inject an advertised tail including missing frame 1.
+                state.setEngine(engine);
+                AtomicReference reported = new AtomicReference<>();
+                try (CursorWebSocketSendLoop loop = newDurableLoop(engine, clients);
+                     SenderErrorDispatcher dispatcher = new SenderErrorDispatcher(reported::set)) {
+                    loop.setSchemaMismatchPolicy(SenderError.Policy.REJECT_AND_CONTINUE);
+                    loop.setSchemaRejectionState(state);
+                    loop.setErrorDispatcher(dispatcher);
+                    setSentCount(loop, 1L);
+                    deliverSchemaNack(loop, 0L, "transaction mismatch");
+                    try {
+                        loop.checkError();
+                        fail("transactional closer scan failure must latch terminal");
+                    } catch (LineSenderException e) {
+                        assertTrue(e.getMessage().contains("could not resolve schema-rejected"));
+                    }
+                    assertEquals("terminal path must not enter reconnect", 0L,
+                            loop.getTotalReconnectAttempts());
+                    assertEquals(-1L, engine.ackedFsn());
+                    long deadline = System.nanoTime() + 5_000_000_000L;
+                    while (reported.get() == null && System.nanoTime() < deadline) Thread.yield();
+                    assertEquals(SenderError.Policy.TERMINAL, reported.get().getAppliedPolicy());
+                }
+            } finally {
+                closeAll(clients);
+            }
+        });
+    }
+
     @Test
     public void testPoisonTerminalNamesTheRejectedFsn() throws Exception {
         // The escalated terminal must name the frame the server rejected, not
@@ -880,6 +1090,36 @@ public void testNotWritableRecycleFirstImmediateThenPaced() throws Exception {
     // harness
     // ---------------------------------------------------------------------
 
+    private static final class FailFirstTemporaryMkdirFacade extends DelegatingFilesFacade {
+        private boolean failed;
+
+        @Override
+        public int mkdir(String path, int mode) {
+            if (!failed && path.contains("/rejected/.tmp-")) {
+                failed = true;
+                return -1;
+            }
+            return super.mkdir(path, mode);
+        }
+    }
+
+    private static final class CountingPreserveFacade extends DelegatingFilesFacade {
+        private final String rejectedRoot;
+        private int rejectedRootSyncs;
+
+        private CountingPreserveFacade(String rejectedRoot) {
+            this.rejectedRoot = rejectedRoot;
+        }
+
+        @Override
+        public int fsyncDir(String path) {
+            if (rejectedRoot.equals(path)) {
+                rejectedRootSyncs++;
+            }
+            return super.fsyncDir(path);
+        }
+    }
+
     /**
      * In-memory transport emulating a healthy server that deterministically
      * NACKs the head frame: accepts the connection, waits for one send on
@@ -1056,6 +1296,19 @@ private static void appendFrames(CursorSendEngine engine, int count) {
         }
     }
 
+    private static void appendDeferredFrame(CursorSendEngine engine) {
+        long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+        try {
+            Unsafe.getUnsafe().setMemory(buf, 16, (byte) 0);
+            Unsafe.getUnsafe().putInt(buf, io.questdb.client.cutlass.qwp.protocol.QwpConstants.MAGIC_MESSAGE);
+            Unsafe.getUnsafe().putByte(buf + io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_OFFSET_FLAGS,
+                    io.questdb.client.cutlass.qwp.protocol.QwpConstants.FLAG_DEFER_COMMIT);
+            engine.appendBlocking(buf, 16);
+        } finally {
+            Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+        }
+    }
+
     private static long buildErrorPayload(long wireSeq, byte status, String message) {
         // Error frame: status(1) + sequence(8) + msgLen(2) + bytes
         byte[] msg = message.getBytes(StandardCharsets.UTF_8);
@@ -1140,6 +1393,18 @@ private static void deliverRetriableNack(CursorWebSocketSendLoop loop, long wire
         }
     }
 
+    private static void deliverSchemaNack(CursorWebSocketSendLoop loop, long wireSeq,
+                                          String msg) throws Exception {
+        long packed = buildErrorPayload(wireSeq, WebSocketResponse.STATUS_SCHEMA_MISMATCH, msg);
+        long ptr = packed & 0xFFFFFFFFFFFFL;
+        int size = (int) (packed >>> 48);
+        try {
+            invokeOnBinaryMessage(loop, ptr, size);
+        } finally {
+            Unsafe.free(ptr, size, MemoryTag.NATIVE_DEFAULT);
+        }
+    }
+
     private static void closeAll(List clients) {
         // swapClient already closed all but the most recently installed
         // client; close() is idempotent, so sweep them all.
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopSchemaPreservationCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopSchemaPreservationCloseTest.java
new file mode 100644
index 00000000..e911f7bc
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopSchemaPreservationCloseTest.java
@@ -0,0 +1,139 @@
+/*******************************************************************************
+ * Copyright (c) 2014-2026 QuestDB
+ * Licensed under the Apache License, Version 2.0.
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.DefaultHttpClientConfiguration;
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.http.client.WebSocketClient;
+import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.RejectedMiniSlotArchive;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SchemaRejectionState;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
+import io.questdb.client.network.PlainSocketFactory;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.test.tools.DelegatingFilesFacade;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.*;
+
+public class CursorWebSocketSendLoopSchemaPreservationCloseTest {
+    @Rule
+    public final TemporaryFolder temp = new TemporaryFolder();
+
+    @Test(timeout = 30_000L)
+    public void testBlockedCopyRetainsEngineUntilIoThreadCleanup() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            String directory = temp.newFolder("slot").getAbsolutePath();
+            BlockingArchiveFacade ff = new BlockingArchiveFacade();
+            CursorSendEngine engine = new CursorSendEngine(directory, 4096);
+            WebSocketClient client = new WebSocketClient(
+                    DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE) {
+                @Override
+                protected void ioWait(int timeout, int operation) {
+                }
+
+                @Override
+                protected void setupIoWait() {
+                }
+
+                @Override
+                public void closeTraffic() {
+                    // No real socket; closing network traffic cannot cancel disk I/O.
+                }
+            };
+            CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop(client, engine, 0,
+                    CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, null, 1000, 5000, false);
+            CountDownLatch cleaned = new CountDownLatch(1);
+            try (SenderErrorDispatcher dispatcher = new SenderErrorDispatcher(error -> { })) {
+                long frame = Unsafe.malloc(QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+                try {
+                    Unsafe.getUnsafe().setMemory(frame, QwpConstants.HEADER_SIZE, (byte) 0);
+                    Unsafe.getUnsafe().putInt(frame, QwpConstants.MAGIC_MESSAGE);
+                    engine.appendBlocking(frame, QwpConstants.HEADER_SIZE);
+                } finally {
+                    Unsafe.free(frame, QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+                }
+                SchemaRejectionState state = new SchemaRejectionState();
+                state.setEngine(engine);
+                state.beginLease(1, 0, false);
+                assertTrue(state.reject(0, 0, new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                        SenderError.Policy.REJECT_AND_CONTINUE, 3, "mismatch", 0,
+                        0, 0, "tab", System.nanoTime())));
+                loop.setSchemaRejectionState(state);
+                loop.setRejectionArchive(new RejectedMiniSlotArchive(ff, directory));
+                loop.setErrorDispatcher(dispatcher);
+                loop.setShutdownAwaitTimeoutMillis(25);
+                loop.start();
+                assertTrue("I/O thread did not begin preservation", ff.entered.await(5, TimeUnit.SECONDS));
+                assertEquals("qdb-cursor-ws-io", ff.copyThread.get().getName());
+                assertEquals(-1, engine.ackedFsn());
+                try {
+                    loop.close();
+                    fail("blocked disk I/O must exhaust the shutdown budget");
+                } catch (LineSenderException expected) {
+                    assertTrue(expected.getMessage().contains("timed out"));
+                }
+                assertTrue(loop.delegateClose(() -> {
+                    engine.close();
+                    cleaned.countDown();
+                }));
+                assertEquals(1, cleaned.getCount());
+                try (SlotLock ignored = SlotLock.acquire(directory)) {
+                    fail("engine lock was released while the copy still uses its memory");
+                } catch (IllegalStateException expected) {
+                    // The I/O thread retains engine ownership until disk access ends.
+                }
+                ff.release.countDown();
+                assertTrue("deferred engine cleanup did not finish", cleaned.await(5, TimeUnit.SECONDS));
+                try (SlotLock ignored = SlotLock.acquire(directory)) {
+                    // Rebuild can now acquire the same queue.
+                }
+            } finally {
+                ff.release.countDown();
+                loop.close();
+                engine.close();
+                client.close();
+            }
+        });
+    }
+
+    private static final class BlockingArchiveFacade extends DelegatingFilesFacade {
+        private final CountDownLatch entered = new CountDownLatch(1);
+        private final CountDownLatch release = new CountDownLatch(1);
+        private final AtomicReference copyThread = new AtomicReference<>();
+
+        @Override
+        public int mkdir(String path, int mode) {
+            if (path.contains("/rejected/.tmp-")) {
+                copyThread.set(Thread.currentThread());
+                entered.countDown();
+                boolean interrupted = false;
+                while (true) {
+                    try {
+                        release.await();
+                        break;
+                    } catch (InterruptedException ignored) {
+                        interrupted = true;
+                    }
+                }
+                if (interrupted) Thread.currentThread().interrupt();
+            }
+            return super.mkdir(path, mode);
+        }
+    }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java
index 32473cd3..47943cca 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java
@@ -204,8 +204,10 @@ public void testHeaderShapeMatchesTheDocumentedLayout() throws Exception {
         assertEquals(1, MmapSegment.VERSION);
         TestUtils.assertMemoryLeak(() -> {
             String path = tmpDir + "/seg-header.sfa";
+            long generationToken;
             try (MmapSegment seg = MmapSegment.create(path, 7L, 4096L)) {
                 assertEquals(7L, seg.baseSeq());
+                generationToken = seg.generationToken();
             }
             byte[] bytes = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(path));
             java.nio.ByteBuffer header = java.nio.ByteBuffer
@@ -216,7 +218,69 @@ public void testHeaderShapeMatchesTheDocumentedLayout() throws Exception {
             assertEquals("flags", 0, header.get(5));
             assertEquals("reserved", 0, header.getShort(6));
             assertEquals(7L, header.getLong(8));
-            assertTrue("createdMicros must be stamped", header.getLong(16) > 0L);
+            assertEquals(generationToken, header.getLong(16));
+        });
+    }
+
+    @Test
+    public void testFreshSegmentsHaveDistinctGenerationTokens() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            try (MmapSegment firstDisk = MmapSegment.create(tmpDir + "/generation-1.sfa", 0L, 4096L);
+                 MmapSegment secondDisk = MmapSegment.create(tmpDir + "/generation-2.sfa", 0L, 4096L);
+                 MmapSegment firstMemory = MmapSegment.createInMemory(0L, 4096L);
+                 MmapSegment secondMemory = MmapSegment.createInMemory(0L, 4096L)) {
+                assertNotEquals(firstDisk.generationToken(), secondDisk.generationToken());
+                assertNotEquals(firstMemory.generationToken(), secondMemory.generationToken());
+            }
+        });
+    }
+
+    @Test
+    public void testLegacyCreationTimestampIsReadAsOpaqueGenerationToken() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            String path = tmpDir + "/legacy-generation.sfa";
+            try (MmapSegment ignored = MmapSegment.create(path, 7L, 4096L)) {
+                // Close before replacing the token with a legacy timestamp value.
+            }
+            long legacyTimestamp = 1_234_567_890L;
+            try (RandomAccessFile file = new RandomAccessFile(path, "rw")) {
+                file.seek(16L);
+                file.writeLong(Long.reverseBytes(legacyTimestamp));
+            }
+            try (MmapSegment reopened = MmapSegment.openExisting(path)) {
+                assertEquals(legacyTimestamp, reopened.generationToken());
+            }
+        });
+    }
+
+    @Test
+    public void testLiveFrameLookupCacheRetainsBoundsAndCorruptionChecks() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            long payload = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+            try (MmapSegment segment = MmapSegment.createInMemory(10L, 4096L)) {
+                java.lang.reflect.Method payloadLength = MmapSegment.class
+                        .getDeclaredMethod("liveFramePayloadLength", long.class);
+                payloadLength.setAccessible(true);
+                for (int i = 0; i < 4; i++) {
+                    assertTrue(segment.tryAppend(payload, i + 1) >= 0);
+                }
+
+                assertEquals(1, ((Integer) payloadLength.invoke(segment, 10L)).intValue());
+                assertEquals(3, ((Integer) payloadLength.invoke(segment, 12L)).intValue());
+                assertEquals(3, ((Integer) payloadLength.invoke(segment, 12L)).intValue());
+                assertEquals(2, ((Integer) payloadLength.invoke(segment, 11L)).intValue());
+                assertEquals(4, ((Integer) payloadLength.invoke(segment, 13L)).intValue());
+                assertEquals(-1, ((Integer) payloadLength.invoke(segment, 9L)).intValue());
+                assertEquals(-1, ((Integer) payloadLength.invoke(segment, 14L)).intValue());
+
+                // A cached offset is still validated before use.
+                long fourthOffset = MmapSegment.HEADER_SIZE
+                        + 3L * MmapSegment.FRAME_HEADER_SIZE + 1L + 2L + 3L;
+                Unsafe.getUnsafe().putInt(segment.address() + fourthOffset + 4, Integer.MAX_VALUE);
+                assertEquals(-1, ((Integer) payloadLength.invoke(segment, 13L)).intValue());
+            } finally {
+                Unsafe.free(payload, 16, MemoryTag.NATIVE_DEFAULT);
+            }
         });
     }
 
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/RejectedMiniSlotArchiveTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/RejectedMiniSlotArchiveTest.java
new file mode 100644
index 00000000..88e3ae49
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/RejectedMiniSlotArchiveTest.java
@@ -0,0 +1,276 @@
+/*******************************************************************************
+ * Copyright (c) 2014-2026 QuestDB
+ * Licensed under the Apache License, Version 2.0.
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentCorruptionException;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.RejectedMiniSlotArchive;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SfOperationalException;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SfRecoveryException;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
+import io.questdb.client.std.FilesFacade;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.test.tools.DelegatingFilesFacade;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.*;
+
+public class RejectedMiniSlotArchiveTest {
+    @Rule
+    public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build();
+
+    @Test
+    public void testPreservedSubsetReplaysWithDictionaryAndKeepsArchive() throws Exception {
+        String source = temp.newFolder().getAbsolutePath();
+        byte[] dict = {4, 'z', 'e', 'r', 'o', 3, 'o', 'n', 'e', 6, 'u', 'n', 'u', 's', 'e', 'd'};
+        try (CursorSendEngine engine = new CursorSendEngine(source, 4096)) {
+            appendDeltaFrame(engine, 0, true, "zero");
+            appendDeltaFrame(engine, 1, true, "one");
+            String message = TestUtils.repeat("mismatch: \u00e9\n\\=", 8192);
+            SenderError error = new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                    SenderError.Policy.REJECT_AND_CONTINUE, 3, message, 1, 0, 1, "tab", 42);
+            RejectedMiniSlotArchive writer = new RejectedMiniSlotArchive(FilesFacade.INSTANCE, source);
+            RejectedMiniSlotArchive.Result result = writer.preserve(engine, error, dict, 3);
+            assertTrue(result.bytesWritten > 0);
+            Properties metadata = new Properties();
+            try (java.io.InputStream input = Files.newInputStream(Paths.get(result.path,
+                    RejectedMiniSlotArchive.METADATA_FILE_NAME))) {
+                metadata.load(input);
+            }
+            assertEquals("0", metadata.getProperty("fromFsn"));
+            assertEquals("1", metadata.getProperty("toFsn"));
+            assertEquals(message, metadata.getProperty("message"));
+            try (MmapSegment segment = MmapSegment.openExisting(result.path + '/'
+                    + RejectedMiniSlotArchive.SEGMENT_FILE_NAME)) {
+                long second = MmapSegment.HEADER_SIZE;
+                second += MmapSegment.FRAME_HEADER_SIZE
+                        + Unsafe.getUnsafe().getInt(segment.address() + second + 4);
+                long payload = segment.address() + second + MmapSegment.FRAME_HEADER_SIZE;
+                assertEquals(0, Unsafe.getUnsafe().getByte(payload + QwpConstants.HEADER_OFFSET_FLAGS)
+                        & QwpConstants.FLAG_DEFER_COMMIT);
+            }
+            String working = Paths.get(source, "working").toString();
+            copyArchive(result.path, working);
+            try (CursorSendEngine replay = new CursorSendEngine(working, 4096)) {
+                assertEquals(1, replay.publishedFsn());
+                assertEquals(-1, replay.ackedFsn());
+                assertEquals(3, replay.getPersistedSymbolDict().size());
+                replay.acknowledge(1);
+            }
+            assertTrue(Files.exists(Paths.get(result.path, RejectedMiniSlotArchive.SEGMENT_FILE_NAME)));
+            assertEquals(-1, engine.ackedFsn());
+        }
+    }
+
+    @Test
+    public void testSameRangeIsReusedAcrossWriterRestart() throws Exception {
+        String source = temp.newFolder().getAbsolutePath();
+        String namespace = RejectedMiniSlotArchive.namespaceForSource(source);
+        RejectedMiniSlotArchive.Result first;
+        try (CursorSendEngine engine = new CursorSendEngine(source, 4096)) {
+            appendDeltaFrame(engine, 0, false, "zero");
+            first = new RejectedMiniSlotArchive(
+                    FilesFacade.INSTANCE, source, namespace).preserve(engine, rejection(0), null, 0);
+            assertFalse(first.reused);
+        }
+        try (CursorSendEngine engine = new CursorSendEngine(source, 4096)) {
+            RejectedMiniSlotArchive.Result second = new RejectedMiniSlotArchive(
+                    FilesFacade.INSTANCE, source, namespace).preserve(engine, rejection(0), null, 0);
+            assertTrue(second.reused);
+            assertEquals(first.path, second.path);
+            assertEquals(0, second.bytesWritten);
+            try (java.util.stream.Stream archives = Files.list(Paths.get(source, "rejected"))) {
+                assertEquals(1, archives.count());
+            }
+        }
+    }
+
+    @Test
+    public void testFreshFsnNamespaceDoesNotReuseOldRange() throws Exception {
+        String source = temp.newFolder().getAbsolutePath();
+        String namespace = RejectedMiniSlotArchive.namespaceForSource(source);
+        String first;
+        long firstGeneration;
+        try (CursorSendEngine engine = new CursorSendEngine(source, 4096)) {
+            appendDeltaFrame(engine, 0, false, "first");
+            firstGeneration = engine.findSegmentContaining(0).generationToken();
+            first = new RejectedMiniSlotArchive(FilesFacade.INSTANCE, source, namespace)
+                    .preserve(engine, rejection(0), null, 0).path;
+            engine.acknowledge(0);
+        }
+        try (CursorSendEngine engine = new CursorSendEngine(source, 4096)) {
+            appendDeltaFrame(engine, 0, false, "second");
+            assertNotEquals(firstGeneration, engine.findSegmentContaining(0).generationToken());
+            RejectedMiniSlotArchive writer = new RejectedMiniSlotArchive(
+                    FilesFacade.INSTANCE, source, namespace);
+            assertNull(writer.findRecoveredOrphanReport(engine, 0, 0));
+            RejectedMiniSlotArchive.Result second = writer.preserve(engine, rejection(0), null, 0);
+            assertFalse(second.reused);
+            assertNotEquals(first, second.path);
+        }
+    }
+
+    @Test
+    public void testRecoveredLookupCleansOnlyExactStagingDirectory() throws Exception {
+        String source = temp.newFolder().getAbsolutePath();
+        try (CursorSendEngine engine = new CursorSendEngine(source, 4096)) {
+            appendDeltaFrame(engine, 0, false, "zero");
+            RejectedMiniSlotArchive writer = new RejectedMiniSlotArchive(FilesFacade.INSTANCE, source);
+            Path completed = Paths.get(writer.preserve(engine, rejection(0), null, 0).path);
+            Path otherWriter = Paths.get(new RejectedMiniSlotArchive(FilesFacade.INSTANCE, source,
+                    RejectedMiniSlotArchive.namespaceForSource(null))
+                    .preserve(engine, rejection(0), null, 0).path);
+            Path staging = completed.resolveSibling(".tmp-" + completed.getFileName());
+            Files.move(completed, staging);
+            Path unrelated = Files.createDirectories(Paths.get(source, "rejected", ".tmp-unrelated"));
+            Files.write(unrelated.resolve("keep"), new byte[]{1});
+
+            assertNull(new RejectedMiniSlotArchive(FilesFacade.INSTANCE, source)
+                    .findRecoveredOrphanReport(engine, 0, 0));
+            assertFalse(Files.exists(staging));
+            assertTrue(Files.isDirectory(otherWriter));
+            assertArrayEquals(new byte[]{1}, Files.readAllBytes(unrelated.resolve("keep")));
+        }
+        assertNotEquals(RejectedMiniSlotArchive.namespaceForSource(null),
+                RejectedMiniSlotArchive.namespaceForSource(null));
+    }
+
+    @Test
+    public void testParentSyncRetryDoesNotCopyAgain() throws Exception {
+        String source = temp.newFolder().getAbsolutePath();
+        AtomicInteger publications = new AtomicInteger();
+        AtomicInteger rootSyncs = new AtomicInteger();
+        FilesFacade ff = new DelegatingFilesFacade() {
+            @Override
+            public int rename(String from, String to) {
+                int result = super.rename(from, to);
+                if (result == 0) publications.incrementAndGet();
+                return result;
+            }
+            @Override
+            public int fsyncDir(String dir) {
+                if (dir.equals(source + "/rejected") && rootSyncs.incrementAndGet() <= 2) return -1;
+                return super.fsyncDir(dir);
+            }
+        };
+        try (CursorSendEngine engine = new CursorSendEngine(source, 4096)) {
+            appendDeltaFrame(engine, 0, false, "zero");
+            RejectedMiniSlotArchive writer = new RejectedMiniSlotArchive(ff, source);
+            for (int attempt = 0; attempt < 2; attempt++) {
+                try {
+                    writer.preserve(engine, rejection(0), null, 0);
+                    fail("directory barrier must gate successful preservation");
+                } catch (SfOperationalException expected) {
+                    assertEquals(-1, engine.ackedFsn());
+                }
+            }
+            RejectedMiniSlotArchive.Result result = writer.preserve(engine, rejection(0), null, 0);
+            assertEquals(1, publications.get());
+            assertTrue(Files.exists(Paths.get(result.path, RejectedMiniSlotArchive.METADATA_FILE_NAME)));
+        }
+    }
+
+    @Test
+    public void testFailedCopyCleansOnlyItsOwnStagingDirectory() throws Exception {
+        String source = temp.newFolder().getAbsolutePath();
+        Path rejected = Files.createDirectory(Paths.get(source, "rejected"));
+        Path other = Files.createDirectory(rejected.resolve(".tmp-another-writer"));
+        Files.write(other.resolve("keep"), new byte[]{1});
+        AtomicInteger attempts = new AtomicInteger();
+        FilesFacade ff = new DelegatingFilesFacade() {
+            @Override
+            public int openRWExclusive(String path) {
+                if (path.endsWith(RejectedMiniSlotArchive.METADATA_FILE_NAME) && attempts.getAndIncrement() == 0) return -1;
+                return super.openRWExclusive(path);
+            }
+        };
+        try (CursorSendEngine engine = new CursorSendEngine(source, 4096)) {
+            appendDeltaFrame(engine, 0, false, "zero");
+            RejectedMiniSlotArchive writer = new RejectedMiniSlotArchive(ff, source);
+            try {
+                writer.preserve(engine, rejection(0), null, 0);
+                fail("injected metadata write failure");
+            } catch (SfOperationalException expected) {
+                assertEquals(-1, engine.ackedFsn());
+            }
+            try (java.util.stream.Stream children = Files.list(rejected)) {
+                assertEquals(1, children.count());
+            }
+            writer.preserve(engine, rejection(0), null, 0);
+            assertArrayEquals(new byte[]{1}, Files.readAllBytes(other.resolve("keep")));
+        }
+    }
+
+    @Test
+    public void testExistingRecoveryReaderRejectsDamagedArchiveCopy() throws Exception {
+        String source = temp.newFolder().getAbsolutePath();
+        try (CursorSendEngine engine = new CursorSendEngine(source, 4096)) {
+            appendDeltaFrame(engine, 0, false, "zero");
+            String archive = new RejectedMiniSlotArchive(FilesFacade.INSTANCE, source)
+                    .preserve(engine, rejection(0), null, 0).path;
+            Path segment = Paths.get(archive, RejectedMiniSlotArchive.SEGMENT_FILE_NAME);
+            byte[] bytes = Files.readAllBytes(segment);
+            bytes[0] ^= 1;
+            Files.write(segment, bytes);
+            String working = source + "/working";
+            copyArchive(archive, working);
+            try (CursorSendEngine ignored = new CursorSendEngine(working, 4096)) {
+                fail("damaged archive must not replay");
+            } catch (MmapSegmentCorruptionException | SfRecoveryException expected) {
+                assertArrayEquals(bytes, Files.readAllBytes(segment));
+            }
+        }
+    }
+
+    private static void copyArchive(String archive, String working) throws Exception {
+        Path target = Files.createDirectory(Paths.get(working));
+        try (java.util.stream.Stream files = Files.list(Paths.get(archive))) {
+            for (Path file : (Iterable) files::iterator) {
+                Files.copy(file, target.resolve(file.getFileName()));
+            }
+        }
+    }
+
+    private static SenderError rejection(long fsn) {
+        return new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                SenderError.Policy.REJECT_AND_CONTINUE, 3, "column mismatch", fsn,
+                fsn, fsn, "tab", 42);
+    }
+
+    private static void appendDeltaFrame(CursorSendEngine engine, int deltaStart,
+                                         boolean deferred, String symbol) {
+        byte[] utf8 = symbol.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+        int size = QwpConstants.HEADER_SIZE + 2 + 1 + utf8.length;
+        long buf = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+        try {
+            Unsafe.getUnsafe().setMemory(buf, size, (byte) 0);
+            Unsafe.getUnsafe().putInt(buf, QwpConstants.MAGIC_MESSAGE);
+            Unsafe.getUnsafe().putByte(buf + QwpConstants.HEADER_OFFSET_FLAGS,
+                    (byte) (QwpConstants.FLAG_DELTA_SYMBOL_DICT
+                            | (deferred ? QwpConstants.FLAG_DEFER_COMMIT : 0)));
+            long p = buf + QwpConstants.HEADER_SIZE;
+            Unsafe.getUnsafe().putByte(p, (byte) deltaStart);
+            Unsafe.getUnsafe().putByte(p + 1, (byte) 1);
+            Unsafe.getUnsafe().putByte(p + 2, (byte) utf8.length);
+            Unsafe.getUnsafe().copyMemory(utf8, Unsafe.BYTE_OFFSET, null, p + 3, utf8.length);
+            engine.appendBlocking(buf, size);
+        } finally {
+            Unsafe.free(buf, size, MemoryTag.NATIVE_DEFAULT);
+        }
+    }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SchemaRejectionStateTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SchemaRejectionStateTest.java
new file mode 100644
index 00000000..990784df
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SchemaRejectionStateTest.java
@@ -0,0 +1,335 @@
+/*******************************************************************************
+ * Copyright (c) 2014-2026 QuestDB
+ * Licensed under the Apache License, Version 2.0.
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.LineSenderServerException;
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SchemaRejectionState;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import org.junit.Test;
+import org.junit.Rule;
+import org.junit.rules.TemporaryFolder;
+
+
+import static org.junit.Assert.*;
+
+public class SchemaRejectionStateTest {
+    @Rule
+    public final TemporaryFolder temp = new TemporaryFolder();
+
+    @Test
+    public void testLateOrdinaryRejectionAfterManyBorrows() throws Exception {
+        SchemaRejectionState state = new SchemaRejectionState();
+        for (int i = 0; i < 20_000; i++) {
+            state.beginLease(i, i, false);
+            state.endLease(i, i);
+        }
+        state.beginLease(20_000, 20_000, false);
+        assertTrue(state.reject(10_000, 10_000, error(10_000)));
+        assertEquals(10_000, state.sealedRange().lastFsn);
+        assertFalse(state.hasOwnedFailure(20_000));
+        assertNull(state.ownedFailure(20_000, 20_000));
+    }
+
+    @Test
+    public void testLateTransactionalRejectionAfterManyBorrows() throws Exception {
+        try (CursorSendEngine engine = new CursorSendEngine(null, 4096)) {
+            SchemaRejectionState state = new SchemaRejectionState();
+            state.setEngine(engine);
+            for (int i = 0; i < 20_000; i++) {
+                state.beginLease(i, 2L * i, true);
+                append(engine, true);
+                append(engine, false);
+                state.endLease(i, 2L * i + 1);
+            }
+                state.beginLease(20_000, 40_000, true);
+            assertTrue(state.reject(20_000, 20_000, error(20_000)));
+            assertEquals(20_001, state.sealedRange().lastFsn);
+            assertFalse(state.hasOwnedFailure(20_000));
+            state.completeRetirement(20_001);
+            assertTrue(state.reject(20_002, 20_002, error(20_002)));
+            assertEquals(20_003, state.sealedRange().lastFsn);
+            assertFalse(state.hasOwnedFailure(20_000));
+        }
+    }
+
+    @Test
+    public void testUnfinishedReturnedTransactionCannotConsumeLaterBorrow() throws Exception {
+        try (CursorSendEngine engine = new CursorSendEngine(null, 4096)) {
+            SchemaRejectionState state = new SchemaRejectionState();
+            state.setEngine(engine);
+            state.beginLease(1, 0, true);
+            append(engine, true);
+            append(engine, true);
+            assertTrue(state.reject(0, 0, error(0)));
+            assertNull(state.sealedRange());
+            state.endLease(1, 1);
+            state.beginLease(2, 2, true);
+            append(engine, false);
+            state.endLease(2, 2);
+            state.beginLease(3, 3, true);
+            assertEquals(1, state.sealedRange().lastFsn);
+            assertFalse(state.hasOwnedFailure(3));
+        }
+    }
+
+    @Test
+    public void testPendingRetirementSurvivesManyBorrows() throws Exception {
+        SchemaRejectionState state = new SchemaRejectionState();
+        state.beginLease(1, 0, false);
+        state.endLease(1, 0);
+        assertTrue(state.reject(0, 0, error(0)));
+        for (int i = 2; i < 20_000; i++) {
+            state.beginLease(i, i - 1, false);
+            state.endLease(i, i - 1);
+        }
+        assertEquals(0, state.sealedRange().lastFsn);
+        state.completeRetirement(0);
+        state.beginLease(20_000, 19_999, false);
+        assertTrue(state.reject(1, 1, error(1)));
+        assertEquals(1, state.sealedRange().lastFsn);
+        assertFalse(state.hasOwnedFailure(20_000));
+    }
+
+    @Test
+    public void testPendingRangeAndNextBorrowHaveIndependentFailures() {
+        SchemaRejectionState state = new SchemaRejectionState();
+        state.beginLease(1, 0, false);
+        state.endLease(1, 0);
+        state.beginLease(2, 1, false);
+        assertTrue(state.reject(1, 1, error(1)));
+        LineSenderServerException failure = state.ownedFailure(2, 1);
+        assertSame(failure, state.endLease(2, 1));
+
+        // Reborrowing cannot replace the pending notification's original range.
+        state.beginLease(3, 2, false);
+        assertEquals(1, state.sealedRange().firstFsn);
+        assertEquals(1, state.sealedRange().lastFsn);
+        assertFalse(state.hasOwnedFailure(2));
+        assertFalse(state.hasOwnedFailure(3));
+        state.completeRetirement(1);
+        assertTrue(state.reject(2, 2, error(2)));
+        assertTrue(state.hasOwnedFailure(3));
+        LineSenderServerException nextFailure = state.ownedFailure(3, 2);
+        assertNotSame(failure, nextFailure);
+        assertEquals(2, nextFailure.getServerError().getRejectedFsn());
+    }
+
+    @Test
+    public void testEmptyBorrowsDoNotOwnEarlierPublications() throws Exception {
+        SchemaRejectionState state = new SchemaRejectionState();
+        state.beginLease(0, 0, false);
+        state.endLease(0, 0);
+        for (int i = 1; i < 20_000; i++) {
+            state.beginLease(i, 1, false);
+            state.endLease(i, 0);
+        }
+        state.beginLease(20_000, 1, false);
+        state.endLease(20_000, 0);
+    }
+
+    @Test
+    public void testRecoveredGroupRetiresThroughCloserRegardlessOfNewLeaseMode() throws Exception {
+        String path = temp.newFolder("recovered-group").getAbsolutePath();
+        try (CursorSendEngine original = new CursorSendEngine(path, 4096)) {
+            append(original, true);
+            append(original, true);
+            append(original, false);
+            append(original, false);
+        }
+        try (CursorSendEngine recovered = new CursorSendEngine(path, 4096)) {
+            SchemaRejectionState state = new SchemaRejectionState();
+            state.setEngine(recovered);
+            state.beginLease(1, 4, false);
+            append(recovered, false);
+            assertTrue(state.reject(1, 0, error(1)));
+            assertEquals(2, state.sealedRange().lastFsn);
+            assertFalse(state.hasOwnedFailure(1));
+        }
+    }
+
+    @Test
+    public void testRecoveredOpenTailCannotUseNewProducerCloser() throws Exception {
+        String path = temp.newFolder("recovered-tail").getAbsolutePath();
+        try (CursorSendEngine original = new CursorSendEngine(path, 4096)) {
+            append(original, true);
+            append(original, true);
+        }
+        try (CursorSendEngine recovered = new CursorSendEngine(path, 4096)) {
+            SchemaRejectionState state = new SchemaRejectionState();
+            state.setEngine(recovered);
+            state.beginLease(1, 2, false);
+            append(recovered, false);
+            assertTrue(state.reject(0, 0, error(0)));
+            assertEquals(1, state.sealedRange().lastFsn);
+            assertFalse(state.hasOwnedFailure(1));
+        }
+    }
+
+    private static void append(CursorSendEngine engine, boolean deferred) {
+        long frame = Unsafe.malloc(QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+        try {
+            Unsafe.getUnsafe().setMemory(frame, QwpConstants.HEADER_SIZE, (byte) 0);
+            Unsafe.getUnsafe().putInt(frame, QwpConstants.MAGIC_MESSAGE);
+            Unsafe.getUnsafe().putByte(frame + QwpConstants.HEADER_OFFSET_FLAGS,
+                    (byte) (deferred ? QwpConstants.FLAG_DEFER_COMMIT : 0));
+            engine.appendBlocking(frame, QwpConstants.HEADER_SIZE);
+        } finally {
+            Unsafe.free(frame, QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+        }
+    }
+
+    @Test
+    public void testReturnCapturesRejectionArrivingAfterFinalProducerCall() {
+        SchemaRejectionState state = new SchemaRejectionState();
+        state.beginLease(7, 10, true);
+        assertTrue(state.reject(12, 10, error(12)));
+        LineSenderServerException failure = state.endLease(7, 15);
+        assertNotNull(failure);
+        assertEquals(15, failure.getServerError().getToFsn());
+        assertNull("duplicate return must not throw again", state.endLease(7, 15));
+    }
+
+    @Test
+    public void testOpenTransactionSealsOnFirstProducerObservation() {
+        SchemaRejectionState state = new SchemaRejectionState();
+        state.beginLease(7, 10, true);
+        assertTrue(state.reject(12, 10, error(12)));
+        assertTrue(state.hasOwnedFailure(7));
+        assertNull(state.sealedRange());
+
+        LineSenderServerException first = state.ownedFailure(7, 15);
+        assertNotNull(first);
+        assertSame(first, state.ownedFailure(7, 99));
+        assertEquals(10, first.getServerError().getFromFsn());
+        assertEquals(15, first.getServerError().getToFsn());
+        assertEquals(15, state.sealedRange().lastFsn);
+    }
+
+    @Test
+    public void testOrdinaryRangeIsImmediatelySealedAtRejectedFrame() {
+        SchemaRejectionState state = new SchemaRejectionState();
+        state.beginLease(2, 3, false);
+        assertTrue(state.reject(5, 4, error(5)));
+        assertEquals(5, state.sealedRange().lastFsn);
+        assertEquals(5, state.ownedFailure(2, 8).getServerError().getToFsn());
+    }
+
+    @Test
+    public void testRecoveredFrameWithoutLeaseStillRetiresAndReports() {
+        SchemaRejectionState state = new SchemaRejectionState();
+        assertTrue(state.reject(4, 2, error(4)));
+        SchemaRejectionState.Range range = state.sealedRange();
+        assertNotNull(range);
+        assertEquals(2, range.firstFsn);
+        assertEquals(4, range.lastFsn);
+        assertEquals(4, range.error.getRejectedFsn());
+    }
+
+    @Test
+    public void testReturnedUnackedTransactionalLeaseReportsWithoutOwnedFailure() {
+        SchemaRejectionState state = new SchemaRejectionState();
+        state.beginLease(7, 10, true);
+        state.endLease(7, 15);
+
+        assertTrue(state.reject(12, 10, error(12)));
+        assertEquals(10, state.stopFsn());
+        assertEquals(15, state.sealedRange().lastFsn);
+        assertFalse(state.hasOwnedFailure(7));
+        assertNull(state.ownedFailure(7, 99));
+    }
+
+    @Test
+    public void testClosedTransactionDoesNotRetireNextTransactionInSameLease() {
+        try (CursorSendEngine engine = new CursorSendEngine(null, 4096)) {
+            long frame = Unsafe.malloc(QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+            try {
+                Unsafe.getUnsafe().setMemory(frame, QwpConstants.HEADER_SIZE, (byte) 0);
+                Unsafe.getUnsafe().putInt(frame, QwpConstants.MAGIC_MESSAGE);
+                // Two closed transactions share one sender lease.
+                for (int i = 0; i < 4; i++) {
+                    Unsafe.getUnsafe().putByte(frame + QwpConstants.HEADER_OFFSET_FLAGS, (byte) ((i & 1) == 0 ? QwpConstants.FLAG_DEFER_COMMIT : 0));
+                    engine.appendBlocking(frame, QwpConstants.HEADER_SIZE);
+                }
+                SchemaRejectionState state = new SchemaRejectionState();
+                state.setEngine(engine);
+                state.beginLease(1, 0, true);
+                assertTrue(state.reject(0, 0, error(0)));
+                assertEquals(1, state.sealedRange().lastFsn);
+                assertEquals(1, state.ownedFailure(1, 3).getServerError().getToFsn());
+                state.completeRetirement(1);
+                // A second rejected transaction has its own notification span;
+                // the handle keeps the first immutable exception.
+                assertTrue(state.reject(2, 2, error(2)));
+                assertEquals(2, state.sealedRange().error.getRejectedFsn());
+                assertEquals(3, state.sealedRange().lastFsn);
+                assertEquals(1, state.ownedFailure(1, 3).getServerError().getToFsn());
+            } finally {
+                Unsafe.free(frame, QwpConstants.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+            }
+        }
+    }
+
+    @Test
+    public void testUnclosedReturnDoesNotReleaseProducerOwnership() throws Exception {
+        try (CursorSendEngine engine = new CursorSendEngine(null, 4096)) {
+            SchemaRejectionState state = new SchemaRejectionState();
+            state.setEngine(engine);
+            state.beginLease(1, 0, true);
+            append(engine, true);
+            try {
+                state.endLease(1, 0);
+                fail("normal return needs a closer");
+            } catch (IllegalStateException expected) {
+                assertTrue(expected.getMessage().contains("no commit or rejection boundary"));
+            }
+            // Producer ownership remains intact; only sealing its rejection
+            // permits this unfinished transaction to be followed by a new borrow.
+            assertTrue(state.reject(0, 0, error(0)));
+            assertNotNull(state.endLease(1, 0));
+            state.beginLease(2, 1, true);
+            append(engine, false);
+            assertEquals(0, state.sealedRange().lastFsn);
+            assertFalse(state.hasOwnedFailure(2));
+        }
+    }
+
+    @Test
+    public void testReturnRacingRejectionNeverFailsNextBorrow() throws Exception {
+        java.util.concurrent.ExecutorService io = java.util.concurrent.Executors.newSingleThreadExecutor();
+        try {
+            for (int i = 0; i < 500; i++) {
+                SchemaRejectionState state = new SchemaRejectionState();
+                state.beginLease(1, 0, false);
+                java.util.concurrent.CyclicBarrier start = new java.util.concurrent.CyclicBarrier(2);
+                java.util.concurrent.Future rejected = io.submit(() -> {
+                    start.await(5, java.util.concurrent.TimeUnit.SECONDS);
+                    return state.reject(0, 0, error(0));
+                });
+                start.await(5, java.util.concurrent.TimeUnit.SECONDS);
+                LineSenderServerException returned = state.endLease(1, 0);
+                state.beginLease(2, 1, false);
+                assertTrue(rejected.get(5, java.util.concurrent.TimeUnit.SECONDS));
+                if (returned != null) assertEquals(0, returned.getServerError().getRejectedFsn());
+                assertFalse(state.hasOwnedFailure(2));
+                assertNull(state.ownedFailure(2, 1));
+                assertEquals(0, state.sealedRange().lastFsn);
+            }
+        } finally {
+            io.shutdownNow();
+            assertTrue(io.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
+        }
+    }
+
+    private static SenderError error(long fsn) {
+        return new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+                SenderError.Policy.REJECT_AND_CONTINUE, 7, "mismatch", 1,
+                fsn, fsn, "tab", System.nanoTime());
+    }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderErrorDispatcherTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderErrorDispatcherTest.java
index 69725fb7..e27e0dfd 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderErrorDispatcherTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderErrorDispatcherTest.java
@@ -37,6 +37,45 @@
 
 public class SenderErrorDispatcherTest {
 
+    @Test
+    public void testSchemaCapacityIncludesExecutingCallbackAndSurvivesOverflow() throws Exception {
+        CountDownLatch entered = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        CountDownLatch delivered = new CountDownLatch(256);
+        AtomicInteger schemaCalls = new AtomicInteger();
+        try (SenderErrorDispatcher dispatcher = new SenderErrorDispatcher(error -> {
+            if (error.getAppliedPolicy() == SenderError.Policy.REJECT_AND_CONTINUE) {
+                if (schemaCalls.getAndIncrement() == 0) {
+                    entered.countDown();
+                    try {
+                        release.await();
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                }
+                delivered.countDown();
+            }
+        }, 2)) {
+            try {
+                Assert.assertTrue(dispatcher.tryOfferSchema(buildError(0).withRejectionSpan(0, 0)));
+                Assert.assertTrue(entered.await(5, TimeUnit.SECONDS));
+                for (int i = 1; i < 256; i++) {
+                    Assert.assertTrue(dispatcher.tryOfferSchema(buildError(i).withRejectionSpan(i, i)));
+                }
+                Assert.assertEquals(256, dispatcher.getPendingSchemaNotifications());
+                Assert.assertFalse(dispatcher.tryOfferSchema(buildError(256).withRejectionSpan(256, 256)));
+                for (int i = 0; i < 100; i++) {
+                    dispatcher.offer(buildError(i));
+                }
+                Assert.assertEquals(256, dispatcher.getPendingSchemaNotifications());
+            } finally {
+                release.countDown();
+            }
+            Assert.assertTrue(delivered.await(5, TimeUnit.SECONDS));
+            Assert.assertEquals(256, schemaCalls.get());
+        }
+    }
+
     @Test
     public void testCloseDrainsRemainingEntries() {
         // After close(), entries already in the queue should still be
diff --git a/core/src/test/java/io/questdb/client/test/impl/SchemaRejectionPoolTest.java b/core/src/test/java/io/questdb/client/test/impl/SchemaRejectionPoolTest.java
new file mode 100644
index 00000000..7c34d7e5
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/impl/SchemaRejectionPoolTest.java
@@ -0,0 +1,380 @@
+/*******************************************************************************
+ * Copyright (c) 2014-2026 QuestDB
+ * Licensed under the Apache License, Version 2.0.
+ ******************************************************************************/
+
+package io.questdb.client.test.impl;
+
+import io.questdb.client.QuestDB;
+import io.questdb.client.LineSenderServerException;
+import io.questdb.client.Sender;
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.test.cutlass.qwp.client.QwpWireTestUtils;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class SchemaRejectionPoolTest {
+    @Rule
+    public final TemporaryFolder temp = TemporaryFolder.builder().assureDeletion().build();
+
+    @Test
+    public void testNoAckBorrowsRetainBoundedSchemaHistory() throws Exception {
+        assertNoAckBorrowHistory(SenderError.Policy.REJECT_AND_CONTINUE, false);
+        assertNoAckBorrowHistory(SenderError.Policy.REJECT_AND_CONTINUE, true);
+    }
+
+    @Test
+    public void testTerminalPolicyDoesNotAllocateSchemaHistory() throws Exception {
+        assertNoAckBorrowHistory(SenderError.Policy.TERMINAL, false);
+        assertNoAckBorrowHistory(SenderError.Policy.TERMINAL, true);
+    }
+
+    private void assertNoAckBorrowHistory(SenderError.Policy policy, boolean transactional) throws Exception {
+        CountDownLatch received = new CountDownLatch(1);
+        try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+            @Override
+            public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+                received.countDown(); // Deliberately never ACK.
+            }
+        })) {
+            server.start();
+            Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+            // Exercise both memory queues and disk-backed outage buffering.
+            String storage = transactional ? "sf_dir=" + temp.newFolder().getAbsolutePath()
+                    + ";sf_durability=periodic;sf_sync_interval_millis=1000;" : "";
+            try (QuestDB db = QuestDB.builder()
+                    .fromConfig("ws::addr=localhost:" + server.getPort()
+                            + ";close_flush_timeout_millis=0;auto_flush_rows=1;auto_flush_bytes=off;transaction="
+                            + (transactional ? "on" : "off") + ";" + storage)
+                    .senderPoolSize(1).queryPoolMin(0).queryPoolMax(1)
+                    .schemaMismatchPolicy(policy).dlqEnabled(false).build()) {
+                Object delegate = null;
+                for (int i = 0; i < 20_000; i++) {
+                    try (Sender sender = db.borrowSender()) {
+                        Object borrowedDelegate = field(field(sender, "slot"), "delegate");
+                        if (delegate == null) {
+                            delegate = borrowedDelegate;
+                        } else {
+                            Assert.assertSame("all borrows must reuse the same slot", delegate, borrowedDelegate);
+                        }
+                        sender.table("unacked").longColumn("value", i).atNow();
+                    }
+                }
+                Assert.assertTrue(received.await(5, TimeUnit.SECONDS));
+                CursorSendEngine engine = (CursorSendEngine) field(delegate, "cursorEngine");
+                Assert.assertEquals(-1, engine.ackedFsn());
+                Assert.assertTrue("every borrow must publish data", engine.publishedFsn() >= 19_999);
+                Object state = field(delegate, "schemaRejectionState");
+                if (policy == SenderError.Policy.TERMINAL) {
+                    Assert.assertNull(state);
+                } else {
+                    Assert.assertNotNull(field(state, "current"));
+                    Assert.assertNull(field(state, "pending"));
+                }
+            }
+        }
+    }
+
+    private static Object field(Object object, String name) throws Exception {
+        Field field = object.getClass().getDeclaredField(name);
+        field.setAccessible(true);
+        return field.get(object);
+    }
+
+    @Test
+    public void testLazyPoolValidatesDestinationBeforeFirstBorrow() throws Exception {
+        String file = temp.newFile("not-a-directory").getAbsolutePath();
+        try (QuestDB ignored = QuestDB.builder().fromConfig("ws::addr=localhost:1;")
+                .senderPoolMin(0).senderPoolMax(1).queryPoolMin(0).queryPoolMax(1)
+                .dlqDirectory(file).build()) {
+            Assert.fail("build must reject a destination that cannot hold archives");
+        } catch (io.questdb.client.cutlass.line.LineSenderException expected) {
+            Assert.assertTrue(expected.getMessage().contains("schema preservation destination"));
+        }
+    }
+
+    @Test
+    public void testDiskQueuePreservesBeforeRetirementAndContinuation() throws Exception {
+        CountDownLatch reported = new CountDownLatch(1);
+        AtomicReference rejection = new AtomicReference<>();
+        AtomicReference firstConnection = new AtomicReference<>();
+        Map sequences = new ConcurrentHashMap<>();
+        try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+            @Override
+            public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+                long sequence = sequences.merge(client, 1L, Long::sum) - 1;
+                try {
+                    if (firstConnection.compareAndSet(null, client)) {
+                        client.sendBinary(QwpWireTestUtils.buildNack(sequence, WebSocketResponse.STATUS_SCHEMA_MISMATCH));
+                    } else if (firstConnection.get() != client) {
+                        client.sendBinary(QwpWireTestUtils.buildAck(sequence));
+                    }
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        })) {
+            server.start();
+            Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+            String sfDir = temp.newFolder("sf").getAbsolutePath();
+            try (QuestDB db = QuestDB.builder()
+                    .fromConfig("ws::addr=localhost:" + server.getPort() + ";sf_dir=" + sfDir
+                            + ";close_flush_timeout_millis=0;")
+                    .senderPoolSize(1).queryPoolMin(0).queryPoolMax(1)
+                    .errorHandler(error -> { rejection.set(error); reported.countDown(); }).build()) {
+                Sender failed = db.borrowSender();
+                failed.table("bad").stringColumn("value", "wrong").atNow();
+                long rejectedFsn = failed.flushAndGetSequence();
+                Assert.assertTrue(reported.await(10, TimeUnit.SECONDS));
+                try {
+                    failed.awaitAckedFsn(rejectedFsn, 0);
+                    Assert.fail("owning handle must fail after preserved rejection");
+                } catch (LineSenderServerException expected) {
+                    // Mark the lease-local failure observed so close only returns the slot.
+                }
+                failed.close();
+                SenderError error = rejection.get();
+                Assert.assertEquals(SenderError.Policy.REJECT_AND_CONTINUE, error.getAppliedPolicy());
+                Assert.assertNotNull(error.getRejectedPath());
+                Assert.assertTrue(Files.isDirectory(java.nio.file.Paths.get(error.getRejectedPath())));
+                try (Sender healthy = db.borrowSender()) {
+                    healthy.table("good").longColumn("value", 42).atNow();
+                    long target = healthy.flushAndGetSequence();
+                    Assert.assertTrue(healthy.awaitAckedFsn(target, 10_000));
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testObservedFailedHandleCloseReturnsSlot() throws Exception {
+        CountDownLatch rejected = new CountDownLatch(1);
+        AtomicReference firstConnection = new AtomicReference<>();
+        Map sequences = new ConcurrentHashMap<>();
+        try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+            @Override
+            public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+                long sequence = sequences.merge(client, 1L, Long::sum) - 1;
+                try {
+                    if (firstConnection.compareAndSet(null, client)) {
+                        client.sendBinary(QwpWireTestUtils.buildNack(sequence, WebSocketResponse.STATUS_SCHEMA_MISMATCH));
+                        rejected.countDown();
+                    } else if (firstConnection.get() != client) {
+                        client.sendBinary(QwpWireTestUtils.buildAck(sequence));
+                    }
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        })) {
+            server.start();
+            Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+            try (QuestDB db = newPool(server)) {
+                Sender failed = db.borrowSender();
+                failed.table("bad").stringColumn("value", "wrong").atNow();
+                long rejectedFsn = failed.flushAndGetSequence();
+                Assert.assertTrue(rejected.await(5, TimeUnit.SECONDS));
+                try {
+                    failed.awaitAckedFsn(rejectedFsn, 10_000);
+                    Assert.fail("owning handle must observe schema rejection");
+                } catch (LineSenderServerException expected) {
+                    Assert.assertEquals(rejectedFsn, expected.getServerError().getRejectedFsn());
+                }
+                failed.close();
+
+                try (Sender healthy = db.borrowSender()) {
+                    healthy.table("good").longColumn("value", 42).atNow();
+                    long target = healthy.flushAndGetSequence();
+                    Assert.assertTrue("returned slot must remain usable", healthy.awaitAckedFsn(target, 10_000));
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testTransactionalDeferredRejectionRetiresThroughPublishedTail() throws Exception {
+        CountDownLatch firstReceived = new CountDownLatch(1);
+        CountDownLatch rejectNow = new CountDownLatch(1);
+        AtomicReference firstConnection = new AtomicReference<>();
+        Map sequences = new ConcurrentHashMap<>();
+        try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+            @Override
+            public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+                long sequence = sequences.merge(client, 1L, Long::sum) - 1;
+                try {
+                    if (firstConnection.compareAndSet(null, client)) {
+                        firstReceived.countDown();
+                        if (!rejectNow.await(5, TimeUnit.SECONDS)) {
+                            throw new AssertionError("test did not release NACK");
+                        }
+                        client.sendBinary(QwpWireTestUtils.buildNack(sequence, WebSocketResponse.STATUS_SCHEMA_MISMATCH));
+                    } else if (firstConnection.get() != client) {
+                        client.sendBinary(QwpWireTestUtils.buildAck(sequence));
+                    }
+                } catch (IOException | InterruptedException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        })) {
+            server.start();
+            Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+            try (QuestDB db = QuestDB.builder()
+                    .fromConfig("ws::addr=localhost:" + server.getPort()
+                            + ";auto_flush_rows=1;auto_flush_bytes=off;transaction=on;close_flush_timeout_millis=0;")
+                    .senderPoolSize(1).queryPoolMin(0).queryPoolMax(1)
+                    .schemaMismatchPolicy(SenderError.Policy.REJECT_AND_CONTINUE).dlqEnabled(false).build()) {
+                Sender failed = db.borrowSender();
+                failed.table("bad").longColumn("value", 1).atNow();
+                Assert.assertTrue(firstReceived.await(5, TimeUnit.SECONDS));
+                failed.table("bad").longColumn("value", 2).atNow();
+                rejectNow.countDown();
+                try {
+                    failed.awaitAckedFsn(1, 10_000);
+                    Assert.fail("transaction owner must fail");
+                } catch (LineSenderServerException expected) {
+                    Assert.assertEquals(0, expected.getServerError().getFromFsn());
+                    Assert.assertEquals(1, expected.getServerError().getToFsn());
+                }
+                failed.close();
+                try (Sender healthy = db.borrowSender()) {
+                    healthy.table("good").longColumn("value", 3).atNow();
+                }
+            } finally {
+                rejectNow.countDown();
+            }
+        }
+    }
+
+    @Test
+    public void testMalformedSchemaSequenceFailsClosed() throws Exception {
+        CountDownLatch rejected = new CountDownLatch(1);
+        try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+            @Override
+            public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+                try {
+                    client.sendBinary(QwpWireTestUtils.buildNack(99, WebSocketResponse.STATUS_SCHEMA_MISMATCH));
+                    rejected.countDown();
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        })) {
+            server.start();
+            Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+            Sender sender = Sender.builder("ws::addr=localhost:" + server.getPort()
+                            + ";close_flush_timeout_millis=0;")
+                    .schemaMismatchPolicy(SenderError.Policy.REJECT_AND_CONTINUE)
+                    .dlqEnabled(false)
+                    .build();
+            try {
+                sender.table("bad").longColumn("value", 1).atNow();
+                long target = sender.flushAndGetSequence();
+                Assert.assertTrue(rejected.await(5, TimeUnit.SECONDS));
+                try {
+                    sender.awaitAckedFsn(target, 10_000);
+                    Assert.fail("out-of-range NACK must fail closed");
+                } catch (LineSenderServerException expected) {
+                    Assert.assertEquals(SenderError.Policy.TERMINAL,
+                            expected.getServerError().getAppliedPolicy());
+                }
+            } finally {
+                try {
+                    sender.close();
+                } catch (LineSenderServerException ignored) {
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testRejectionAfterReturnDoesNotFailNextBorrow() throws Exception {
+        assertRejectionAfterReturnDoesNotFailNextBorrow(false);
+        assertRejectionAfterReturnDoesNotFailNextBorrow(true);
+    }
+
+    private static void assertRejectionAfterReturnDoesNotFailNextBorrow(boolean transactional) throws Exception {
+        CountDownLatch firstReceived = new CountDownLatch(1);
+        CountDownLatch rejectNow = new CountDownLatch(1);
+        CountDownLatch reported = new CountDownLatch(1);
+        AtomicReference rejection = new AtomicReference<>();
+        AtomicReference rejectedConnection = new AtomicReference<>();
+        Map sequences = new ConcurrentHashMap<>();
+        try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+            @Override
+            public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+                long sequence = sequences.merge(client, 1L, Long::sum) - 1;
+                try {
+                    if (rejectedConnection.compareAndSet(null, client)) {
+                        firstReceived.countDown();
+                        if (!rejectNow.await(5, TimeUnit.SECONDS)) {
+                            throw new AssertionError("test did not release NACK");
+                        }
+                        client.sendBinary(QwpWireTestUtils.buildNack(sequence, WebSocketResponse.STATUS_SCHEMA_MISMATCH));
+                    } else if (rejectedConnection.get() != client) {
+                        client.sendBinary(QwpWireTestUtils.buildAck(sequence));
+                    }
+                } catch (IOException | InterruptedException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        })) {
+            server.start();
+            Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+            try (QuestDB db = QuestDB.builder()
+                    .fromConfig("ws::addr=localhost:" + server.getPort()
+                            + ";close_flush_timeout_millis=0;auto_flush_rows=1;auto_flush_bytes=off;transaction="
+                            + (transactional ? "on" : "off") + ";")
+                    .senderPoolSize(1).queryPoolMin(0).queryPoolMax(1)
+                    .schemaMismatchPolicy(SenderError.Policy.REJECT_AND_CONTINUE).dlqEnabled(false)
+                    .errorHandler(error -> { rejection.set(error); reported.countDown(); }).build()) {
+                try (Sender a = db.borrowSender()) {
+                    a.table("bad").stringColumn("value", "wrong").atNow();
+                    a.flush();
+                    Assert.assertTrue(firstReceived.await(5, TimeUnit.SECONDS));
+                }
+                // Let several borrowers publish before the old rejection arrives.
+                for (int i = 0; i < 20; i++) {
+                    try (Sender intervening = db.borrowSender()) {
+                        intervening.table("good").longColumn("value", i).atNow();
+                    }
+                }
+                try (Sender b = db.borrowSender()) {
+                    b.table("good").longColumn("value", 42).atNow();
+                    b.flush();
+                    long target = ((CursorSendEngine) field(field(field(b, "slot"), "delegate"), "cursorEngine"))
+                            .publishedFsn();
+                    rejectNow.countDown();
+                    Assert.assertTrue("later borrow must drain past old rejection", b.awaitAckedFsn(target, 10_000));
+                    Assert.assertTrue(reported.await(5, TimeUnit.SECONDS));
+                    Assert.assertEquals(SenderError.Policy.REJECT_AND_CONTINUE, rejection.get().getAppliedPolicy());
+                    Assert.assertEquals(0, rejection.get().getRejectedFsn());
+                    Assert.assertEquals(transactional ? 1 : 0, rejection.get().getToFsn());
+                }
+            } finally {
+                rejectNow.countDown();
+            }
+        }
+    }
+
+    private static QuestDB newPool(TestWebSocketServer server) {
+        return QuestDB.builder()
+                .fromConfig("ws::addr=localhost:" + server.getPort() + ";close_flush_timeout_millis=0;")
+                .senderPoolSize(1).queryPoolMin(0).queryPoolMax(1)
+                .schemaMismatchPolicy(SenderError.Policy.REJECT_AND_CONTINUE).dlqEnabled(false)
+                .build();
+    }
+}
diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java
index 24e60e49..83ded197 100644
--- a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java
+++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java
@@ -2615,8 +2615,8 @@ public void testConcurrentBorrowReturnStress() throws Exception {
     @Test
     public void testConcurrentFirstBorrowsWithMinZeroRaceOnSfDir() throws Exception {
         // C2 regression: senderPoolMin(0) means no single-threaded pre-warm,
-        // so the shared parent sf_dir is NOT created at construction (the
-        // constructor probe only parses the config). The first concurrent
+        // so no slot is created at construction. Remove the empty directory
+        // left by the eager DLQ destination probe to exercise concurrent
         // borrows then race into build() -> Files.mkdir(sfDir) outside the
         // pool lock. Pre-fix, the mkdir loser got a non-zero rc (EEXIST) and
         // its borrow() threw "could not create sf_dir" on a perfectly healthy
@@ -2629,9 +2629,10 @@ public void testConcurrentFirstBorrowsWithMinZeroRaceOnSfDir() throws Exception
                 Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
 
                 String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";";
-                // minSize=0 -> no pre-warm -> sf_dir absent until first borrow.
+                // minSize=0 -> no slots; the destination probe creates only the root.
                 try (SenderPool pool = new SenderPool(config, 0, 4, 10_000, Long.MAX_VALUE, Long.MAX_VALUE)) {
-                    Assert.assertFalse("sf_dir must not exist before the first borrow",
+                    java.nio.file.Files.delete(java.nio.file.Paths.get(sfDir));
+                    Assert.assertFalse("test must restore the first-directory creation race",
                             Files.exists(sfDir));
 
                     final int threads = 4;
diff --git a/design/schema-mismatch-terminal-resolution.md b/design/schema-mismatch-terminal-resolution.md
new file mode 100644
index 00000000..7a20e4fe
--- /dev/null
+++ b/design/schema-mismatch-terminal-resolution.md
@@ -0,0 +1,174 @@
+# Schema rejection: preserve, retire, continue
+
+Status: uncommitted simplification experiment based on `26e3c3a` (PR #94).
+Updated: 2026-09-09. No server or wire-format changes.
+
+## Goal and scope
+
+A schema-rejected QWP batch must not permanently disable a pooled sender slot.
+Fail the active borrow which published the rejected data, preserve the affected
+queued frames when configured, retire that range, and continue independent
+queued work. Returning a failed borrow allows the next borrower to use the slot.
+A standalone handle remains failed until closed and rebuilt.
+
+This experiment preserves the PR's rejection-range rules, default policy,
+notification capacity, and preserve-before-retirement ordering. It simplifies
+borrow ownership and replaces general archive discovery with an exact lookup for
+a recovered orphan range. It does not implement whole-group rejection for
+ordinary split flushes or set aside entire queues.
+
+## Behavioral changes from PR #94
+
+| Area | Experiment |
+|---|---|
+| Completed borrow history | Keep only the current borrow and one pending rejection. No deque, coalescing, pruning, or per-ACK ownership maintenance. |
+| Transaction mode | Fixed for a sender's borrow lifecycle, as configured by the builders. Switching it between borrows is rejected. |
+| Archive identity | Deterministic from the source namespace, source segment generation token, and exact FSN range; no durable `.slot-epoch`. |
+| Restart | Recover the live SFA queue, then check only the deterministic archive path for an exact recovered orphan range. No directory scan. |
+| Callback reconstruction | A structurally valid completed copy for that still-live orphan range is queued before retirement. Other archives do not reconstruct callbacks. |
+| Duplicate copies | A retry or restart for the same live identity and range reuses the completed copy. |
+| Metadata | `rejection.properties` replaces the custom CRC-protected `rejection-meta.bin` format. Payload segments and dictionary retain their existing checked formats. |
+| Interrupted copies | Recovery or preservation removes the deterministic staging tree for that exact live range. Unrelated and legacy staging trees remain untouched. |
+
+There is no public archive reader or replay-copy API in the ingestion client.
+Copy an archive directory to a separate working directory and use the existing
+SFA recovery reader. Both old and new copies retain the same SFA payload formats;
+the metadata filenames differ. Old PR archives and `.slot-epoch` files are left
+untouched and cannot block startup. The internal exact-path lookup reads the
+properties only to validate and reconstruct the matching orphan report; there is
+no archive index or general notification log.
+
+## Rejection boundaries
+
+An FSN is a local frame sequence number. A commit-bearing frame has
+`FLAG_DEFER_COMMIT` clear. The group starts after the last commit-bearing frame
+below the rejected FSN, bounded below by the unresolved queue floor.
+
+- Ordinary flush: retire the deferred prefix through the rejected frame.
+  Published successors remain eligible for replay and may produce a partial flush.
+- Transactional sender: retire through the first commit-bearing frame at or after
+  the rejection. If no closer has been published, the producer must seal its
+  published tail when it observes the failure or returns the borrow.
+- Recovered data: transaction mode is unknown, so retire conservatively through
+  the recovered group's closer or recovered open tail. Never use a new
+  producer's closer to determine an old group's end.
+
+Retirement does not imply server rollback. Schema changes and previously forced
+commits can survive rejection, so manually replaying a copy can duplicate rows.
+
+## Ownership without history
+
+`SchemaRejectionState` holds the current borrow and one pending rejection.
+The current record contains generation, first FSN, transaction mode, active/end
+state, and its first immutable failure. A pending rejection may retain the record
+of a returned failed borrow until its range retires; it does not retain any
+other historical borrows.
+
+The NACK must first be validated against frames actually sent on the current
+connection. An active borrow owns it only if the rejected FSN is at or above
+that borrow's first FSN. Older and recovered data produce asynchronous reports
+without failing the current borrower. A returned borrow cannot receive another
+producer-side exception, even if no new borrower has arrived yet.
+
+Begin, return, rejection installation, and open-tail sealing synchronize on the
+same state object. `failedGeneration` and `stopFsn` remain volatile observations
+for the producer and I/O loop. An open rejected range is sealed before returning
+its producer ownership. Successful normal pool return flushes a commit boundary;
+an unclosed transactional return without a covering rejection is refused.
+The check tolerates ACK/trim racing the frame lookup.
+
+Historical transaction ends are recoverable from queued commit flags. For an
+old live rejection, the scan stops before the active borrow, or at the most
+recent return when the slot is idle. Recovered ranges use the engine's original
+recovery boundary. Consequently no scan borrows a new producer's transaction
+closer. This relies on normal returns closing transactions and exceptional open
+returns sealing their rejection before reuse; arbitrary unclosed returns are
+not a supported state transition.
+
+## Retirement and preservation
+
+The I/O loop performs the following sequence:
+
+1. Validate the NACK and identify its range. Ambiguous or out-of-range responses
+   never become local retirement targets.
+2. Latch the owning borrow's failure and reconnect. Replay independent lower
+   frames until they have their configured ACKs, stopping before the rejection.
+3. Wait for the range to be sealed. Preserve dictionary deltas from every
+   skipped frame, including unsent transaction successors, for later replay.
+4. When preservation is configured, copy the range into its deterministic staging
+   directory using existing segment, manifest, watermark, and dictionary formats.
+   Clear the final copied frame's defer flag so the copy can replay independently.
+5. Write diagnostic properties, sync the files and directory, rename the staging
+   directory, and sync its parent. Only then is preservation complete.
+6. Retain the final notification in the separate 256-entry schema queue. A full
+   queue pauses retirement; callback completion otherwise does not gate it.
+7. Advance the existing resolved watermark through the range, then resume from
+   the next FSN on a correctly reanchored connection.
+
+`RejectedMiniSlotArchive` writes copies and can read the one deterministic path
+for an exact live range. There is no directory scan, separate preserver wrapper,
+or slot-epoch lifecycle. The writer is used by one I/O thread. After rename
+succeeds, it retains the result until the parent-directory sync succeeds, so a
+transient barrier failure retries without creating repeated copies. A later
+retry or restart reuses a completed valid copy and removes the exact crashed
+staging tree. Earlier write failures retain source frames and retry with the send
+loop's existing bounded backoff.
+
+A damaged completed archive cannot block live queue startup. For an exact
+recovered orphan range, the internal lookup validates the properties, segment,
+manifest, watermark, and optional dictionary. A missing, malformed, unreadable,
+or mismatched copy produces no report and the live orphan still retires. For
+manual replay, always use a working copy because queue cleanup can remove drained
+files and existing corruption handling can quarantine damaged working data.
+
+## Crash and shutdown boundaries
+
+- Before preservation completes: source frames remain queued. Closed groups can
+  replay and be rejected again. Recovery may discard an unclosed orphan tail
+  without another NACK; there is no universal callback or archive guarantee.
+- After archive publication but before durable retirement: the copy survives;
+  restart reuses it. If the exact range is a recovered orphan tail, its report is
+  retained before retirement; a closed range can replay and be rejected again.
+- After retirement but before callback delivery: a crash or bounded dispatcher
+  shutdown can lose the callback. With no still-live range, startup does not
+  reconstruct it from the archive.
+- A blocked filesystem call can exceed the close budget. Existing delegated
+  I/O-thread cleanup retains the source engine and lock until the worker exits.
+
+With disk buffering, preservation defaults to the slot's `rejected/` directory.
+An explicit DLQ base also supports memory queues. Memory queues without a
+configured destination, or preservation disabled explicitly, retire without
+copies. Completed archives have no automatic retention policy.
+
+`getAckedFsn`, `awaitAckedFsn`, and `drain` retain PR #94's resolved-progress
+semantics: locally retired data counts as progress, not server acceptance.
+The owning handle still throws for its rejected publication. Other error
+categories retain their existing policies, and `TERMINAL` remains selectable.
+
+## Validation and remaining limits
+
+Focused coverage includes delayed NACKs after many ordinary/transactional borrows,
+return-versus-NACK races, failed open-tail sealing, recovered group boundaries,
+successor dictionary continuity, source retention during write failure,
+parent-sync retry without recopying, notification saturation, blocked-copy close,
+offline archive replay, deterministic archive reuse and staging cleanup, recovered
+orphan notification reconstruction, and repeated startup with damaged archived
+copies.
+
+Validation on OpenJDK 25:
+
+- Before the final segment-token substitution, the full core suite ran 3,507
+  tests with zero failures or errors and seven skipped. A broader schema-focused
+  run at that stage passed 66 tests.
+- After the segment-token change, focused `MmapSegmentTest`,
+  `RejectedMiniSlotArchiveTest`, and `RejectedArchiveRecoveryTest` coverage passed
+  39 tests.
+- Examples compiled successfully. `git diff --check` passed.
+
+The current production diff removes 507 lines relative to `26e3c3a`. The archive
+class is 332 lines, down from 511, with the 73-line preserver wrapper and 116-line
+slot-epoch class deleted. The stronger return invariant and explicit
+archive behavior changes still require design review before adoption. No measured
+throughput improvement is claimed. The real server rollback behavior and minimum
+server version remain the PR's existing assumptions; local protocol tests use
+the repository's test server.
diff --git a/examples/POOLED_SF_POISON_DEMO.md b/examples/POOLED_SF_POISON_DEMO.md
new file mode 100644
index 00000000..8d5e5162
--- /dev/null
+++ b/examples/POOLED_SF_POISON_DEMO.md
@@ -0,0 +1,49 @@
+# Pooled store-and-forward schema-rejection demo
+
+This demo shows the default `REJECT_AND_CONTINUE` behavior for a one-slot
+pooled WebSocket sender. A borrow that publishes a schema-mismatched row fails,
+but returning that borrow releases the slot. A later borrow from the same pool
+then publishes a valid row successfully.
+
+Before retiring the rejected range, a disk-backed sender preserves its raw QWP
+frames and dictionary state in a completed archive. The owning borrow receives
+a synchronous `LineSenderServerException` identifying the rejected FSN range.
+The configured asynchronous error handler receives a `SenderError` containing
+the completed archive path. The demo checks both signals and verifies that the
+valid row reaches QuestDB.
+
+It uses a real QuestDB server with QWP available at `localhost:9000`. Start an
+ephemeral test server:
+
+```bash
+docker run --rm -d --name qdb-java-sfa-poison-demo \
+  -p 9000:9000 questdb/questdb:nightly
+```
+
+The demo drops and recreates only the table `java_sfa_poison_demo`; do not point
+it at a production database.
+
+Build the current client and choose a new temporary directory. A source build
+needs CMake, NASM, a C/C++ compiler, and the checked-out zstd submodule:
+
+```bash
+git submodule update --init --recursive
+cmake -DCMAKE_BUILD_TYPE=Release -B core/cmake-build-release -S core
+cmake --build core/cmake-build-release --config Release
+mvn -pl core -Dmaven.test.skip=true install
+mvn -f examples/pom.xml -DskipTests compile
+export QDB_POISON_DEMO_SF_DIR="$(mktemp -d)"
+```
+
+Run the demo once with that empty directory:
+
+```bash
+mvn -f examples/pom.xml \
+  org.codehaus.mojo:exec-maven-plugin:3.5.0:java \
+  -Dexec.mainClass=com.example.sender.WsPooledSchemaPoisonDemo \
+  -Dexec.args="${QDB_POISON_DEMO_SF_DIR}"
+```
+
+The output names the failed borrow's FSN range, the preserved-copy directory,
+and the successful valid row. Pass `host:port` as the final argument to use a
+server other than `localhost:9000`.
diff --git a/examples/src/main/java/com/example/sender/WsPooledSchemaPoisonDemo.java b/examples/src/main/java/com/example/sender/WsPooledSchemaPoisonDemo.java
new file mode 100644
index 00000000..4bc2dd14
--- /dev/null
+++ b/examples/src/main/java/com/example/sender/WsPooledSchemaPoisonDemo.java
@@ -0,0 +1,256 @@
+package com.example.sender;
+
+import io.questdb.client.LineSenderServerException;
+import io.questdb.client.QuestDB;
+import io.questdb.client.Sender;
+import io.questdb.client.SenderError;
+import io.questdb.client.SenderErrorHandler;
+import io.questdb.client.cutlass.qwp.client.QwpColumnBatch;
+import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler;
+import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Shows that a schema rejection fails one pooled-sender borrow without
+ * poisoning the underlying store-and-forward slot.
+ *
+ * See {@code examples/POOLED_SF_POISON_DEMO.md} for setup and invocation.
+ */
+public final class WsPooledSchemaPoisonDemo {
+
+    private static final String SENDER_ID = "java-schema-poison-demo";
+    private static final String TABLE = "java_sfa_poison_demo";
+    private static final long WAIT_MILLIS = 10_000;
+
+    private WsPooledSchemaPoisonDemo() {
+    }
+
+    public static void main(String[] args) throws Exception {
+        if (args.length < 1 || args.length > 2) {
+            printUsageAndExit();
+        }
+
+        Path sfDir = Paths.get(args[0]).toAbsolutePath().normalize();
+        String address = args.length == 2 ? args[1] : "localhost:9000";
+        rejectConfigSeparators(sfDir, address);
+
+        Path slotDir = sfDir.resolve(SENDER_ID + "-0");
+        if (Files.exists(slotDir)) {
+            throw new IllegalStateException(
+                    "The demo slot already exists: " + slotDir + ". Use a new empty directory.");
+        }
+
+        resetTable(address);
+        runDemo(address, sfDir);
+    }
+
+    private static void runDemo(String address, Path sfDir) throws Exception {
+        CountDownLatch reportReady = new CountDownLatch(1);
+        AtomicReference report = new AtomicReference<>();
+
+        try (QuestDB db = createOneSlotPool(address, sfDir, error -> {
+            if (error.getCategory() == SenderError.Category.SCHEMA_MISMATCH) {
+                report.compareAndSet(null, error);
+                reportReady.countDown();
+            }
+        })) {
+            System.out.println("First borrow: sending a STRING into the LONG column.");
+            LineSenderServerException rejection = sendRejectedRow(db);
+            requireSchemaMismatch(rejection);
+            printRejection("Owning borrow failed", rejection.getServerError());
+
+            if (!reportReady.await(WAIT_MILLIS, TimeUnit.MILLISECONDS)) {
+                throw new IllegalStateException("Timed out waiting for the schema-rejection report");
+            }
+            SenderError preserved = report.get();
+            if (preserved == null || preserved.getRejectedPath() == null
+                    || preserved.getAppliedPolicy() != SenderError.Policy.REJECT_AND_CONTINUE
+                    || preserved.getFromFsn() != rejection.getServerError().getFromFsn()
+                    || preserved.getToFsn() != rejection.getServerError().getToFsn()
+                    || !Files.isDirectory(Paths.get(preserved.getRejectedPath()))) {
+                throw new IllegalStateException("The rejected range was not preserved", rejection);
+            }
+            printRejection("Asynchronous preserved-copy report", preserved);
+            System.out.println("Rejected bytes were preserved at " + preserved.getRejectedPath());
+
+            System.out.println("Second borrow: sending a valid LONG row through the same one-slot pool.");
+            try (Sender healthy = db.borrowSender()) {
+                healthy.table(TABLE)
+                        .longColumn("value", 42)
+                        .symbol("marker", "good-after-rejection")
+                        .atNow();
+                long fsn = healthy.flushAndGetSequence();
+                if (!healthy.awaitAckedFsn(fsn, WAIT_MILLIS)) {
+                    throw new IllegalStateException("Timed out waiting for the valid row [fsn=" + fsn + ']');
+                }
+            }
+        }
+
+        long delivered = awaitGoodRows(address);
+        if (delivered != 1) {
+            throw new IllegalStateException("Expected one valid row [count=" + delivered + ']');
+        }
+        System.out.println("SUCCESS: returning the failed borrow kept the slot usable; the valid row was delivered.");
+    }
+
+    private static QuestDB createOneSlotPool(
+            String address,
+            Path sfDir,
+            SenderErrorHandler errorHandler
+    ) {
+        String config = "ws::addr=" + address + ';'
+                + "sf_dir=" + sfDir + ';'
+                + "sender_id=" + SENDER_ID + ';'
+                + "sf_durability=periodic;"
+                + "sf_sync_interval_millis=1;"
+                + "close_flush_timeout_millis=0;";
+
+        return QuestDB.builder()
+                .fromConfig(config)
+                .senderPoolSize(1)
+                .queryPoolMin(0)
+                .queryPoolMax(1)
+                .acquireTimeoutMillis(3_000)
+                .errorHandler(errorHandler)
+                .build();
+    }
+
+    private static long awaitGoodRows(String address) throws InterruptedException {
+        long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(WAIT_MILLIS);
+        long count;
+        do {
+            count = countGoodRows(address);
+            if (count > 0) {
+                return count;
+            }
+            Thread.sleep(50);
+        } while (System.nanoTime() < deadline);
+        return count;
+    }
+
+    private static LineSenderServerException sendRejectedRow(QuestDB db) {
+        Sender sender = db.borrowSender();
+        LineSenderServerException rejection = null;
+        try {
+            sender.table(TABLE)
+                    .stringColumn("value", "not-a-long")
+                    .symbol("marker", "bad")
+                    .atNow();
+            long fsn = sender.flushAndGetSequence();
+            if (!sender.awaitAckedFsn(fsn, WAIT_MILLIS)) {
+                throw new IllegalStateException("Timed out waiting for schema rejection [fsn=" + fsn + ']');
+            }
+            throw new IllegalStateException("The bad row was unexpectedly accepted");
+        } catch (LineSenderServerException expected) {
+            rejection = expected;
+        } finally {
+            try {
+                sender.close();
+            } catch (LineSenderServerException closeRejection) {
+                if (rejection == null) {
+                    rejection = closeRejection;
+                }
+            }
+        }
+        return rejection;
+    }
+
+    private static void resetTable(String address) {
+        execute(address, "DROP TABLE IF EXISTS " + TABLE, new NoRowsHandler());
+        execute(address,
+                "CREATE TABLE " + TABLE
+                        + " (value LONG, marker SYMBOL, ts TIMESTAMP)"
+                        + " TIMESTAMP(ts) PARTITION BY DAY WAL",
+                new NoRowsHandler());
+    }
+
+    private static long countGoodRows(String address) {
+        final long[] count = {Long.MIN_VALUE};
+        execute(address,
+                "SELECT count() FROM " + TABLE + " WHERE marker = 'good-after-rejection'",
+                new QwpColumnBatchHandler() {
+                    @Override
+                    public void onBatch(QwpColumnBatch batch) {
+                        if (batch.getRowCount() > 0) {
+                            count[0] = batch.getLongValue(0, 0);
+                        }
+                    }
+
+                    @Override
+                    public void onEnd(long totalRows) {
+                    }
+
+                    @Override
+                    public void onError(byte status, String message) {
+                        throw new IllegalStateException(
+                                String.format("Verification query failed [status=0x%02X, message=%s]",
+                                        status & 0xFF,
+                                        message));
+                    }
+                });
+        if (count[0] == Long.MIN_VALUE) {
+            throw new IllegalStateException("The verification query returned no count");
+        }
+        return count[0];
+    }
+
+    private static void execute(String address, String sql, QwpColumnBatchHandler handler) {
+        try (QwpQueryClient client = QwpQueryClient.fromConfig("ws::addr=" + address + ';')) {
+            client.connect();
+            client.execute(sql, handler);
+        }
+    }
+
+    private static void requireSchemaMismatch(LineSenderServerException rejection) {
+        if (rejection == null
+                || rejection.getServerError().getCategory() != SenderError.Category.SCHEMA_MISMATCH
+                || rejection.getServerError().getAppliedPolicy() != SenderError.Policy.REJECT_AND_CONTINUE) {
+            throw new IllegalStateException("The first borrow did not fail with REJECT_AND_CONTINUE", rejection);
+        }
+    }
+
+    private static void printRejection(String prefix, SenderError error) {
+        System.out.printf(
+                "%s: category=%s policy=%s fsn=[%d..%d] message=%s%n",
+                prefix,
+                error.getCategory(),
+                error.getAppliedPolicy(),
+                error.getFromFsn(),
+                error.getToFsn(),
+                error.getServerMessage());
+    }
+
+    private static void rejectConfigSeparators(Path sfDir, String address) {
+        if (sfDir.toString().indexOf(';') >= 0 || address.indexOf(';') >= 0) {
+            throw new IllegalArgumentException("The address and sf-dir must not contain ';'");
+        }
+    }
+
+    private static void printUsageAndExit() {
+        System.err.println("Usage: WsPooledSchemaPoisonDemo  [host:port]");
+        System.exit(2);
+    }
+
+    private static final class NoRowsHandler implements QwpColumnBatchHandler {
+        @Override
+        public void onBatch(QwpColumnBatch batch) {
+            throw new IllegalStateException("DDL unexpectedly returned rows");
+        }
+
+        @Override
+        public void onEnd(long totalRows) {
+        }
+
+        @Override
+        public void onError(byte status, String message) {
+            throw new IllegalStateException(
+                    String.format("DDL failed [status=0x%02X, message=%s]", status & 0xFF, message));
+        }
+    }
+}