@@ -819,17 +819,22 @@ enum InitialConnectMode {
819819 * <li>{@link #MEMORY} — never fsync explicitly. Bytes live in the OS
820820 * page cache; survive a JVM crash but not an OS crash. Default
821821 * and the lowest-latency setting.</li>
822- * <li>{@link #FLUSH} — fsync the active segment at every
823- * {@code Sender.flush()} (and at the implicit close-flush). One
824- * fsync per user flush, regardless of frame count.</li>
825- * <li>{@link #APPEND} — fsync after every individual frame append.
826- * Strongest guarantee, slowest path; pay a disk fsync per row.</li>
822+ * <li>{@link #PERIODIC} — checkpoint published frames in the background
823+ * at {@code sf_sync_interval_millis}. The configured interval is a
824+ * target cadence; scheduler and storage latency add to the actual
825+ * power-loss recovery window.</li>
826+ * <li>{@link #FLUSH} — reserved for a future synchronous
827+ * {@code Sender.flush()} barrier; currently rejected by
828+ * {@code build()}.</li>
829+ * <li>{@link #APPEND} — reserved for a future barrier after every frame
830+ * append; currently rejected by {@code build()}.</li>
827831 * </ul>
828832 */
829833 enum SfDurability {
830834 MEMORY ,
831835 FLUSH ,
832- APPEND
836+ APPEND ,
837+ PERIODIC
833838 }
834839
835840 /**
@@ -972,6 +977,7 @@ final class LineSenderBuilder {
972977 // syscall cost so smaller segments give finer trim granularity and
973978 // make the cap arithmetic friendlier (cap / segment >> 2).
974979 private static final long DEFAULT_SEGMENT_BYTES = 4L * 1024 * 1024 ;
980+ private static final long DEFAULT_SF_SYNC_INTERVAL_MILLIS = 5_000L ;
975981 // Slot identity within sfDir. Each sender owns <sfDir>/<senderId>/ and
976982 // takes an advisory exclusive lock there. Default "default" is fine for
977983 // single-sender deployments; multi-sender setups must set this explicitly
@@ -1119,12 +1125,12 @@ public int getConnectTimeout() {
11191125 // there is no separate on/off flag (presence of the directory is the switch).
11201126 // null sfDir → memory-only async ingest (same lock-free architecture, no disk).
11211127 private String sfDir ;
1122- // Durability contract for SF append/flush. Today only MEMORY is
1123- // implemented; FLUSH and APPEND are deferred follow-ups (cursor needs
1124- // to learn fsync first).
1128+ // Durability contract for SF append/flush. FLUSH and APPEND remain
1129+ // deferred follow-ups; PERIODIC uses the segment manager.
11251130 private SfDurability sfDurability = SfDurability .MEMORY ;
11261131 private long sfMaxBytes = PARAMETER_NOT_SET_EXPLICITLY ;
11271132 private long sfMaxTotalBytes = PARAMETER_NOT_SET_EXPLICITLY ;
1133+ private long sfSyncIntervalMillis = PARAMETER_NOT_SET_EXPLICITLY ;
11281134 private boolean shouldDestroyPrivKey ;
11291135 private boolean tlsEnabled ;
11301136 private TlsValidationMode tlsValidationMode ;
@@ -1443,9 +1449,9 @@ public Sender build() {
14431449
14441450 // Setting sfDir enables store-and-forward (mmap'd, recoverable
14451451 // across sender restarts); omitting it gives memory-only mode
1446- // (same lock-free architecture, no disk involvement). The
1447- // sf_durability != memory rejection lives in validateParameters
1448- // so it is reached by build() and by no-connect validation alike .
1452+ // (same lock-free architecture, no disk involvement).
1453+ // Durability-combination validation lives in validateParameters
1454+ // so build() and no-connect validation apply the same rules .
14491455 long actualSfMaxBytes = sfMaxBytes == PARAMETER_NOT_SET_EXPLICITLY
14501456 ? DEFAULT_SEGMENT_BYTES
14511457 : sfMaxBytes ;
@@ -1534,15 +1540,25 @@ public Sender build() {
15341540 "could not create sf_dir: " + sfDir + " rc=" + rc );
15351541 }
15361542 }
1543+ if (sfDurability == SfDurability .PERIODIC
1544+ && Files .fsyncParentDir (sfDir ) != 0 ) {
1545+ throw new LineSenderException (
1546+ "could not sync parent directory for sf_dir: " + sfDir );
1547+ }
15371548 slotPath = sfDir + "/" + senderId ;
15381549 }
15391550 long actualSfAppendDeadlineNanos =
15401551 sfAppendDeadlineMillis == PARAMETER_NOT_SET_EXPLICITLY
15411552 ? CursorSendEngine .DEFAULT_APPEND_DEADLINE_NANOS
15421553 : sfAppendDeadlineMillis * 1_000_000L ;
1554+ long actualSfSyncIntervalNanos = sfDurability == SfDurability .PERIODIC
1555+ ? (sfSyncIntervalMillis == PARAMETER_NOT_SET_EXPLICITLY
1556+ ? DEFAULT_SF_SYNC_INTERVAL_MILLIS : sfSyncIntervalMillis ) * 1_000_000L
1557+ : 0L ;
15431558 CursorSendEngine cursorEngine = new CursorSendEngine (
15441559 slotPath , actualSfMaxBytes ,
1545- actualSfMaxTotalBytes , actualSfAppendDeadlineNanos );
1560+ actualSfMaxTotalBytes , actualSfAppendDeadlineNanos ,
1561+ actualSfSyncIntervalNanos );
15461562 int actualErrorInboxCapacity = errorInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY
15471563 ? errorInboxCapacity
15481564 : io .questdb .client .cutlass .qwp .client .sf .cursor .SenderErrorDispatcher .DEFAULT_CAPACITY ;
@@ -1623,7 +1639,8 @@ public Sender build() {
16231639 orphans ,
16241640 maxBackgroundDrainers ,
16251641 actualSfMaxBytes ,
1626- actualSfMaxTotalBytes );
1642+ actualSfMaxTotalBytes ,
1643+ actualSfSyncIntervalNanos );
16271644 }
16281645 }
16291646 return connected ;
@@ -2701,20 +2718,18 @@ public LineSenderBuilder sfAppendDeadlineMillis(long millis) {
27012718 * directory <i>is</i> the on-switch — there is no separate
27022719 * enable/disable flag. SF is off iff {@code dir} was never set.
27032720 * <p>
2704- * Every batch is persisted to disk before it leaves the wire and is
2705- * reclaimed as soon as the server acknowledges it. On restart the
2706- * sender replays only batches whose acknowledgement had not been
2707- * received before the previous sender shut down — typically the last
2708- * in-flight batches at close time. Acknowledged batches are not
2709- * replayed: their disk space is freed during normal operation by an
2710- * automatic per-frame trim that force-rotates the active segment
2711- * once every frame in it has been acknowledged.
2721+ * The sender publishes each batch into a memory-mapped segment before
2722+ * transmission and reclaims acknowledged segments in the background.
2723+ * The default {@link SfDurability#MEMORY} mode relies on OS page-cache
2724+ * writeback: it survives a producer-process restart but not guaranteed
2725+ * host power loss. {@link SfDurability#PERIODIC} adds checked background
2726+ * storage barriers at the configured target cadence.
27122727 * <p>
2713- * Note that {@link io.questdb.client.cutlass.qwp.client.QwpWebSocketSender#close()}
2714- * under SF returns once data is on disk, not on server-ack, so a
2715- * sender closed immediately after a flush may still have unacked
2716- * batches in flight; those will be replayed by the next sender
2717- * against the same directory. WebSocket transport only.
2728+ * On restart, the sender replays frames after its durable acknowledgement
2729+ * watermark. An acknowledgement that reached the server but not that
2730+ * watermark can replay, so applications that require row-level
2731+ * idempotence should configure server-side deduplication.
2732+ * WebSocket transport only.
27182733 * <p>
27192734 * The sender takes ownership of the underlying SF storage and closes
27202735 * it when the sender itself is closed.
@@ -2734,7 +2749,10 @@ public LineSenderBuilder storeAndForwardDir(String dir) {
27342749
27352750 /**
27362751 * Selects the durability contract for SF appends and flushes. See
2737- * {@link SfDurability} for the value semantics.
2752+ * {@link SfDurability} for the value semantics. The client currently
2753+ * supports {@link SfDurability#MEMORY} and
2754+ * {@link SfDurability#PERIODIC}; {@code build()} rejects the reserved
2755+ * {@code FLUSH} and {@code APPEND} values.
27382756 * <p>
27392757 * Replaces the prior pair of independent {@code sf_fsync} and
27402758 * {@code sf_fsync_on_flush} booleans — they were three states
@@ -2788,6 +2806,22 @@ public LineSenderBuilder storeAndForwardMaxTotalBytes(long maxTotalBytes) {
27882806 return this ;
27892807 }
27902808
2809+ /**
2810+ * Sets the target background checkpoint cadence for
2811+ * {@link SfDurability#PERIODIC}. Scheduler and storage latency add to
2812+ * the configured interval. Defaults to 5000 ms in periodic mode.
2813+ */
2814+ public LineSenderBuilder storeAndForwardSyncIntervalMillis (long millis ) {
2815+ if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET ) {
2816+ throw new LineSenderException ("store_and_forward is only supported for WebSocket transport" );
2817+ }
2818+ if (millis <= 0L || millis > Long .MAX_VALUE / 1_000_000L ) {
2819+ throw new LineSenderException ("sf_sync_interval_millis is out of range: " ).put (millis );
2820+ }
2821+ this .sfSyncIntervalMillis = millis ;
2822+ return this ;
2823+ }
2824+
27912825 private static boolean charsEqualsRange (CharSequence a , CharSequence b , int bStart , int bEnd ) {
27922826 int len = bEnd - bStart ;
27932827 if (a .length () != len ) {
@@ -2810,10 +2844,11 @@ private static int getValue(CharSequence configurationString, int pos, StringSin
28102844
28112845 private static SfDurability parseDurabilityValue (@ NotNull StringSink value ) {
28122846 if (Chars .equalsIgnoreCase ("memory" , value )) return SfDurability .MEMORY ;
2847+ if (Chars .equalsIgnoreCase ("periodic" , value )) return SfDurability .PERIODIC ;
28132848 if (Chars .equalsIgnoreCase ("flush" , value )) return SfDurability .FLUSH ;
28142849 if (Chars .equalsIgnoreCase ("append" , value )) return SfDurability .APPEND ;
28152850 throw new LineSenderException ("invalid sf_durability [value=" ).put (value )
2816- .put (", allowed-values=[memory, flush, append]]" );
2851+ .put (", allowed-values=[memory, periodic, flush, append]]" );
28172852 }
28182853
28192854 private static int parseIntValue (@ NotNull StringSink value , @ NotNull String name ) {
@@ -3370,6 +3405,12 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
33703405 }
33713406 pos = getValue (configurationString , pos , sink , "sf_durability" );
33723407 storeAndForwardDurability (parseDurabilityValue (sink ));
3408+ } else if (Chars .equals ("sf_sync_interval_millis" , sink )) {
3409+ if (protocol != PROTOCOL_WEBSOCKET ) {
3410+ throw new LineSenderException ("sf_sync_interval_millis is only supported for WebSocket transport" );
3411+ }
3412+ pos = getValue (configurationString , pos , sink , "sf_sync_interval_millis" );
3413+ storeAndForwardSyncIntervalMillis (parseLongValue (sink , "sf_sync_interval_millis" ));
33733414 } else if (Chars .equals ("close_flush_timeout_millis" , sink )) {
33743415 if (protocol != PROTOCOL_WEBSOCKET ) {
33753416 throw new LineSenderException ("close_flush_timeout_millis is only supported for WebSocket transport" );
@@ -3695,6 +3736,9 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString)
36953736 if (view .has ("sf_max_total_bytes" )) {
36963737 storeAndForwardMaxTotalBytes (wsSize (view , v , "sf_max_total_bytes" ));
36973738 }
3739+ if (view .has ("sf_sync_interval_millis" )) {
3740+ storeAndForwardSyncIntervalMillis (wsLong (view , v , "sf_sync_interval_millis" ));
3741+ }
36983742
36993743 s = view .getStr ("sf_dir" );
37003744 if (s != null ) {
@@ -3852,6 +3896,7 @@ public java.util.Map<String, Object> wsConfigSnapshotForTest() {
38523896 m .put ("sf_max_total_bytes" , sfMaxTotalBytes );
38533897 m .put ("sf_durability" , sfDurability == null ? null : sfDurability .name ());
38543898 m .put ("sf_append_deadline_millis" , sfAppendDeadlineMillis );
3899+ m .put ("sf_sync_interval_millis" , sfSyncIntervalMillis );
38553900 m .put ("close_flush_timeout_millis" , closeFlushTimeoutMillis );
38563901 m .put ("durable_ack_keepalive_interval_millis" , durableAckKeepaliveIntervalMillis );
38573902 m .put ("initial_connect_retry" , initialConnectMode == null ? null : initialConnectMode .name ());
@@ -4057,14 +4102,18 @@ private void validateParameters() {
40574102 if (autoFlushIntervalMillis == Integer .MAX_VALUE ) {
40584103 throw new LineSenderException ("disabling auto-flush is not supported for WebSocket protocol" );
40594104 }
4060- // The cursor send path does not fsync yet, so any sf_durability
4061- // other than memory is rejected rather than silently downgraded.
4062- // Validating it here (rather than at connect time) lets a
4063- // no-connect config check reject it as a full build() does.
4064- if (sfDurability != SfDurability .MEMORY ) {
4105+ if ((sfDurability == SfDurability .FLUSH || sfDurability == SfDurability .APPEND )) {
40654106 throw new LineSenderException (
40664107 "sf_durability=" + sfDurability .name ().toLowerCase ()
4067- + " is not yet supported (deferred follow-up; use sf_durability=memory)" );
4108+ + " is not yet supported (use sf_durability=memory or periodic)" );
4109+ }
4110+ if (sfDurability == SfDurability .PERIODIC && sfDir == null ) {
4111+ throw new LineSenderException ("sf_durability=periodic requires sf_dir" );
4112+ }
4113+ if (sfSyncIntervalMillis != PARAMETER_NOT_SET_EXPLICITLY
4114+ && sfDurability != SfDurability .PERIODIC ) {
4115+ throw new LineSenderException (
4116+ "sf_sync_interval_millis requires sf_durability=periodic" );
40684117 }
40694118 } else {
40704119 throw new LineSenderException ("unsupported protocol " )
0 commit comments