Skip to content

Commit 03afa61

Browse files
bluestreak01claude
andcommitted
feat(ilp): opt-in sf_fsync_on_flush, fix sf_fsync default doc lie
Sender.storeAndForwardFsync's Javadoc claimed the default sf_fsync=off "runs fsync on rotation and on explicit flush()". In practice flush() never called segmentLog.fsync() — the only production fsync paths were per-append (gated on fsync_each_append, the sf_fsync=on path), rotation (rare), and new-segment header creation (rare). With default config a sender that flushes coarsely between rotations was leaving all bytes in the OS page cache; an OS crash would lose them despite the docs implying durability. Two-part fix: 1. Doc honesty (Sender.java): storeAndForwardFsync rewritten to spell out exactly what sf_fsync=off and sf_fsync=on mean. The default leaves bytes between rotations in the page cache — process crashes survive, OS crashes don't. 2. Opt-in fsync-on-flush: New knob storeAndForwardFsyncOnFlush(boolean) on the builder, parsed as sf_fsync_on_flush=on/off in the connect string. When enabled, every flush() (and the implicit flush in close()) routes a fsync request to the I/O thread before returning. Off by default — small-batch + frequent-flush senders pay one disk fsync per call, which is unacceptable for high-rate workloads. The fsync runs on the I/O thread because SegmentLog is single- threaded (the I/O thread owns every read/write/trim/rotate). Calling segmentLog.fsync() from the user thread would race against an in-flight trim() (which may force-rotate the active under per-frame trim) or append() from a concurrent send. The signal pattern is the same one used by ping/pong: user sets fsyncRequested + waits on fsyncComplete; I/O thread observes the flag at the top of its iteration, performs the fsync, publishes outcome via fsyncError + fsyncComplete. Concurrent callers are serialised by fsyncLock so each gets its own round-trip. Tests: - testFlushDoesNotFsyncByDefault (SfIntegrationTest): with fsyncOnFlush=false the FsyncCountingFacade observes ZERO fsyncs during flush() — proves we did not regress the small-batch hot path. - testFlushFsyncsWhenOptedIn (SfIntegrationTest): with fsyncOnFlush=true the same counter observes >= 1 fsync per flush() — proves the wiring is end-to-end. - testSfFsyncOnFlushParses (SfFromConfigTest): connect-string round-trip. - testInvalidSfFsyncOnFlushValueRejected (SfFromConfigTest): bad value rejected with a useful message. - testSfFsyncOnFlushOnTcpRejected (SfFromConfigTest): TCP transport rejects the WebSocket-only knob. Full suite: 1981 tests pass, zero regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e38b4d5 commit 03afa61

5 files changed

Lines changed: 459 additions & 5 deletions

