Skip to content

Commit 9c9e1dd

Browse files
committed
Add periodic store-and-forward syncing
Add configurable background checkpoints for store-and-forward payloads.\nUse checked mmap and file-descriptor barriers and gate segment rotation\nuntil the predecessor is durable.\n\nPropagate the interval through foreground senders and orphan drainers,\npreserve failures for producer visibility, and sync replayable data on\nrecovery and close. Persist parent directory entries in periodic mode.\n\nDocument the bounded-RPO contract and cover configuration, scheduling,\nbarrier failures, rotation, recovery, and compatibility in tests.
1 parent 07034cd commit 9c9e1dd

18 files changed

Lines changed: 978 additions & 84 deletions

File tree

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,9 @@ You can also let the client flush batches for you with the `auto_flush_rows` / `
142142
`ws::addr=localhost:9000;auto_flush_rows=10000;auto_flush_interval=1000;`.
143143

144144
**Confirm a batch is durably received.** Over QWP each flush returns a frame sequence number (FSN); `awaitAckedFsn`
145-
blocks until the server has acknowledged it. Rows are safe in the store-and-forward log even if the ack has not landed
146-
yet — they replay on reconnect.
145+
blocks until the server has acknowledged it. With `sf_dir`, rows in the store-and-forward log replay after reconnect
146+
or a producer-process restart. For periodic host-power-loss checkpoints, also configure
147+
`sf_durability=periodic;sf_sync_interval_millis=5000;`.
147148

148149
```java
149150
try (Sender sender = db.borrowSender()) {
@@ -259,7 +260,9 @@ try (QuestDB db = QuestDB.builder()
259260
List every cluster node in one `addr` server list; the single string configures both the ingest and query pools across
260261
all of them. On the query side, `target` selects the node role to route to (`any`, `primary`, or `replica`) and
261262
`failover=on` enables failover across the list. The ingest side reconnects across the same node list on its own — a
262-
store-and-forward sender keeps buffering rows through a failover window and never drops them.
263+
store-and-forward sender keeps buffering rows through a failover window and replays unacknowledged data. The default
264+
`memory` durability mode protects process restarts, not host power loss; use periodic durability for background disk
265+
checkpoints.
263266

264267
```java
265268
try (QuestDB db = QuestDB.connect(
@@ -439,7 +442,10 @@ Applied by the query pool to select and fail over between the nodes in the `addr
439442
| `client_id` | | Opaque client identifier surfaced server-side for observability |
440443

441444
The ingest side also accepts store-and-forward and reconnection tuning keys (`auto_flush_*`, `initial_connect_retry`,
442-
`reconnect_*`, `request_durable_ack`, `sf_*`, `max_frame_rejections`, `poison_min_escalation_window_millis`, …). See the
445+
`reconnect_*`, `request_durable_ack`, `sf_*`, `max_frame_rejections`, `poison_min_escalation_window_millis`, …).
446+
`sf_durability=periodic` checkpoints mmap-published data in the background; `sf_sync_interval_millis` defaults to `5000`
447+
in that mode. The interval is a target cadence: JVM scheduling and storage-sync latency add to the actual loss window.
448+
Use `request_durable_ack=on` when end-to-end server durability is also required. See the
443449
[QuestDB documentation](https://questdb.com/docs/) for the full reference.
444450

445451
## Requirements

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

Lines changed: 84 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)