Skip to content

Commit cc4a68f

Browse files
bluestreak01claude
andcommitted
feat(ilp): cursor SF -- on-disk recovery + maxTotalBytes cap
Two pre-wiring additions on the cursor side that the upcoming WebSocketSendQueue replacement will need. 1. SegmentRing.openExisting(sfDir, maxBytesPerSegment) Walks *.sfa files in the directory, opens each via MmapSegment.openExisting (which already validates header + scans torn tails), arranges by baseSeq, and returns a ring with the newest as active and the rest as sealed. Validates that the recovered segments form a contiguous FSN range -- a gap signals manual deletion or partial-write damage and aborts recovery rather than silently producing duplicate / missing FSNs after restart. Stray .sfa files with bad headers are skipped (logged- then-ignored), not fatal. 2. SegmentManager maxTotalBytes cap Manager tracks total bytes it has provisioned across all rings it serves. When provisioning a hot spare would exceed the cap, the manager skips the install and the requesting ring stays in BACKPRESSURE_NO_SPARE until ACK-driven trim frees space. Default is UNLIMITED_TOTAL_BYTES (no behavioural change for existing callers). Disk-full state is logged at WARN, throttled to once per 30s so a sustained-full state doesn't drown the log. Cap is approximate -- it counts only manager-provisioned segments, not the engine's initial active per ring (so the effective on-disk cap is maxTotalBytes + (rings * segmentSizeBytes)). Acceptable for a runaway-growth guard; documented in the constructor. Also makes SegmentRing.sealedSegments mutation thread-safe via a synchronized snapshot path that the I/O loop will use, and marks SegmentRing.active volatile so cross-thread rotation publication is correct without a lock. 10 new tests across SegmentRingTest + SegmentManagerTest covering: recovery happy path, FSN-gap detection, bad-magic skip, cap blocks provisioning, cap is released by ACK-driven trim. All 2020 tests in the suite pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 49f1683 commit cc4a68f

4 files changed