File tree

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,7 @@ public int getTimeout() {
631631
private long sfMaxBytes = PARAMETER_NOT_SET_EXPLICITLY;
632632
private long sfMaxTotalBytes = PARAMETER_NOT_SET_EXPLICITLY;
633633
private boolean sfFsync;
634+
private boolean sfFsyncOnFlush;
634635
private boolean tlsEnabled;
635636
private TlsValidationMode tlsValidationMode;
636637
private char[] trustStorePassword;
@@ -968,7 +969,8 @@ public Sender build() {
968969
wsAuthHeader,
969970
actualMaxSchemasPerConnection,
970971
requestDurableAck,
971-
segmentLog
972+
segmentLog,
973+
sfFsyncOnFlush
972974
);
973975
} catch (Throwable t) {
974976
// If connect failed, the sender's close() ran and would have closed
@@ -1640,9 +1642,18 @@ public LineSenderBuilder storeAndForwardMaxTotalBytes(long maxTotalBytes) {
16401642
/**
16411643
* When enabled, every successful SF append calls {@code fsync} on the
16421644
* active segment file before returning. Trades throughput for the
1643-
* strongest durability guarantee — captured frames survive even an OS
1644-
* crash, not just a process crash. Default: off (fsync runs on rotation
1645-
* and on explicit flush()).
1645+
* strongest durability guarantee — every captured frame survives an OS
1646+
* crash, not just a process crash.
1647+
* <p>
1648+
* Default: off. With {@code sf_fsync=off}, fsync only fires on
1649+
* segment rotation and new-segment header creation; bytes appended to
1650+
* the active segment between rotations live only in the OS page cache
1651+
* and may be lost in an OS crash, kernel panic, or power loss. The
1652+
* JVM going down is survived (the page cache outlives the process).
1653+
* <p>
1654+
* If you flush coarsely (one fsync per flush is acceptable) and want
1655+
* OS-crash survival without paying per-append fsync cost, set
1656+
* {@link #storeAndForwardFsyncOnFlush(boolean)} instead.
16461657
*/
16471658
public LineSenderBuilder storeAndForwardFsync(boolean enabled) {
16481659
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
@@ -1652,6 +1663,32 @@ public LineSenderBuilder storeAndForwardFsync(boolean enabled) {
16521663
return this;
16531664
}
16541665

1666+
/**
1667+
* When enabled, every successful {@code Sender.flush()} (and the
1668+
* implicit flush during {@code close()}) calls {@code fsync} on the
1669+
* SF active segment file before returning. Trades flush latency
1670+
* (one fsync per flush) for OS-crash survival of every byte that
1671+
* the user explicitly flushed.
1672+
* <p>
1673+
* Off by default. Use this when batches are large or flushes are
1674+
* coarse and you want OS-crash durability without paying the
1675+
* per-append fsync cost of {@link #storeAndForwardFsync(boolean)}.
1676+
* Avoid it when batches are small and flushes are frequent — every
1677+
* flush blocks on a disk fsync, which is typically the slowest
1678+
* operation in the SF write path.
1679+
* <p>
1680+
* Combining {@code sf_fsync=on} and {@code sf_fsync_on_flush=on}
1681+
* is allowed but redundant: per-append fsync already covers every
1682+
* byte before flush returns.
1683+
*/
1684+
public LineSenderBuilder storeAndForwardFsyncOnFlush(boolean enabled) {
1685+
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
1686+
throw new LineSenderException("store_and_forward is only supported for WebSocket transport");
1687+
}
1688+
this.sfFsyncOnFlush = enabled;
1689+
return this;
1690+
}
1691+
16551692
/**
16561693
* Configures the maximum time the Sender will spend retrying upon receiving a recoverable error from the server.
16571694
* <br>
@@ -2118,6 +2155,18 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
21182155
} else {
21192156
throw new LineSenderException("invalid sf_fsync [value=").put(sink).put(", allowed-values=[on, off]]");
21202157
}
2158+
} else if (Chars.equals("sf_fsync_on_flush", sink)) {
2159+
if (protocol != PROTOCOL_WEBSOCKET) {
2160+
throw new LineSenderException("sf_fsync_on_flush is only supported for WebSocket transport");
2161+
}
2162+
pos = getValue(configurationString, pos, sink, "sf_fsync_on_flush");
2163+
if (Chars.equalsIgnoreCase("on", sink)) {
2164+
storeAndForwardFsyncOnFlush(true);
2165+
} else if (Chars.equalsIgnoreCase("off", sink)) {
2166+
storeAndForwardFsyncOnFlush(false);
2167+
} else {
2168+
throw new LineSenderException("invalid sf_fsync_on_flush [value=").put(sink).put(", allowed-values=[on, off]]");
2169+
}
21212170
} else if (Chars.equals("max_datagram_size", sink)) {
21222171
pos = getValue(configurationString, pos, sink, "max_datagram_size");
21232172
int mds = parseIntValue(sink, "max_datagram_size");

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ public class QwpWebSocketSender implements Sender {
172172
// True when this sender took ownership of segmentLog (e.g. via the
173173
// connect-string builder); close() will then close the log too.
174174
private boolean ownsSegmentLog;
175+
// When true, every successful flush() (including the implicit flush
176+
// during close()) routes a fsync request to the I/O thread before
177+
// returning. Off by default — opt-in via setSegmentLogFsyncOnFlush
178+
// or sf_fsync_on_flush=on in the connect string.
179+
private boolean fsyncOnFlush;
175180
// Set by the I/O thread after a successful SF reconnect; checked by the user
176181
// thread on the next flushPendingRows so the next batch re-publishes schemas
177182
// the new server doesn't yet know about.
@@ -350,6 +355,33 @@ public static QwpWebSocketSender connect(
350355
int maxSchemasPerConnection,
351356
boolean requestDurableAck,
352357
SegmentLog segmentLog
358+
) {
359+
return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes,
360+
autoFlushIntervalNanos, inFlightWindowSize, authorizationHeader,
361+
maxSchemasPerConnection, requestDurableAck, segmentLog, false);
362+
}
363+
364+
/**
365+
* Connect overload with store-and-forward and an explicit
366+
* fsync-on-flush opt-in. {@code fsyncOnFlush=true} routes a fsync of
367+
* the SF active segment to the I/O thread at every {@link #flush()}
368+
* (and at the implicit flush during {@link #close()}); off by default
369+
* because small-batch / frequent-flush senders pay one disk fsync per
370+
* call.
371+
*/
372+
public static QwpWebSocketSender connect(
373+
String host,
374+
int port,
375+
ClientTlsConfiguration tlsConfig,
376+
int autoFlushRows,
377+
int autoFlushBytes,
378+
long autoFlushIntervalNanos,
379+
int inFlightWindowSize,
380+
String authorizationHeader,
381+
int maxSchemasPerConnection,
382+
boolean requestDurableAck,
383+
SegmentLog segmentLog,
384+
boolean fsyncOnFlush
353385
) {
354386
QwpWebSocketSender sender = new QwpWebSocketSender(
355387
host, port, tlsConfig,
@@ -361,6 +393,7 @@ public static QwpWebSocketSender connect(
361393
if (segmentLog != null) {
362394
sender.setSegmentLog(segmentLog, true);
363395
}
396+
sender.setSegmentLogFsyncOnFlush(fsyncOnFlush);
364397
sender.ensureConnected();
365398
} catch (Throwable t) {
366399
sender.close();
@@ -579,6 +612,12 @@ public void close() {
579612
// SF dir will replay them.
580613
if (sendQueue != null) {
581614
sendQueue.flush();
615+
if (fsyncOnFlush && segmentLog != null) {
616+
// Same opt-in fsync as the public flush(): the
617+
// user asked for "data is durable on flush"
618+
// semantics, and close() implies a final flush.
619+
sendQueue.requestSegmentLogFsync();
620+
}
582621
if (segmentLog == null) {
583622
sendQueue.awaitPendingAcks();
584623
}
@@ -866,6 +905,20 @@ public void flush() {
866905
throw e;
867906
}
868907

908+
// Opt-in fsync of the SF active segment. Routed through the
909+
// I/O thread (it owns SegmentLog) via the requestSegmentLogFsync
910+
// signal; blocks until the syscall returns. Off by default
911+
// because small-batch / frequent-flush senders pay one disk
912+
// fsync per call.
913+
if (fsyncOnFlush && segmentLog != null) {
914+
try {
915+
sendQueue.requestSegmentLogFsync();
916+
} catch (LineSenderException e) {
917+
checkConnectionError();
918+
throw e;
919+
}
920+
}
921+
869922
// Under SF the durability guarantee is "data is on disk", not "data is
870923
// server-acked". sendQueue.flush() already waited for processingCount
871924
// to drain, which means SF.append has run for every queued batch. Skip
@@ -1234,6 +1287,28 @@ public void setSegmentLog(SegmentLog log, boolean takeOwnership) {
12341287
this.ownsSegmentLog = takeOwnership && log != null;
12351288
}
12361289

1290+
/**
1291+
* Opt in to fsyncing the SF active segment at every {@link #flush()}
1292+
* (and at the implicit flush during {@link #close()}). Off by default.
1293+
* <p>
1294+
* Useful for senders that flush coarsely and want OS-crash survival
1295+
* without paying the per-append fsync cost of {@code sf_fsync=on}.
1296+
* Avoid for high-rate small-batch + frequent-flush workloads — every
1297+
* flush blocks on a disk fsync.
1298+
* <p>
1299+
* Must be set before the first send (mirrors {@link #setSegmentLog}).
1300+
*/
1301+
public void setSegmentLogFsyncOnFlush(boolean enabled) {
1302+
if (closed) {
1303+
throw new LineSenderException("Sender is closed");
1304+
}
1305+
if (connected) {
1306+
throw new LineSenderException(
1307+
"setSegmentLogFsyncOnFlush must be called before the first send");
1308+
}
1309+
this.fsyncOnFlush = enabled;
1310+
}
1311+
12371312
/**
12381313
* Number of times an outgoing batch was stalled because the SF total disk cap
12391314
* was reached. Each stall blocks the user thread's flush() until ACKs trim

core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketSendQueue.java

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,17 @@ public class WebSocketSendQueue implements QuietCloseable {
159159
private volatile boolean pingRequested;
160160
private volatile boolean pongReceived;
161161
private long pingDeadlineNanos;
162+
// Signal-based fsync: user thread sets fsyncRequested + waits on
163+
// fsyncComplete; I/O thread (the only thread that owns SegmentLog)
164+
// observes the flag, calls segmentLog.fsync(), publishes outcome
165+
// via fsyncError + fsyncComplete. Mirrors the ping handshake.
166+
private volatile boolean fsyncRequested;
167+
private volatile boolean fsyncComplete;
168+
private volatile Throwable fsyncError;
169+
// Serialises concurrent requestSegmentLogFsync callers, same idiom as
170+
// pingLock — each caller gets its own round-trip so post-conditions
171+
// hold per caller.
172+
private final Object fsyncLock = new Object();
162173
// Running state
163174
private volatile boolean running;
164175
private volatile boolean shuttingDown;
@@ -490,6 +501,55 @@ public void ping() {
490501
}
491502
}
492503

504+
/**
505+
* Asks the I/O thread to fsync the SF active segment and blocks until
506+
* the syscall returns. No-op when no SegmentLog is configured.
507+
* <p>
508+
* SegmentLog is single-threaded — the I/O thread owns every read,
509+
* write, trim and rotate. Calling {@code segmentLog.fsync()} from the
510+
* user thread would race against an in-flight {@code trim()} (which
511+
* may force-rotate the active segment under per-frame trim) or
512+
* against {@code append()} from a concurrent send. The signal pattern
513+
* keeps SegmentLog ownership clean.
514+
* <p>
515+
* Concurrent callers are serialised via {@link #fsyncLock} so each
516+
* one gets its own round-trip — the post-condition "every byte
517+
* persisted before the call returned is durable on disk" holds per
518+
* caller independently.
519+
*/
520+
public void requestSegmentLogFsync() {
521+
if (segmentLog == null) {
522+
return;
523+
}
524+
synchronized (fsyncLock) {
525+
checkError();
526+
synchronized (processingLock) {
527+
fsyncComplete = false;
528+
fsyncError = null;
529+
fsyncRequested = true;
530+
processingLock.notifyAll();
531+
while (!fsyncComplete && running) {
532+
try {
533+
processingLock.wait(1000);
534+
} catch (InterruptedException e) {
535+
Thread.currentThread().interrupt();
536+
throw new LineSenderException("SF fsync interrupted");
537+
}
538+
}
539+
if (!fsyncComplete) {
540+
checkError();
541+
throw new LineSenderException(
542+
"SF fsync aborted: send queue is shutting down");
543+
}
544+
}
545+
Throwable err = fsyncError;
546+
if (err != null) {
547+
throw new LineSenderException("SF fsync failed: " + err.getMessage(), err);
548+
}
549+
checkError();
550+
}
551+
}
552+
493553
/**
494554
* Returns the total number of batches sent.
495555
*/
@@ -641,6 +701,30 @@ private void ioLoop() {
641701
}
642702
}
643703

704+
// Honour any pending SF fsync request. Runs before batch
705+
// processing so the user's flush() observes a stable
706+
// "every byte appended before the request is durable"
707+
// invariant. A failure here is published to the caller
708+
// via fsyncError; we do NOT failConnection because an
709+
// fsync EIO is a storage problem, not a wire problem,
710+
// and the user can decide whether to retry vs. close.
711+
if (fsyncRequested) {
712+
fsyncRequested = false;
713+
Throwable err = null;
714+
try {
715+
if (segmentLog != null) {
716+
segmentLog.fsync();
717+
}
718+
} catch (Throwable t) {
719+
err = t;
720+
}
721+
fsyncError = err;
722+
synchronized (processingLock) {
723+
fsyncComplete = true;
724+
processingLock.notifyAll();
725+
}
726+
}
727+
644728
MicrobatchBuffer batch = null;
645729
boolean hasInFlight = (inFlightWindow != null && inFlightWindow.getInFlightCount() > 0);
646730
IoState state = computeState(hasInFlight);
@@ -652,7 +736,7 @@ private void ioLoop() {
652736
// Nothing to do - wait for work under lock
653737
synchronized (processingLock) {
654738
// Re-check under lock to avoid missed wakeup
655-
if (isPendingEmpty() && running && !pingRequested) {
739+
if (isPendingEmpty() && running && !pingRequested && !fsyncRequested) {
656740
try {
657741
processingLock.wait(100);
658742
} catch (InterruptedException e) {

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/SfFromConfigTest.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,53 @@ public void testInvalidSfFsyncValueRejected() {
499499
}
500500
}
501501

502+
/**
503+
* sf_fsync_on_flush is opt-in. Verify the connect-string parses both
504+
* values and the wiring reaches the sender (basic round-trip — the
505+
* actual fsync-on-flush behaviour is exercised in WebSocketSendQueueTest
506+
* with a counting FilesFacade).
507+
*/
508+
@Test
509+
public void testSfFsyncOnFlushParses() throws Exception {
510+
int port = TEST_PORT + 6;
511+
AckHandler handler = new AckHandler();
512+
try (TestWebSocketServer server = new TestWebSocketServer(port, handler)) {
513+
server.start();
514+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
515+
String config = "ws::addr=localhost:" + port
516+
+ ";store_and_forward=on;sf_dir=" + sfDir
517+
+ ";sf_fsync_on_flush=on;";
518+
try (Sender sender = Sender.fromConfig(config)) {
519+
sender.table("foo").longColumn("v", 1L).atNow();
520+
sender.flush();
521+
}
522+
Assert.assertTrue(Files.exists(sfDir));
523+
}
524+
}
525+
526+
@Test
527+
public void testInvalidSfFsyncOnFlushValueRejected() {
528+
String config = "ws::addr=localhost:1;store_and_forward=on;sf_dir=" + sfDir
529+
+ ";sf_fsync_on_flush=maybe;";
530+
try (Sender ignored = Sender.fromConfig(config)) {
531+
Assert.fail("expected rejection");
532+
} catch (LineSenderException expected) {
533+
Assert.assertTrue(expected.getMessage(),
534+
expected.getMessage().contains("invalid sf_fsync_on_flush"));
535+
}
536+
}
537+
538+
@Test
539+
public void testSfFsyncOnFlushOnTcpRejected() {
540+
String config = "tcp::addr=localhost:1;sf_fsync_on_flush=on;";
541+
try (Sender ignored = Sender.fromConfig(config)) {
542+
Assert.fail("expected rejection");
543+
} catch (LineSenderException expected) {
544+
Assert.assertTrue(expected.getMessage(),
545+
expected.getMessage().contains("WebSocket"));
546+
}
547+
}
548+
502549
@Test
503550
public void testStoreAndForwardWithSyncWindowRejected() {
504551
String config = "ws::addr=localhost:1;store_and_forward=on;sf_dir=" + sfDir

0 commit comments

Comments
 (0)