Lines changed: 385 additions & 20 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,26 +60,68 @@
6060
public final class SegmentManager implements QuietCloseable {
6161

6262
public static final long DEFAULT_POLL_NANOS = 1_000_000L; // 1 ms
63+
public static final long DISK_FULL_LOG_THROTTLE_NANOS = 30_000_000_000L; // 30 s
64+
public static final long UNLIMITED_TOTAL_BYTES = Long.MAX_VALUE;
6365
private static final Logger LOG = LoggerFactory.getLogger(SegmentManager.class);
6466

6567
private final AtomicLong fileGeneration = new AtomicLong();
6668
private final Object lock = new Object();
69+
private final long maxTotalBytes;
6770
private final long pollNanos;
6871
private final ObjList<RingEntry> rings = new ObjList<>();
6972
private final long segmentSizeBytes;
73+
// Total bytes currently allocated across every segment owned by every
74+
// registered ring (active + sealed + hot-spare). Manager-thread only —
75+
// incremented when a spare is created, decremented when trim removes a
76+
// segment. No lock needed because both operations happen on the manager
77+
// thread inside serviceRing().
78+
private long totalBytes;
79+
private long lastDiskFullLogNs;
7080
private volatile boolean running;
7181
private Thread workerThread;
7282

7383
public SegmentManager(long segmentSizeBytes) {
74-
this(segmentSizeBytes, DEFAULT_POLL_NANOS);
84+
this(segmentSizeBytes, DEFAULT_POLL_NANOS, UNLIMITED_TOTAL_BYTES);
7585
}
7686

7787
public SegmentManager(long segmentSizeBytes, long pollNanos) {
88+
this(segmentSizeBytes, pollNanos, UNLIMITED_TOTAL_BYTES);
89+
}
90+
91+
/**
92+
* Full constructor.
93+
*
94+
* @param segmentSizeBytes per-segment file size in bytes
95+
* @param pollNanos how often the worker polls each registered ring;
96+
* default {@link #DEFAULT_POLL_NANOS}
97+
* @param maxTotalBytes upper bound on total bytes the manager will
98+
* provision. When provisioning a hot spare would
99+
* exceed this, the manager skips the install — the
100+
* requesting ring stays in the
101+
* {@link SegmentRing#BACKPRESSURE_NO_SPARE} state
102+
* until ACK-driven trim frees space. Pass
103+
* {@link #UNLIMITED_TOTAL_BYTES} to disable.
104+
* <b>Approximation:</b> the cap counts only segments
105+
* the manager itself provisioned. Each ring's
106+
* initial active segment (created by the engine
107+
* before the ring was registered) is "free" for
108+
* cap purposes — so the effective on-disk cap is
109+
* {@code maxTotalBytes + (rings × segmentSizeBytes)}.
110+
* A 1-segment slop is acceptable for the cap's role
111+
* (preventing runaway growth).
112+
*/
113+
public SegmentManager(long segmentSizeBytes, long pollNanos, long maxTotalBytes) {
78114
if (segmentSizeBytes < MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 1) {
79115
throw new IllegalArgumentException("segmentSizeBytes too small: " + segmentSizeBytes);
80116
}
117+
if (maxTotalBytes < segmentSizeBytes) {
118+
throw new IllegalArgumentException(
119+
"maxTotalBytes (" + maxTotalBytes + ") must allow at least one segment of "
120+
+ segmentSizeBytes + " bytes");
121+
}
81122
this.segmentSizeBytes = segmentSizeBytes;
82123
this.pollNanos = pollNanos;
124+
this.maxTotalBytes = maxTotalBytes;
83125
}
84126

85127
@Override
@@ -136,24 +178,41 @@ public synchronized void start() {
136178
}
137179

138180
private void serviceRing(RingEntry e) {
139-
// 1. Provision a hot spare if the ring needs one.
181+
// 1. Provision a hot spare if the ring needs one AND we have headroom
182+
// under the disk-total cap. Cap check is per-tick; if we're capped
183+
// here, the ring stays in BACKPRESSURE_NO_SPARE until trim (step 2)
184+
// on this or a subsequent tick frees space. Logged at most once per
185+
// DISK_FULL_LOG_THROTTLE_NANOS so a sustained-disk-full state
186+
// doesn't drown the log.
140187
if (e.ring.needsHotSpare()) {
141-
String path = nextSparePath(e.dir);
142-
try {
143-
// baseSeq is provisional — SegmentRing.appendOrFsn calls
144-
// rebaseSeq() at rotation time to pin the real value. We
145-
// pass the manager's best guess (nextSeqHint at this
146-
// instant), which is fine since it's overwritten anyway.
147-
MmapSegment spare = MmapSegment.create(path, e.ring.nextSeqHint(), segmentSizeBytes);
188+
if (totalBytes + segmentSizeBytes > maxTotalBytes) {
189+
long now = System.nanoTime();
190+
if (now - lastDiskFullLogNs >= DISK_FULL_LOG_THROTTLE_NANOS) {
191+
LOG.warn("SF disk-full: cannot provision spare in {} "
192+
+ "(totalBytes={}, cap={}, segmentSize={}). "
193+
+ "Producer is backpressured until ACK-driven trim frees space.",
194+
e.dir, totalBytes, maxTotalBytes, segmentSizeBytes);
195+
lastDiskFullLogNs = now;
196+
}
197+
} else {
198+
String path = nextSparePath(e.dir);
148199
try {
149-
e.ring.installHotSpare(spare);
200+
// baseSeq is provisional — SegmentRing.appendOrFsn calls
201+
// rebaseSeq() at rotation time to pin the real value. We
202+
// pass the manager's best guess (nextSeqHint at this
203+
// instant), which is fine since it's overwritten anyway.
204+
MmapSegment spare = MmapSegment.create(path, e.ring.nextSeqHint(), segmentSizeBytes);
205+
try {
206+
e.ring.installHotSpare(spare);
207+
totalBytes += segmentSizeBytes;
208+
} catch (Throwable t) {
209+
spare.close();
210+
Files.remove(path);
211+
throw t;
212+
}
150213
} catch (Throwable t) {
151-
spare.close();
152-
Files.remove(path);
153-
throw t;
214+
LOG.warn("Failed to provision hot spare in {} (will retry next tick)", e.dir, t);
154215
}
155-
} catch (Throwable t) {
156-
LOG.warn("Failed to provision hot spare in {} (will retry next tick)", e.dir, t);
157216
}
158217
}
159218

@@ -163,11 +222,13 @@ private void serviceRing(RingEntry e) {
163222
for (int i = 0, n = trim.size(); i < n; i++) {
164223
MmapSegment s = trim.get(i);
165224
String path = s.path();
225+
long sz = s.sizeBytes();
166226
try {
167227
s.close();
168228
if (!Files.remove(path)) {
169229
LOG.warn("Failed to unlink trimmed segment {}", path);
170230
}
231+
totalBytes -= sz;
171232
} catch (Throwable t) {
172233
LOG.warn("Failed to trim segment {}", path, t);
173234
}

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
package io.questdb.client.cutlass.qwp.client.sf.cursor;
2626

27+
import io.questdb.client.std.Files;
2728
import io.questdb.client.std.ObjList;
2829
import io.questdb.client.std.QuietCloseable;
2930

@@ -68,7 +69,10 @@ public final class SegmentRing implements QuietCloseable {
6869
// looks at sealedSegments after observing a higher ackedFsn, by which
6970
// point the producer thread's add to sealedSegments has retired.
7071
private final ObjList<MmapSegment> sealedSegments = new ObjList<>();
71-
private MmapSegment active;
72+
// active: written by producer (constructor + appendOrFsn rotation),
73+
// read by I/O thread via getActive(). Volatile so the I/O thread sees
74+
// rotations promptly and never observes a torn reference.
75+
private volatile MmapSegment active;
7276
private volatile long ackedFsn = -1L;
7377
// hotSpare: written by segment manager (installHotSpare), read+cleared by
7478
// producer thread on rotation. Volatile so the producer sees fresh installs.
@@ -91,6 +95,110 @@ public SegmentRing(MmapSegment initialActive, long maxBytesPerSegment) {
9195
this.publishedFsn = nextSeq - 1;
9296
}
9397

98+
/**
99+
* Recovers a ring from segments already on disk in {@code sfDir}. Used at
100+
* sender startup when the user's previous session left durable but
101+
* not-yet-acked frames behind. Walks every {@code *.sfa} file in the
102+
* directory, opens each via {@link MmapSegment#openExisting}, and
103+
* arranges them by baseSeq:
104+
* <ul>
105+
* <li>Highest-baseSeq segment becomes the active (further appends land
106+
* there until it fills, at which point normal rotation kicks in).</li>
107+
* <li>All others become sealed segments awaiting ACK and trim.</li>
108+
* </ul>
109+
* Returns {@code null} if the directory is empty or contains no
110+
* recognizable {@code .sfa} files — the caller should then construct a
111+
* fresh ring with {@link #SegmentRing(MmapSegment, long)} and a freshly
112+
* created initial segment.
113+
* <p>
114+
* Recovery is best-effort: a single bad-magic file is silently skipped
115+
* (logged-then-ignored is the right call here; a stray unrelated file in
116+
* the SF dir shouldn't take the whole sender down). A failure to open
117+
* an otherwise-valid segment IS fatal — the caller's data integrity
118+
* depends on every segment being readable.
119+
*/
120+
public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
121+
if (!Files.exists(sfDir)) {
122+
return null;
123+
}
124+
ObjList<MmapSegment> opened = new ObjList<>();
125+
long find = Files.findFirst(sfDir);
126+
if (find == 0) {
127+
return null;
128+
}
129+
try {
130+
int rc = 1;
131+
while (rc > 0) {
132+
String name = Files.utf8ToString(Files.findName(find));
133+
if (name != null && name.endsWith(".sfa") && !".".equals(name) && !"..".equals(name)) {
134+
String path = sfDir + "/" + name;
135+
try {
136+
opened.add(MmapSegment.openExisting(path));
137+
} catch (MmapSegmentException ignored) {
138+
// Stray file with the .sfa extension but bad header /
139+
// unreadable: skip rather than fail the recovery.
140+
// Logging is the engine's responsibility — SegmentRing
141+
// doesn't have a logger of its own.
142+
}
143+
}
144+
rc = Files.findNext(find);
145+
}
146+
} finally {
147+
Files.findClose(find);
148+
}
149+
if (opened.size() == 0) {
150+
return null;
151+
}
152+
// Sort by baseSeq ascending. ObjList lacks sort; do a simple selection
153+
// sort — typical recovery is < 100 segments, O(n^2) is fine.
154+
for (int i = 0, n = opened.size(); i < n; i++) {
155+
int minIdx = i;
156+
long minBase = opened.get(i).baseSeq();
157+
for (int j = i + 1; j < n; j++) {
158+
long b = opened.get(j).baseSeq();
159+
if (b < minBase) {
160+
minBase = b;
161+
minIdx = j;
162+
}
163+
}
164+
if (minIdx != i) {
165+
MmapSegment tmp = opened.get(i);
166+
opened.setQuick(i, opened.get(minIdx));
167+
opened.setQuick(minIdx, tmp);
168+
}
169+
}
170+
// Sanity: the recovered segments must form a contiguous FSN range.
171+
// Detect gaps so a partial-write/manual-deletion mishap doesn't
172+
// silently produce duplicate or missing FSNs after recovery.
173+
for (int i = 1, n = opened.size(); i < n; i++) {
174+
MmapSegment prev = opened.get(i - 1);
175+
MmapSegment curr = opened.get(i);
176+
long expected = prev.baseSeq() + prev.frameCount();
177+
if (curr.baseSeq() != expected) {
178+
// Close everything we've opened so the file handles don't leak.
179+
for (int j = 0; j < n; j++) opened.get(j).close();
180+
throw new MmapSegmentException(
181+
"FSN gap in recovered segments: prev baseSeq=" + prev.baseSeq()
182+
+ " frameCount=" + prev.frameCount()
183+
+ " expected next baseSeq=" + expected
184+
+ " but got " + curr.baseSeq());
185+
}
186+
}
187+
// The newest segment becomes the active. Even if it's full, that's OK:
188+
// the next appendOrFsn returns BACKPRESSURE_NO_SPARE, the manager
189+
// installs a hot spare, the producer rotates. Same fast path as a
190+
// mid-life ring.
191+
int last = opened.size() - 1;
192+
MmapSegment active = opened.get(last);
193+
opened.remove(last);
194+
SegmentRing ring = new SegmentRing(active, maxBytesPerSegment);
195+
// Older segments become sealed in baseSeq order.
196+
for (int i = 0, n = opened.size(); i < n; i++) {
197+
ring.sealedSegments.add(opened.get(i));
198+
}
199+
return ring;
200+
}
201+
94202
/**
95203
* Highest FSN that the server has ACK'd. Read by the segment manager to
96204
* decide which sealed segments are safe to munmap + unlink.
@@ -137,7 +245,12 @@ public long appendOrFsn(long payloadAddr, int payloadLen) {
137245
// earlier guess at baseSeq is irrelevant.
138246
long actualBase = active.baseSeq() + active.frameCount();
139247
spare.rebaseSeq(actualBase);
140-
sealedSegments.add(active);
248+
// Mutate sealedSegments under the same monitor used by
249+
// snapshotSealedSegments — the I/O thread reads through that
250+
// path and must not see a half-resized ObjList.
251+
synchronized (this) {
252+
sealedSegments.add(active);
253+
}
141254
active = spare;
142255
hotSpare = null;
143256
offset = active.tryAppend(payloadAddr, payloadLen);
@@ -179,11 +292,13 @@ public void close() {
179292
* when nothing is eligible (avoids ObjList allocation in the steady
180293
* state where most polls are no-ops).
181294
*/
182-
public ObjList<MmapSegment> drainTrimmable() {
295+
public synchronized ObjList<MmapSegment> drainTrimmable() {
183296
long acked = ackedFsn;
184297
ObjList<MmapSegment> out = null;
185298
// Sealed segments are in baseSeq order, oldest first; once we hit one
186299
// that isn't fully acked, none of the later ones can be either.
300+
// Synchronized so the I/O thread's snapshotSealedSegments() can't
301+
// race against the remove(0) shuffling slots underneath it.
187302
while (sealedSegments.size() > 0) {
188303
MmapSegment s = sealedSegments.get(0);
189304
long lastSeq = s.baseSeq() + s.frameCount() - 1;
@@ -204,11 +319,44 @@ public MmapSegment getActive() {
204319
return active;
205320
}
206321

207-
/** Snapshot view of sealed segments (oldest first); for I/O thread to drain. */
322+
/**
323+
* Direct view of sealed segments (oldest first). NOT thread-safe — use
324+
* only from the producer thread, or alongside a lock that excludes
325+
* concurrent rotation. Cross-thread readers (typically the I/O loop)
326+
* should use {@link #snapshotSealedSegments(MmapSegment[])} instead.
327+
*/
208328
public ObjList<MmapSegment> getSealedSegments() {
209329
return sealedSegments;
210330
}
211331

332+
/**
333+
* Thread-safe snapshot of the current sealed-segment list. Copies
334+
* references into the caller-supplied {@code target} array (oldest
335+
* first, packed left). Returns the number of references copied. If
336+
* {@code target} is too small, copies the first {@code target.length}
337+
* references and returns {@code -1} as a signal that the caller needs
338+
* to grow the buffer and retry.
339+
* <p>
340+
* Synchronized against rotation (producer's
341+
* {@link #appendOrFsn} mutates {@code sealedSegments}). Cost is one
342+
* monitor acquire/release per call, paid by the I/O loop at most once
343+
* per tick — far below the cost of the actual {@code sendBinary} that
344+
* the I/O loop is about to do.
345+
*/
346+
public synchronized int snapshotSealedSegments(MmapSegment[] target) {
347+
int n = sealedSegments.size();
348+
if (n > target.length) {
349+
for (int i = 0; i < target.length; i++) {
350+
target[i] = sealedSegments.get(i);
351+
}
352+
return -1;
353+
}
354+
for (int i = 0; i < n; i++) {
355+
target[i] = sealedSegments.get(i);
356+
}
357+
return n;
358+
}
359+
212360
/**
213361
* Segment manager pre-creates the next segment and parks it here. The
214362
* producer consumes the spare on its next rotation. Throws if a spare

0 commit comments

Comments
 (0)