diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5ccfaa64 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + # JDK 8 is the source of truth: the client ships as a Java 8 artifact + # (io.questdb:questdb-client) and is released from JDK 8, so on JDK 8 it must + # compile, the full test suite must pass against the committed native + # libraries, and the javadoc jar must build (-P javadoc attaches it at the + # package phase). The committed native .so/.dylib/.dll are enough -- the only + # git submodule (zstd) is needed solely for C++ native rebuilds, not here. + build-jdk8: + name: Build, test & javadoc (JDK 8) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "8" + cache: maven + + - name: Compile, test, and build javadoc + run: mvn -B -ntp -P javadoc clean install + + # The client is also consumed as a submodule of the main questdb repo, which + # builds on JDK 25. Guard against JDK 25 compile breakage (main + test + # sources, both modules) and confirm the javadoc jar builds on JDK 25 too + # (-P javadoc attaches it at the package phase). Do NOT run the tests -- the + # parent repo runs them against a real server. + compile-jdk25: + name: Compile & javadoc smoke (JDK 25) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "25" + cache: maven + + - name: Compile (main + test) and build javadoc (no tests run) + run: mvn -B -ntp -P javadoc -DskipTests clean package diff --git a/CLAUDE.md b/CLAUDE.md index 77f331a7..7d2e56b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,9 +7,30 @@ Guidance for Claude Code when working with the QuestDB Java client The standalone client repo for QuestDB ingestion (legacy ILP and QWP). It is a Git submodule of the `questdb` repository and ships as the -`io.questdb:questdb-client` Maven artifact. The module targets Java 11, so -only legacy Java features are available here — no enhanced switch, no -multiline strings, no pattern variables in `instanceof`. +`io.questdb:questdb-client` Maven artifact. + +**Dual JDK target — always aim at both.** The client ships as a Java 8 +artifact (JDK 8 is the source of truth: CI builds, tests, and releases on +JDK 8) but must also compile cleanly on JDK 11+ (CI runs a JDK 25 +compile/javadoc smoke job). The right Maven profile (`java8` / +`java11+`) auto-activates from the running JDK; a `src/main/java8` vs +`src/main/java11` source root supplies the per-JDK shims. Practically this +means **Java 8 is the language/API floor** — every change must build on +both, so write to Java 8 and never introduce an API or syntax that only a +newer JDK has. + +Disallowed here (newer than Java 8), among others: +- `var` local-variable type inference (10), enhanced `switch` (14), + multiline text blocks (15), pattern variables in `instanceof` (16) +- `List.of` / `List.copyOf` / `Map.of` and friends (9/10), + `String.repeat` / `String.strip` / `String.isBlank` (11) +- `ProcessHandle` (9), `Thread.onSpinWait` (9), + the `Provider(String, String, String)` ctor (9 — use the Java 8 + `Provider(String, double, String)` overload) + +When in doubt, check the API's `@since` and keep it `<= 1.8`. JDK +9+-only behaviour belongs behind the `src/main/java11` shim with a Java 8 +counterpart in `src/main/java8`. ## Git & PR Conventions @@ -35,13 +56,18 @@ multiline strings, no pattern variables in `instanceof`. ## Build -Standard Maven build. Targets Java 11: +Standard Maven build. Dual-target (Java 8 floor, Java 11+ also supported); +the `java8` / `java11+` profile auto-activates from the JDK running Maven: ```bash mvn clean install -DskipTests # build + install to local Maven cache mvn -pl core test # run client unit tests ``` +Build on a JDK 8 to validate the shipping artifact (the source-of-truth +target); also confirm it compiles on a modern JDK (11+) before merging, the +same two fronts CI guards. + The parent `questdb` repo's `local-client` profile pulls this module as a sub-module so server changes can build against unpublished client code; if you change client code, install it (or pass `-P local-client` in the parent) diff --git a/core/pom.xml b/core/pom.xml index 5c8d5cf8..33a0112e 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -37,6 +37,10 @@ None %regex[.*[^o].class] 1.37 + + src/main/java11 + + 1.5.25 1.3.5-SNAPSHOT @@ -184,6 +188,27 @@ + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-compat-source + generate-sources + + add-source + + + + ${compat.src.dir} + + + + + @@ -206,11 +231,11 @@ none - 11 + ${javac.compile.source} false - ${compilerArg1} - ${compilerArg2} + ${javadocJOption1} + ${javadocJOption2} ${excludePattern1} @@ -338,11 +363,11 @@ none - 11 + ${javac.compile.source} false - ${compilerArg1} - ${compilerArg2} + ${javadocJOption1} + ${javadocJOption2} ${excludePattern1} @@ -444,6 +469,11 @@ questdb --add-exports java.base/jdk.internal.math=io.questdb.client + + --add-exports + java.base/jdk.internal.math=io.questdb.client nothing-to-exclude-dummy-value-include-all-java11plus nothing-to-exclude-dummy-value-include-all-java11plus ${javac.target} @@ -466,6 +496,47 @@ + + java8 + + 8 + [1.8,11) + questdb + + -Xlint:none + -Xlint:none + + -quiet + -quiet + + **/module-info.java + **/module-info.java + src/main/java8 + 1.8 + 1.8 + + 1.3.15 + + + 1.8 + + + + org.jetbrains + annotations + 17.0.0 + provided + + + org.slf4j + slf4j-api + 2.0.17 + + + @@ -485,7 +556,7 @@ ch.qos.logback logback-classic - 1.5.25 + ${logback.version} test diff --git a/core/src/main/java/io/questdb/client/QuestDBBuilder.java b/core/src/main/java/io/questdb/client/QuestDBBuilder.java index 7a037698..0b73541b 100644 --- a/core/src/main/java/io/questdb/client/QuestDBBuilder.java +++ b/core/src/main/java/io/questdb/client/QuestDBBuilder.java @@ -73,6 +73,13 @@ public QuestDBBuilder acquireTimeoutMillis(long millis) { * connections in each pool; further slots are allocated lazily up to * {@code max} when load demands and reaped back to {@code min} when * idle. + *

+ * Non-blocking on startup recovery: when store-and-forward is enabled, + * unacked data a previous run left in this pool's managed slots is + * recovered on a background housekeeper thread shortly after this method + * returns -- so {@code build()} does not block on a slow or + * reachable-but-not-acking server. The recovered data is durable on disk + * and is delivered once the server acks; until then it stays preserved. */ public QuestDB build() { if (ingestConfig == null) { diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 8e9513b1..bd536e6b 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1022,6 +1022,15 @@ final class LineSenderBuilder { // runtime lands in a follow-up commit. For now we surface the // count via logging so users can confirm orphans are being seen. private boolean drainOrphans = false; + // Orphan-scan exclusion for the connection pool. The pool co-manages + // exactly - for i in [0, orphanDrainSlotCount) and + // recovers each of those on (re)creation, so pooled senders must never + // treat one another's live slots as drainable orphans. Anything else -- + // a different base, a bare un-suffixed id, OR a same-base index at or + // above the count (a slot left behind by a larger pool before maxSize + // shrank) -- is still drained, so unacked data is never stranded. + private String orphanDrainBase; + private int orphanDrainSlotCount; private long durableAckKeepaliveIntervalMillis = DURABLE_ACK_KEEPALIVE_NOT_SET; // Optional user-supplied async error handler. When null, the sender // uses DefaultSenderErrorHandler.INSTANCE (loud-not-silent log). @@ -1472,7 +1481,15 @@ public Sender build() { } else { if (!Files.exists(sfDir)) { int rc = Files.mkdir(sfDir, Files.DIR_MODE_DEFAULT); - if (rc != 0) { + // mkdir is non-zero on failure, but "already exists" + // is one such failure. Multiple SF senders sharing one + // sf_dir can be built concurrently (the pool calls + // build() outside its lock), so two threads can both + // pass the exists() check and race into mkdir; the + // loser gets EEXIST. Treat a benign creation race -- + // the dir now exists -- as success and only fail when + // the directory is genuinely absent afterwards. + if (rc != 0 && !Files.exists(sfDir)) { throw new LineSenderException( "could not create sf_dir: " + sfDir + " rc=" + rc); } @@ -1548,7 +1565,7 @@ public Sender build() { if (drainOrphans && sfDir != null) { io.questdb.client.std.ObjList orphans = io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner - .scan(sfDir, senderId); + .scan(sfDir, senderId, orphanDrainBase, orphanDrainSlotCount); if (orphans.size() > 0) { org.slf4j.LoggerFactory.getLogger(LineSenderBuilder.class) .info("dispatching drainers for {} orphan slot(s) under {} " @@ -2471,6 +2488,71 @@ public LineSenderBuilder senderId(String id) { return this; } + /** + * The slot id ({@code sender_id}) currently configured on this + * builder, either parsed from the config string or left at its + * {@code "default"} default. Introspection hook for the connection + * pool, which derives a distinct per-slot id from this base so that + * multiple pooled senders sharing one {@code sf_dir} don't collide + * on the slot {@code flock}. + */ + public String getConfiguredSenderId() { + return senderId; + } + + /** + * The store-and-forward group root ({@code sf_dir}) currently + * configured on this builder, or {@code null} when SF is disabled. + * Introspection hook for the connection pool, which needs the group + * root to locate its own managed slot dirs {@code /-} + * when recovering unacked data a previous run left behind. + */ + public String getConfiguredSfDir() { + return sfDir; + } + + /** + * Excludes the connection pool's live slot set from + * {@link #drainOrphans(boolean)} scanning: a sibling slot under + * {@code sf_dir} named {@code -} with + * {@code 0 <= index < slotCount} is never treated as a drainable orphan. + *

+ * Internal introspection hook for the connection pool. The pool gives + * each pooled SF sender a distinct slot id {@code -} and + * recovers each slot's unacked data itself when it (re)creates that + * slot. Without this exclusion, one pooled sender's startup drainer + * could adopt a sibling pool slot's lock and dir, reintroducing the + * very "sf slot already in use" collision the per-slot ids were added + * to prevent. + *

+ * Unlike a blanket {@code -} prefix exclusion, the bound is the + * pool's {@code maxSize}: a same-base slot whose index is at or above + * {@code slotCount} (e.g. {@code -3} left behind by a larger pool + * before {@code maxSize} shrank from 4 to 2) is NOT excluded and is + * drained like any foreign leftover, so its unacked data is recovered + * instead of being silently stranded. Foreign leftovers (a different + * base, or a bare un-suffixed id) are also still drained. + *

+ * Pass a {@code null}/empty base or {@code slotCount <= 0} to disable + * the exclusion (the default). + */ + public LineSenderBuilder orphanDrainExcludeManagedSlots(String base, int slotCount) { + this.orphanDrainBase = base; + this.orphanDrainSlotCount = slotCount; + return this; + } + + /** + * True iff store-and-forward is enabled (an {@code sf_dir} was set). + * Introspection hook for the connection pool: SF senders own an + * exclusive on-disk slot, so each pooled sender needs its own slot + * id, whereas non-SF (memory-mode / HTTP / TCP) senders share no + * such resource and need no per-slot identity. + */ + public boolean isStoreAndForwardEnabled() { + return sfDir != null; + } + /** * Per-call deadline for {@code Sender.flush()} spinning on a full * cursor segment ring waiting for ACKs to drain space. Default diff --git a/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV3.java b/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV3.java index e5fc31b4..eff6acd5 100644 --- a/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV3.java +++ b/core/src/main/java/io/questdb/client/cutlass/line/http/LineHttpSenderV3.java @@ -129,7 +129,7 @@ public Sender decimalColumn(CharSequence name, CharSequence value) { } catch (NumericException e) { throw new LineSenderException("Failed to parse sent decimal value: " + value, e); } - var request = writeFieldName(name); + HttpClient.Request request = writeFieldName(name); request.put(value).putAscii('d'); return this; } @@ -139,7 +139,7 @@ public Sender decimalColumn(CharSequence name, Decimal256 value) { if (value == null || value.isNull()) { return this; } - var request = writeFieldName(name) + HttpClient.Request request = writeFieldName(name) .putAscii('=') .put(EntityTypes.DECIMAL) .put((byte) value.getScale()) @@ -156,7 +156,7 @@ public Sender decimalColumn(CharSequence name, Decimal128 value) { if (value == null || value.isNull()) { return this; } - var request = writeFieldName(name) + HttpClient.Request request = writeFieldName(name) .putAscii('=') .put(EntityTypes.DECIMAL) .put((byte) value.getScale()) @@ -171,7 +171,7 @@ public Sender decimalColumn(CharSequence name, Decimal64 value) { if (value == null || value.isNull()) { return this; } - var request = writeFieldName(name) + HttpClient.Request request = writeFieldName(name) .putAscii('=') .put(EntityTypes.DECIMAL) .put((byte) value.getScale()) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpSpscQueue.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpSpscQueue.java index ccc6990b..c5c292d3 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpSpscQueue.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpSpscQueue.java @@ -1,4 +1,4 @@ -/******************************************************************************* +/*+***************************************************************************** * ___ _ ____ ____ * / _ \ _ _ ___ ___| |_| _ \| __ ) * | | | | | | |/ _ \/ __| __| | | | _ \ @@ -24,6 +24,8 @@ package io.questdb.client.cutlass.qwp.client; +import io.questdb.client.std.Compat; + import java.util.concurrent.locks.LockSupport; /** @@ -112,7 +114,6 @@ public T poll() { * {@link InterruptedException} when the consumer thread was interrupted * while waiting. */ - @SuppressWarnings("unchecked") public T take() throws InterruptedException { // Fast path: the producer beat us here and the value is already visible. T value = poll(); @@ -123,7 +124,7 @@ public T take() throws InterruptedException { // inside the window. onSpinWait lets the CPU slow its pipeline while // waiting -- meaningful on hyperthreaded cores. for (int i = 0; i < SPIN_ITERATIONS; i++) { - Thread.onSpinWait(); + Compat.onSpinWait(); if ((value = poll()) != null) { return value; } 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 e34a1923..6735e7ac 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 @@ -67,6 +67,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; @@ -234,6 +235,12 @@ public class QwpWebSocketSender implements Sender { private Sender.InitialConnectMode initialConnectMode = Sender.InitialConnectMode.OFF; private boolean ownsCursorEngine; private long pendingBytes; + // Set true by close() once the SF slot flock has been released (the normal + // teardown path). Stays false if close() bailed early with the I/O thread + // still running -- then cursorEngine.close() never ran and the flock is + // still held, so the owning pool MUST keep the slot reserved rather than + // hand the still-locked dir to the next borrow ("sf slot already in use"). + private boolean slotLockReleased; private int pendingRowCount; private SenderProgressDispatcher progressDispatcher; // Async-delivery sink for ack-watermark advances. Default no-op; a @@ -279,7 +286,7 @@ private QwpWebSocketSender( if (endpoints == null || endpoints.isEmpty()) { throw new IllegalArgumentException("endpoints must be non-empty"); } - this.endpoints = List.copyOf(endpoints); + this.endpoints = Collections.unmodifiableList(new ArrayList<>(endpoints)); this.hostTracker = new QwpHostHealthTracker(this.endpoints.size()); this.authorizationHeader = authorizationHeader; this.tlsConfig = tlsConfig; @@ -1087,6 +1094,10 @@ public void close() { cursorEngine = null; ownsCursorEngine = false; } + // Past the ioThreadStopped guard => cursorEngine.close() ran and + // released the SF flock in its finally (or this sender owned no + // engine holding one). Signal the pool it may reuse the slot. + slotLockReleased = true; // Shutdown order: dispatcher last, after the I/O loop has stopped // producing into it. close() drains pending entries with a short @@ -1129,6 +1140,16 @@ public void close() { } } + /** + * True once {@link #close()} has released the store-and-forward slot + * flock. False means close() leaked the still-running I/O thread (and its + * resources), so the flock is still held; the owning pool must keep the + * slot index reserved instead of reusing the still-locked slot dir. + */ + public boolean isSlotLockReleased() { + return slotLockReleased; + } + @Override public Sender decimalColumn(CharSequence name, Decimal64 value) { checkNotClosed(); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainerPool.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainerPool.java index eab701ba..458a4b9a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainerPool.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainerPool.java @@ -24,6 +24,7 @@ package io.questdb.client.cutlass.qwp.client.sf.cursor; +import io.questdb.client.std.Compat; import io.questdb.client.std.ObjList; import io.questdb.client.std.QuietCloseable; import org.slf4j.Logger; @@ -122,7 +123,7 @@ public void close() { // ensures every submit's executor.submit has already returned // before we shut the executor down. while (state.get() != CLOSED_BIT) { - Thread.onSpinWait(); + Compat.onSpinWait(); } // Reject new tasks but let in-flight drainers finish their drain // naturally. Without this grace window a drainer that's seconds 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 e23b2252..94322e9c 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 @@ -1,4 +1,4 @@ -/******************************************************************************* +/*+***************************************************************************** * ___ _ ____ ____ * / _ \ _ _ ___ ___| |_| _ \| __ ) * | | | | | | |/ _ \/ __| __| | | | _ \ @@ -24,6 +24,7 @@ package io.questdb.client.cutlass.qwp.client.sf.cursor; +import io.questdb.client.std.Compat; import io.questdb.client.std.Files; import io.questdb.client.std.ObjList; import io.questdb.client.std.QuietCloseable; @@ -412,7 +413,7 @@ public long appendOrFsn(long payloadAddr, int payloadLen, long spinDeadlineNanos // The spin tightens the gap between manager-installs-spare and // producer-consumes-spare — usually a few µs on an idle manager thread. while (System.nanoTime() < spinDeadlineNanos) { - Thread.onSpinWait(); + Compat.onSpinWait(); fsn = ring.appendOrFsn(payloadAddr, payloadLen); if (fsn >= 0 || fsn == SegmentRing.PAYLOAD_TOO_LARGE) { return fsn; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/OrphanScanner.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/OrphanScanner.java index ba29779d..833960ec 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/OrphanScanner.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/OrphanScanner.java @@ -25,6 +25,7 @@ package io.questdb.client.cutlass.qwp.client.sf.cursor; import io.questdb.client.std.Files; +import io.questdb.client.std.IntList; import io.questdb.client.std.ObjList; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -75,13 +76,51 @@ private OrphanScanner() { * "no orphans" answer in that case. */ public static ObjList scan(String sfDir, String excludeSlotName) { + // Thin delegate to the managed-aware scan: a null managedBase disables + // managed-slot exclusion, so this is exactly the unmanaged scan. Kept + // as a convenience overload for callers (and tests) with no pool-minted + // slot namespace to skip. + return scan(sfDir, excludeSlotName, null, 0); + } + + /** + * As {@link #scan(String, String)}, but excludes only the exact + * set of slot dirs a connection pool can re-create and self-recover: + * {@code -} for {@code 0 <= i < managedSlotCount}. + *

+ * The exclusion is bounded to the canonical pool-minted slots rather than + * the whole {@code -} namespace, so unacked data is never + * stranded after a {@code maxSize} shrink across restarts: a slot like + * {@code -3} left over from a larger pool is neither re-created (out + * of the new {@code [0,maxSize)} index range) nor silently excluded. By + * bounding the exclusion to {@code [0,managedSlotCount)}, + * any same-base slot with an index at or above {@code managedSlotCount} is + * treated like a foreign leftover and becomes a drainable orphan. Its data + * is recovered either by the pool's startup recovery (which adopts these + * out-of-range same-base slots regardless of {@code drain_orphans}; see + * {@link #listStrandedOutOfRangeManagedSlots}) or, when + * {@code drain_orphans=on}, by the per-sender drain path -- never silently + * stranded. + *

+ * Only canonical, pool-minted names are excluded: the suffix after + * {@code -} must be a canonical non-negative decimal + * ({@code 0,1,2,...} with no leading zeros, sign, or non-digits). Anything + * else under the same base ({@code -foo}, {@code -007}) is not a + * name the pool creates and is reported as a candidate. + *

+ * When {@code managedBase} is null/empty or {@code managedSlotCount <= 0} + * no exclusion is applied (every sibling with data is a candidate). + */ + public static ObjList scan(String sfDir, String excludeSlotName, String managedBase, int managedSlotCount) { ObjList orphans = new ObjList<>(); if (sfDir == null || !Files.exists(sfDir)) { return orphans; } + boolean hasManaged = managedBase != null && !managedBase.isEmpty() && managedSlotCount > 0; + String managedPrefix = hasManaged ? managedBase + "-" : null; long find = Files.findFirst(sfDir); if (find < 0) { - LOG.warn("orphan scan could not enumerate {} — treating as no orphans, " + LOG.warn("orphan scan could not enumerate {} \u2014 treating as no orphans, " + "but this may indicate a permission or transient error", sfDir); return orphans; } @@ -99,6 +138,9 @@ public static ObjList scan(String sfDir, String excludeSlotName) { if (excludeSlotName != null && excludeSlotName.equals(name)) { continue; } + if (hasManaged && isManagedSlot(name, managedPrefix, managedSlotCount)) { + continue; + } String slotPath = sfDir + "/" + name; if (!isCandidateOrphan(slotPath)) { continue; @@ -111,6 +153,116 @@ public static ObjList scan(String sfDir, String excludeSlotName) { return orphans; } + /** + * Lists the canonical indices of this pool's OWN same-base slots that a + * previous run left behind out of the current index range, i.e. + * {@code -} for {@code i >= managedSlotCount} that still + * hold unacked data (no failure sentinel). + *

+ * These are not foreign orphans: they are the pool's own canonical, + * pool-minted slots from a run with a larger {@code maxSize}. Because the + * current run's index range is {@code [0, managedSlotCount)} they are never + * re-created and so never recovered by the pool's normal (re)creation path, + * yet their unacked data is durable on disk. Startup recovery uses this to + * adopt and drain them once, at construction -- so their data is delivered + * under the default config, without waiting for {@code drain_orphans=on}. + *

+ * Only exact, pool-minted names match: the suffix after + * {@code -} must be a canonical non-negative decimal + * ({@code 0,1,2,...} with no leading zeros, sign, or non-digits). Anything + * else under the same base is a foreign leftover, not a slot the pool + * created, and is left to the {@code drain_orphans} path. + * + * @return canonical indices ({@code >= managedSlotCount}) of same-base + * slots holding unacked data; empty when none, or when inputs are invalid + */ + public static IntList listStrandedOutOfRangeManagedSlots(String sfDir, String managedBase, int managedSlotCount) { + IntList out = new IntList(); + if (sfDir == null || managedBase == null || managedBase.isEmpty() + || managedSlotCount < 0 || !Files.exists(sfDir)) { + return out; + } + String managedPrefix = managedBase + "-"; + long find = Files.findFirst(sfDir); + if (find < 0) { + LOG.warn("orphan scan could not enumerate {} \u2014 treating as no stranded slots, " + + "but this may indicate a permission or transient error", sfDir); + return out; + } + if (find == 0) { + return out; + } + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + rc = Files.findNext(find); + if (name == null || ".".equals(name) || "..".equals(name)) { + continue; + } + if (!name.startsWith(managedPrefix)) { + continue; + } + int idx = parseCanonicalIndex(name, managedPrefix.length()); + if (idx < managedSlotCount) { + // Negative (non-canonical) or in-range: not our concern here. + continue; + } + if (!isCandidateOrphan(sfDir + "/" + name)) { + continue; + } + out.add(idx); + } + } finally { + Files.findClose(find); + } + return out; + } + + /** + * True iff {@code name} is a slot the pool actively co-manages, i.e. + * {@code } where {@code i} is a canonical non-negative + * decimal in {@code [0, managedSlotCount)}. Visible for testing. + */ + public static boolean isManagedSlot(String name, String managedPrefix, int managedSlotCount) { + if (name == null || managedPrefix == null || !name.startsWith(managedPrefix)) { + return false; + } + int idx = parseCanonicalIndex(name, managedPrefix.length()); + return idx >= 0 && idx < managedSlotCount; + } + + /** + * Parses the canonical non-negative decimal that makes up the rest of + * {@code name} from {@code from}. Returns {@code -1} for an empty suffix, + * a non-digit, a leading zero (e.g. {@code "007"}), or anything that would + * overflow {@code int}. Only the exact form the pool emits + * ({@code Integer.toString(index)}) is accepted, so foreign or malformed + * same-base names never get mistaken for a managed slot. + */ + private static int parseCanonicalIndex(String name, int from) { + int len = name.length(); + if (from >= len) { + return -1; + } + // Reject leading zeros unless the whole suffix is exactly "0". + if (name.charAt(from) == '0' && len - from > 1) { + return -1; + } + long acc = 0; + for (int i = from; i < len; i++) { + char c = name.charAt(i); + if (c < '0' || c > '9') { + return -1; + } + acc = acc * 10 + (c - '0'); + if (acc > Integer.MAX_VALUE) { + return -1; + } + } + return (int) acc; + } + /** * True iff {@code slotPath} looks like a slot dir with unacked data * and no failure sentinel. Visible for testing. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java index 3bd8a2ed..0b8379de 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java @@ -24,6 +24,7 @@ package io.questdb.client.cutlass.qwp.client.sf.cursor; +import io.questdb.client.std.Compat; import io.questdb.client.std.Files; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.QuietCloseable; @@ -154,7 +155,7 @@ private static String readHolder(String pidPath) { private static void writePid(String pidPath) { long pid; try { - pid = ProcessHandle.current().pid(); + pid = Compat.currentPid(); } catch (Throwable ignored) { // Diagnostic-only — never block lock acquisition on it. pid = -1L; diff --git a/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java b/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java index ecdd6fc9..d5ff3db4 100644 --- a/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java +++ b/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java @@ -31,6 +31,17 @@ */ final class PoolHousekeeper { + // How long stop() waits for the daemon to exit. Kept ABOVE + // SenderPool.RECOVERY_DRAIN_BUDGET_MILLIS so a startup-recovery drain still + // in flight when close() arrives finishes well within this join (C1 fix). + // The recovery build that precedes the drain is bounded separately -- + // recoverers force initial_connect_mode=OFF, so the build makes at most one + // connect attempt rather than a SYNC reconnect-budget retry (M1). The lone + // case that can still overrun this join is an in-flight connect to a + // black-holed host (no application-level connect timeout in the transport); + // see the residual-window note on SenderPool.recoverOneSlotStep. + static final long STOP_TIMEOUT_MILLIS = 2_000; + private final long intervalMillis; private final QueryClientPool queryPool; private final SenderPool senderPool; @@ -56,7 +67,7 @@ void stop() { signalLock.notifyAll(); } try { - thread.join(2_000); + thread.join(STOP_TIMEOUT_MILLIS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } @@ -64,15 +75,40 @@ void stop() { private void runLoop() { while (!stop) { + // Per-slot startup SF recovery, driven on THIS (the reap-loop) + // thread, one stranded slot per iteration. Doing it here rather than + // in the SenderPool constructor keeps QuestDB.build() from blocking + // on a slow or reachable-but-not-acking server. Each step does at + // most one drain bounded by SenderPool.RECOVERY_DRAIN_BUDGET_MILLIS + // (< STOP_TIMEOUT_MILLIS) on a recoverer whose initial connect is + // forced OFF (at most one connect attempt, never a SYNC + // reconnect-budget retry -- M1), and we re-check stop every step, so + // a close() landing mid-recovery normally only waits out a single + // bounded drain and the join in stop() does not time out. The sole + // residual overrun is an in-flight connect to a black-holed host; + // see SenderPool.recoverOneSlotStep. + // While recovery still has work we skip the idle wait so the backlog + // drains promptly; once done we fall back to the normal interval. + // No-op once recovery completes or the pool is closing. Best-effort: + // a recovery failure (including an Error) must never kill this + // daemon, so swallow Throwable -- exactly as the reap guards below. + boolean recovering; + try { + recovering = senderPool.runStartupRecoveryStep(); + } catch (Throwable ignored) { + recovering = false; + } synchronized (signalLock) { if (stop) { return; } - try { - signalLock.wait(intervalMillis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; + if (!recovering) { + try { + signalLock.wait(intervalMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } } } if (stop) { @@ -80,12 +116,21 @@ private void runLoop() { } try { senderPool.reapIdle(); - } catch (RuntimeException ignored) { - // Reaping must not propagate -- it's best-effort housekeeping. + } catch (Throwable ignored) { + // Defensive, intentionally unreachable in normal operation: + // SenderPool.reapIdle() already swallows per-delegate close() + // failures internally. The outer catch is a belt-and-braces + // guard. Reaping must not propagate -- it's best-effort + // housekeeping. Catch Throwable (not just RuntimeException) so + // an Error from a delegate teardown can never kill this daemon + // thread and stop all future reaping for the life of the handle. } try { queryPool.reapIdle(); - } catch (RuntimeException ignored) { + } catch (Throwable ignored) { + // Same rationale as the senderPool guard above: best-effort, + // must never propagate, and Throwable (not RuntimeException) so + // an Error from query-client teardown cannot kill the daemon. } } } 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 9e2dbbb6..61d89296 100644 --- a/core/src/main/java/io/questdb/client/impl/PooledSender.java +++ b/core/src/main/java/io/questdb/client/impl/PooledSender.java @@ -50,13 +50,21 @@ public final class PooledSender implements Sender { private final long createdAtMillis; private final Sender delegate; private final SenderPool pool; + // Index of the store-and-forward slot this wrapper owns within the pool, + // or -1 when SF is disabled. Stable for the wrapper's whole life; the + // pool returns it to the free set only when the wrapper is evicted from + // {@code all} (discardBroken / reapIdle). Used to derive a distinct + // {@code sender_id} per pooled sender so concurrent SF senders sharing + // one {@code sf_dir} never collide on the slot {@code flock}. + private final int slotIndex; private volatile long idleSinceMillis; private volatile boolean inUse; private volatile boolean invalidated; - PooledSender(Sender delegate, SenderPool pool) { + PooledSender(Sender delegate, SenderPool pool, int slotIndex) { this.delegate = delegate; this.pool = pool; + this.slotIndex = slotIndex; this.createdAtMillis = System.currentTimeMillis(); this.idleSinceMillis = this.createdAtMillis; } @@ -148,17 +156,15 @@ public void close() { if (!inUse) { return; } - boolean broken = false; + // 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; + // keying the recycle decision off normal completion treats every + // abnormal exit as unrecyclable, which is the fail-safe default. + boolean flushed = false; try { delegate.flush(); - } catch (RuntimeException e) { - // Sender does not clear its buffer on flush failure (see - // Sender Javadoc), and WebSocket transport latches the failure - // for good. Either way, the wrapper is unsafe to recycle: the - // next borrower would inherit the failed rows or a dead - // connection. - broken = true; - throw e; + flushed = true; } finally { inUse = false; // Clear the pin BEFORE returning the slot. If we cleared @@ -167,10 +173,17 @@ public void close() { // re-pin on this thread would return the (now in-use) // wrapper -- the same race this clear is meant to close. pool.clearPinIfCurrent(this); - if (broken) { - pool.discardBroken(this); - } else { + if (flushed) { pool.giveBack(this); + } else { + // flush() did not complete normally. Sender does not clear + // its buffer on flush failure (see Sender Javadoc), and + // WebSocket transport latches the failure for good. Either + // way the wrapper is unsafe to recycle: the next borrower + // would inherit the failed rows or a dead connection. The + // original throwable propagates naturally once this finally + // returns -- no explicit rethrow needed. + pool.discardBroken(this); } } } @@ -372,6 +385,10 @@ long createdAtMillis() { return createdAtMillis; } + int slotIndex() { + return slotIndex; + } + Sender delegate() { return delegate; } diff --git a/core/src/main/java/io/questdb/client/impl/QueryClientPool.java b/core/src/main/java/io/questdb/client/impl/QueryClientPool.java index 8191f162..a6365dfa 100644 --- a/core/src/main/java/io/questdb/client/impl/QueryClientPool.java +++ b/core/src/main/java/io/questdb/client/impl/QueryClientPool.java @@ -34,6 +34,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; /** * Elastic pool of {@link QueryWorker}s. Each worker pairs one @@ -46,12 +47,26 @@ * outside the pool lock; an {@code inFlightCreations} counter keeps the * cap check honest under concurrent acquires. */ -public final class QueryClientPool { +public final class QueryClientPool implements AutoCloseable { private final long acquireTimeoutMillis; private final ArrayList all; private final ArrayDeque available; private final String configurationString; + // Test seam. Production connects via QwpQueryClient.connect(); white-box + // tests in io.questdb.client.test.impl reach the package-private constructor + // by reflection to inject a hook that throws a non-RuntimeException + // Throwable (e.g. an -ea AssertionError) from the native connect path, + // exercising the Error-safe cleanup on the prewarm and acquire paths. + private final Consumer connectHook; + // Test seam. Production starts the worker's dispatch thread via + // QueryWorker.start(); white-box tests in io.questdb.client.test.impl reach + // the package-private constructor by reflection to inject a hook that throws + // a Throwable (modelling OutOfMemoryError "unable to create native thread") + // *after* createUnlocked() has returned a fully connected client, exercising + // the Error-safe client teardown on the prewarm and acquire paths -- the + // start()-throws path connectHook cannot reach. + private final Consumer startHook; private final long idleTimeoutMillis; private final ReentrantLock lock = new ReentrantLock(); private final long maxLifetimeMillis; @@ -69,10 +84,49 @@ public QueryClientPool( long acquireTimeoutMillis, long idleTimeoutMillis, long maxLifetimeMillis + ) { + this(configurationString, minSize, maxSize, acquireTimeoutMillis, + idleTimeoutMillis, maxLifetimeMillis, null); + } + + // Package-private constructor exposing the connectHook test seam: production + // passes null (-> the real QwpQueryClient.connect()). White-box tests in + // io.questdb.client.test.impl reach this by reflection to inject a hook that + // throws a non-RuntimeException Throwable from the native connect path. + QueryClientPool( + String configurationString, + int minSize, + int maxSize, + long acquireTimeoutMillis, + long idleTimeoutMillis, + long maxLifetimeMillis, + Consumer connectHook + ) { + this(configurationString, minSize, maxSize, acquireTimeoutMillis, + idleTimeoutMillis, maxLifetimeMillis, connectHook, null); + } + + // Package-private constructor exposing both the connectHook and startHook + // test seams: production passes null for each (-> the real + // QwpQueryClient.connect() and QueryWorker.start()). White-box tests in + // io.questdb.client.test.impl reach this by reflection to inject a hook that + // throws a Throwable from either the native connect path (connectHook) or + // the worker thread-start path (startHook). + QueryClientPool( + String configurationString, + int minSize, + int maxSize, + long acquireTimeoutMillis, + long idleTimeoutMillis, + long maxLifetimeMillis, + Consumer connectHook, + Consumer startHook ) { if (minSize < 0 || maxSize < 1 || minSize > maxSize) { throw new IllegalArgumentException("invalid pool sizing: min=" + minSize + ", max=" + maxSize); } + this.connectHook = connectHook != null ? connectHook : QwpQueryClient::connect; + this.startHook = startHook != null ? startHook : QueryWorker::start; this.configurationString = configurationString; this.minSize = minSize; this.maxSize = maxSize; @@ -83,19 +137,48 @@ public QueryClientPool( this.available = new ArrayDeque<>(maxSize); this.workerReleased = lock.newCondition(); int built = 0; + // Tracks a worker built by createUnlocked() but not yet added to `all`: + // it is fully connected (socket + native scratch + I/O thread) the + // instant createUnlocked() returns, yet the following start() can still + // throw (e.g. OutOfMemoryError creating the dispatch thread). Without + // this handle the cleanup loop below -- which only walks `all` -- would + // never close it, stranding exactly the I/O thread and native + // allocations this catch exists to reclaim. + QueryWorker pending = null; try { for (int i = 0; i < minSize; i++) { - QueryWorker w = createUnlocked(); - w.start(); - all.add(w); - available.add(w); + pending = createUnlocked(); + this.startHook.accept(pending); + all.add(pending); + available.add(pending); + pending = null; built++; } - } catch (RuntimeException e) { + } catch (Throwable e) { + // Catch Throwable, not just RuntimeException: createUnlocked()/start() + // run a heavy native build path that can throw an Error -- e.g. an + // -ea AssertionError or OutOfMemoryError -- mid-prewarm. If we only + // caught RuntimeException the Error would propagate without running + // the cleanup below, stranding every already-built worker's I/O + // thread and native allocations. for (int i = 0; i < built; i++) { try { all.get(i).shutdown(); - } catch (RuntimeException ignored) { + } catch (Throwable ignored) { + // Best-effort cleanup: an Error (e.g. -ea AssertionError) + // from one worker's shutdown must not strand the remaining + // pre-warmed workers nor mask the original failure below. + } + } + // Close the worker that was built but never made it into `all` + // (start() threw after createUnlocked() returned a live client). + // createUnlocked() already self-cleans when connect() throws, so + // pending is only non-null on the start()-throws path. + if (pending != null) { + try { + pending.shutdown(); + } catch (Throwable ignored) { + // Best-effort: must not mask the original failure below. } } throw e; @@ -119,15 +202,37 @@ public QueryWorker acquire() { if (all.size() + inFlightCreations < maxSize) { inFlightCreations++; lock.unlock(); - QueryWorker created; + QueryWorker created = null; try { created = createUnlocked(); - created.start(); - } catch (RuntimeException e) { + startHook.accept(created); + } catch (Throwable e) { + // Catch Throwable, not just RuntimeException: + // createUnlocked()/start() run a heavy native build path + // that can throw an Error -- e.g. an -ea AssertionError + // or OutOfMemoryError. If we only caught RuntimeException + // the Error would propagate with inFlightCreations still + // incremented, permanently shrinking pool capacity until + // every acquire() times out. Restoring the reservation + // for any throwable is safe. lock.lock(); inFlightCreations--; workerReleased.signal(); lock.unlock(); + // createUnlocked() returns a fully connected client + // (socket + native scratch + I/O thread), so if start() + // threw afterwards we must close it here -- nothing else + // references it. createUnlocked() already self-cleans + // when connect() throws, leaving created == null, so + // this only fires on the start()-throws path. + if (created != null) { + try { + created.shutdown(); + } catch (Throwable ignored) { + // Best-effort: a teardown Error must not mask the + // original creation failure rethrown below. + } + } throw new QueryException((byte) 0, "failed to create query client: " + e.getMessage(), e); } @@ -136,7 +241,9 @@ public QueryWorker acquire() { if (closed) { try { created.shutdown(); - } catch (RuntimeException ignored) { + } catch (Throwable ignored) { + // Best-effort: an Error from teardown must not mask + // the closed-pool signal. } throw new QueryException((byte) 0, "QuestDB handle is closed"); } @@ -180,7 +287,13 @@ public void close() { // join the worker threads and close their clients. Done outside the lock // so a slow join doesn't keep the pool latched. for (int i = 0; i < snapshot.size(); i++) { - snapshot.get(i).shutdown(); + try { + snapshot.get(i).shutdown(); + } catch (Throwable ignored) { + // Best-effort: a single worker's shutdown failure (including an + // Error such as an -ea AssertionError) must not abort the loop + // and strand the remaining workers unclosed. + } } } @@ -218,7 +331,10 @@ void reapIdle() { for (int i = 0, n = toShutdown.size(); i < n; i++) { try { toShutdown.get(i).shutdown(); - } catch (RuntimeException ignored) { + } catch (Throwable ignored) { + // Best-effort: a single worker's shutdown failure (including + // an Error such as an -ea AssertionError) must not abort the + // reap loop and strand the remaining reaped workers. } } } @@ -239,19 +355,36 @@ void release(QueryWorker w) { } } + // Package-private white-box accessor for tests: reports the current + // in-flight creation count under the pool lock. A non-zero value after a + // failed acquire() means the slot reservation was never released -- the + // capacity-shrink bug this guards against. + int inFlightCreations() { + lock.lock(); + try { + return inFlightCreations; + } finally { + lock.unlock(); + } + } + private QueryWorker createUnlocked() { QwpQueryClient client = QwpQueryClient.fromConfig(configurationString); try { - client.connect(); - } catch (RuntimeException e) { - // connect() may throw after QwpQueryClient.fromConfig() has already + connectHook.accept(client); + } catch (Throwable e) { + // Catch Throwable, not just RuntimeException: connect() runs a heavy + // native path that can throw an Error (e.g. an -ea AssertionError or + // OutOfMemoryError) after QwpQueryClient.fromConfig() has already // allocated native scratch (the QwpBindValues NativeBufferWriter is // field-initialised). Close the half-built client so its allocations - // are released, otherwise every connect failure during pool growth - // leaks NATIVE_DEFAULT bytes. + // are released, otherwise an Error during pool growth leaks the + // NATIVE_DEFAULT bytes that only this cleanup would reclaim. try { client.close(); - } catch (RuntimeException ignored) { + } catch (Throwable ignored) { + // Best-effort: an Error from closing the half-built client must + // not mask the original connect failure being rethrown below. } throw e; } diff --git a/core/src/main/java/io/questdb/client/impl/QueryWorker.java b/core/src/main/java/io/questdb/client/impl/QueryWorker.java index 4b251431..f4f641c8 100644 --- a/core/src/main/java/io/questdb/client/impl/QueryWorker.java +++ b/core/src/main/java/io/questdb/client/impl/QueryWorker.java @@ -105,21 +105,33 @@ void shutdown() { } finally { signalLock.unlock(); } - // If a query is in flight on this worker, ask the client to abort so - // execute() returns promptly and the thread can exit before join times - // out. cancel() is documented as thread-safe and is a no-op when idle. try { - client.cancel(); - } catch (RuntimeException ignored) { - } - try { - thread.join(SHUTDOWN_JOIN_MILLIS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - try { - client.close(); - } catch (RuntimeException ignored) { + // If a query is in flight on this worker, ask the client to abort so + // execute() returns promptly and the thread can exit before join + // times out. cancel() is documented as thread-safe and is a no-op + // when idle. + try { + client.cancel(); + } catch (Throwable ignored) { + // Best-effort. Catch Throwable, not just RuntimeException: an + // Error (e.g. an -ea AssertionError) thrown here must not skip + // the join() below or the close() in finally, which would leak + // the worker thread and the client's native socket/buffers. + } + try { + thread.join(SHUTDOWN_JOIN_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } finally { + // close() must run even if cancel()/join() threw, otherwise the + // client's native buffer pool and socket leak for the lifetime of + // the process. Catch Throwable so shutdown() itself never propagates + // a teardown Error to its callers. + try { + client.close(); + } catch (Throwable ignored) { + } } } 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 cc974ac1..5bba8d46 100644 --- a/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java +++ b/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java @@ -29,6 +29,10 @@ import io.questdb.client.Query; import io.questdb.client.Sender; import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +import java.util.function.Consumer; +import java.util.function.IntFunction; /** * Implementation of {@link QuestDB}. Owns the elastic {@link SenderPool} @@ -55,6 +59,31 @@ public QuestDBImpl( long idleTimeoutMillis, long maxLifetimeMillis, long housekeeperIntervalMillis + ) { + this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax, + acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis, + housekeeperIntervalMillis, null, null); + } + + // Package-private constructor exposing the senderFactory and connectHook test + // seams: production passes null for both (-> the real native build/connect + // paths). White-box tests in io.questdb.client.test.impl reach this by + // reflection (the main module is declared `open`) to make SenderPool prewarm + // an observable delegate while QueryClientPool construction throws an Error, + // exercising the cleanup catch below. + QuestDBImpl( + String ingestConfig, + String queryConfig, + int senderMin, + int senderMax, + int queryMin, + int queryMax, + long acquireTimeoutMillis, + long idleTimeoutMillis, + long maxLifetimeMillis, + long housekeeperIntervalMillis, + IntFunction senderFactory, + Consumer connectHook ) { SenderPool builtSenderPool = null; QueryClientPool builtQueryPool = null; @@ -62,22 +91,38 @@ public QuestDBImpl( try { builtSenderPool = new SenderPool( ingestConfig, senderMin, senderMax, acquireTimeoutMillis, - idleTimeoutMillis, maxLifetimeMillis); + idleTimeoutMillis, maxLifetimeMillis, senderFactory, + // Defer SF startup recovery to the PoolHousekeeper thread so + // build() never blocks on a slow / reachable-but-not-acking + // server; the housekeeper drives it via runStartupRecoveryStep(). + true); builtQueryPool = new QueryClientPool( queryConfig, queryMin, queryMax, acquireTimeoutMillis, - idleTimeoutMillis, maxLifetimeMillis); + idleTimeoutMillis, maxLifetimeMillis, connectHook); builtHousekeeper = new PoolHousekeeper(builtSenderPool, builtQueryPool, housekeeperIntervalMillis); builtHousekeeper.start(); - } catch (RuntimeException e) { - if (builtHousekeeper != null) { - builtHousekeeper.stop(); - } - if (builtQueryPool != null) { - builtQueryPool.close(); - } - if (builtSenderPool != null) { - builtSenderPool.close(); - } + } catch (Throwable e) { + // Catch Throwable, not just RuntimeException: this orchestrator is the + // direct caller of the SenderPool and QueryClientPool constructors, + // both of which run heavy native build/connect paths that can throw an + // Error under -ea (AssertionError, OutOfMemoryError). The pools widened + // their own prewarm catches to Throwable for exactly this reason; if we + // only caught RuntimeException here, an Error from QueryClientPool + // construction (or the housekeeper start) would propagate without + // closing the already-built SenderPool, stranding its prewarmed + // delegates' flocks, mmap'd rings, and I/O threads -- the precise leak + // class this teardown-hardening work exists to kill. The cleanup below + // is best-effort and rethrows the original failure. + // + // Each cleanup step is independently guarded: a Throwable (e.g. an OOM + // from QueryClientPool.close()'s `new ArrayList<>(all)`, or an Error + // from the housekeeper join) must never skip the remaining closes. The + // SenderPool -- owner of the flock/mmap/I/O-thread resources -- is + // closed last, so an unguarded earlier failure would strand exactly + // the resources this catch exists to reclaim. + closeQuietly(builtHousekeeper); + closeQuietly(builtQueryPool); + closeQuietly(builtSenderPool); throw e; } this.senderPool = builtSenderPool; @@ -97,9 +142,44 @@ public void close() { return; } closed = true; - housekeeper.stop(); - queryPool.close(); - senderPool.close(); + // Cancel any in-flight startup SF recovery BEFORE stopping the + // housekeeper: markClosing() raises the pool's shutdown signal so a + // recovery step driven on the housekeeper thread bails between slots and + // the housekeeper join below cannot time out waiting on a fresh slot's + // recovery drain (C1 fix #1). This only raises the flag; full pool + // teardown still happens in senderPool.close() further down. + senderPool.markClosing(); + // Independently guarded so a Throwable from one teardown step cannot skip + // the rest. senderPool is closed last and owns the flock/mmap/I/O-thread + // resources, so it must run even if housekeeper.stop()/queryPool.close() + // throws an Error or OOM. + closeQuietly(housekeeper); + closeQuietly(queryPool); + closeQuietly(senderPool); + } + + private static void closeQuietly(PoolHousekeeper housekeeper) { + if (housekeeper == null) { + return; + } + try { + housekeeper.stop(); + } catch (Throwable ignored) { + // Best-effort teardown: never let a stop() failure skip the + // subsequent pool closes. + } + } + + private static void closeQuietly(AutoCloseable closeable) { + if (closeable == null) { + return; + } + try { + closeable.close(); + } catch (Throwable ignored) { + // Best-effort teardown: never let one close() failure skip the + // remaining closes. + } } @Override 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 61b6ac69..8c9fda7a 100644 --- a/core/src/main/java/io/questdb/client/impl/SenderPool.java +++ b/core/src/main/java/io/questdb/client/impl/SenderPool.java @@ -26,6 +26,12 @@ import io.questdb.client.Sender; import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner; +import io.questdb.client.std.Files; +import io.questdb.client.std.IntList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.ArrayDeque; import java.util.ArrayList; @@ -33,6 +39,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.IntFunction; /** * Elastic pool of {@link Sender} instances, each wrapped in a @@ -49,24 +56,129 @@ * Connection creation happens outside the lock so a slow connect (TLS * handshake, DNS) does not block other borrowers or the housekeeper. The * pool tracks in-flight creations via {@code inFlightCreations} so the cap - * check ({@code allSize + inFlightCreations < maxSize}) stays correct under - * concurrent borrows. + * check ({@code allSize + inFlightCreations + closingSlots + leakedSlots < + * maxSize}) stays correct under concurrent borrows. + *

+ * Store-and-forward slots. When the configuration enables SF + * ({@code sf_dir} set), every sender owns an exclusive on-disk slot + * {@code /} guarded by a {@code flock}. A pool reuses one + * immutable config string for every sender, so without intervention all + * senders would inherit the same {@code sender_id}, point at the same slot, + * and every sender after the first would die with "sf slot already in use". + * The pool therefore hands each slot a distinct id {@code -}, + * where {@code } is the configured {@code sender_id} (default + * {@code "default"}) and {@code } is a stable pool slot index in + * {@code [0, maxSize)}. Indices are reused deterministically (lowest free + * first), so across a restart the same slot dirs are re-adopted and any + * unacked data they hold is recovered on creation. A slot is only returned + * to the free set once its delegate has released the {@code flock}, tracked + * via {@code closingSlots} so a concurrent borrow can never reclaim a slot + * dir whose lock is still held. */ public final class SenderPool implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(SenderPool.class); + // Per-slot wall-clock cap on a single startup-recovery drain. Kept BELOW the + // PoolHousekeeper stop/join budget (PoolHousekeeper.STOP_TIMEOUT_MILLIS) so a + // recovery drain still in flight when close() arrives cannot outlive the + // housekeeper join -- the residual-budget bound that, together with the + // early markClosing() signal, keeps close() prompt (C1 fix). + // + // This caps only the DRAIN. The recovery build that precedes it is bounded + // separately: recovery delegates force initial_connect_mode=OFF (see + // defaultRecoverySender) so the build does at most ONE connect attempt + // rather than a SYNC reconnect-budget retry loop (M1). One in-flight + // connect against a black-holed host still blocks on the OS connect timeout + // -- the residual window documented on recoverOneSlotStep -- because the + // transport has no application-level connect timeout to clamp it. + private static final long RECOVERY_DRAIN_BUDGET_MILLIS = 1_000; private final long acquireTimeoutMillis; private final ArrayList all; private final ArrayDeque available; private final String configurationString; private final long idleTimeoutMillis; + // Test seam. Production builds delegates via defaultSender(); white-box + // tests in io.questdb.client.test.impl reach the package-private + // constructor by reflection to inject a factory that throws a non- + // RuntimeException Throwable (e.g. an -ea AssertionError) mid-prewarm, + // exercising the Error-safe delegate cleanup loop. + private final IntFunction senderFactory; + // Factory for startup-recovery delegates. Distinct from senderFactory so a + // recoverer can force a non-blocking initial connect (initial_connect_mode= + // OFF) regardless of user config: a recovery build runs on the + // PoolHousekeeper thread and must NOT inherit SYNC (auto-enabled by any + // reconnect_* knob), which would retry the connect for the whole reconnect + // budget inside build() -- far past PoolHousekeeper.STOP_TIMEOUT_MILLIS, so + // a close() landing during that build would make housekeeper.stop()'s join + // time out and leave the recoverer holding the slot flock after close() + // returned (M1). Mirrors senderFactory's test seam: an injected factory + // (non-null) drives BOTH paths so white-box recovery tests keep control. + private final IntFunction recoverySenderFactory; private final ReentrantLock lock = new ReentrantLock(); private final long maxLifetimeMillis; private final int maxSize; private final int minSize; + // SF slot base id (configured sender_id, default "default") when SF is + // enabled; null otherwise. Each pooled sender's slot id is + // {@code slotBaseId + "-" + slotIndex}. + private final String slotBaseId; + // SF group root (sf_dir) when SF is enabled; null otherwise. Used to + // locate this pool's own managed slot dirs /- + // for startup recovery of unacked data left by a previous run. + private final String sfDir; + // Reservation bitmap for SF slot indices [0, maxSize). Guarded by lock. + // null when SF is disabled (no per-slot identity needed). + private final boolean[] slotInUse; private final Condition slotReleased; + // True iff the configuration enables store-and-forward (sf_dir set). + private final boolean storeAndForward; private final ThreadLocal threadAffine = new ThreadLocal<>(); + // Slots removed from `all` whose delegate is still releasing its flock. + // They keep reserving capacity (and their slotInUse mark) until the + // flock drops, so the cap check and the slot allocator stay consistent + // and no concurrent borrow can reclaim a slot dir that is still locked. + // Guarded by lock. Only ever ticks for SF slots. + private int closingSlots; + // Shutdown signal: "the pool is shutting down". markClosing() raises it early + // (without tearing down delegates) so an in-flight startup-recovery step + // stops promptly between slots; close() also raises it. Read on the hot + // paths (borrow/giveBack/discardBroken/reapIdle/recovery). private volatile boolean closed; + // True once close() has begun the one-and-only delegate teardown. Distinct + // from `closed` so markClosing() can raise the shutdown signal early + // (cancelling recovery) WITHOUT making a later close() short-circuit the + // teardown. Guarded by lock. + private boolean closeStarted; private int inFlightCreations; + // Slots whose delegate close() returned with the SF flock still held + // (the I/O thread refused to stop). Permanently consumed: the index is + // never freed and never reused, so no borrow ever hands out a still- + // locked slot dir. Counted in the cap check so the lost capacity is + // accounted for. Guarded by lock; only ever ticks for SF slots. + private int leakedSlots; + // SF slots currently held by the in-range startup-recovery pass + // (recoverOneSlotStep): each is reserved under `lock` for the + // duration of its drain and counted in the borrow() cap check so a + // concurrent borrow can neither over-allocate past maxSize nor target a + // dir being recovered. Only ever non-zero on the deferred (housekeeper- + // driven) recovery path, where recovery overlaps borrow()/return; on the + // inline construction path the pool is still single-threaded. Guarded by + // lock; only ever ticks for SF slots. + private int recoveringSlots; + // Resumable startup-recovery scan cursor. Advanced only by the single + // recovery driver -- the inline constructor loop (single-threaded, + // unpublished) or the PoolHousekeeper thread (the sole deferred driver) -- + // so the cursor itself needs no lock; the per-slot reservation it performs + // (slotInUse/recoveringSlots) is still taken under `lock` because borrow() + // races it. recoveryInRangeNext is the next in-range index in [0, maxSize) + // for pass 1; recoveryOutOfRange / recoveryOutOfRangeNext are the lazily + // built pass-2 work list (same-base slots at index >= maxSize) and its + // cursor; recoveryComplete latches true when the whole scan finishes or is + // aborted, making runStartupRecoveryStep()/...ToCompletion() idempotent. + private int recoveryInRangeNext; + private IntList recoveryOutOfRange; + private int recoveryOutOfRangeNext; + private boolean recoveryComplete; public SenderPool( String configurationString, @@ -75,10 +187,55 @@ public SenderPool( long acquireTimeoutMillis, long idleTimeoutMillis, long maxLifetimeMillis + ) { + this(configurationString, minSize, maxSize, acquireTimeoutMillis, + idleTimeoutMillis, maxLifetimeMillis, null); + } + + // Package-private constructor exposing the senderFactory test seam: + // production passes null (-> the real defaultSender()). White-box tests in + // io.questdb.client.test.impl reach this by reflection to inject a factory + // that throws a non-RuntimeException Throwable mid-prewarm. Recovery runs + // inline here (deferStartupRecovery=false); the pooled QuestDB handle uses + // the 8-arg overload to defer it to the housekeeper thread. + SenderPool( + String configurationString, + int minSize, + int maxSize, + long acquireTimeoutMillis, + long idleTimeoutMillis, + long maxLifetimeMillis, + IntFunction senderFactory + ) { + this(configurationString, minSize, maxSize, acquireTimeoutMillis, + idleTimeoutMillis, maxLifetimeMillis, senderFactory, false); + } + + // Full constructor. deferStartupRecovery=true skips the inline, + // construction-time SF recovery (recoverOneSlotStep) so + // QuestDB.build() never blocks on a slow or reachable-but-not-acking + // server; the owner (QuestDBImpl) then drives recovery one slot per tick on + // the PoolHousekeeper thread via runStartupRecoveryStep(). The in-range + // recovery pass is concurrency-safe against borrow()/return on that + // deferred path -- see recoverOneSlotStep(). + SenderPool( + String configurationString, + int minSize, + int maxSize, + long acquireTimeoutMillis, + long idleTimeoutMillis, + long maxLifetimeMillis, + IntFunction senderFactory, + boolean deferStartupRecovery ) { if (minSize < 0 || maxSize < 1 || minSize > maxSize) { throw new IllegalArgumentException("invalid pool sizing: min=" + minSize + ", max=" + maxSize); } + this.senderFactory = senderFactory != null ? senderFactory : this::defaultSender; + // An injected factory (tests) drives recovery too, preserving the + // white-box recovery seam; production recovery forces OFF-mode connects + // via defaultRecoverySender (see field comment / createRecoverer). + this.recoverySenderFactory = senderFactory != null ? senderFactory : this::defaultRecoverySender; this.configurationString = configurationString; this.minSize = minSize; this.maxSize = maxSize; @@ -88,24 +245,383 @@ public SenderPool( this.all = new ArrayList<>(maxSize); this.available = new ArrayDeque<>(maxSize); this.slotReleased = lock.newCondition(); - // Pre-warm minSize connections. + // Probe the config once, up front: this validates it eagerly (so a + // bad config fails at construction even when minSize == 0) and tells + // us whether SF is on and, if so, the base slot id to derive + // per-sender ids from. + Sender.LineSenderBuilder probe = Sender.builder(configurationString); + this.storeAndForward = probe.isStoreAndForwardEnabled(); + this.slotBaseId = this.storeAndForward ? probe.getConfiguredSenderId() : null; + this.sfDir = this.storeAndForward ? probe.getConfiguredSfDir() : null; + this.slotInUse = this.storeAndForward ? new boolean[maxSize] : null; + // Pre-warm minSize connections. Pre-warm runs single-threaded in the + // constructor, so slots 0..minSize-1 are reserved directly. int built = 0; try { for (int i = 0; i < minSize; i++) { - PooledSender ps = createUnlocked(); + if (storeAndForward) { + slotInUse[i] = true; + } + PooledSender ps = createUnlocked(storeAndForward ? i : -1); all.add(ps); available.add(ps); built++; } - } catch (RuntimeException e) { + } catch (Throwable e) { + // Catch Throwable, not just RuntimeException: createUnlocked() runs a + // heavy native build path (mmap, flock, WebSocket connect) that can + // throw an Error -- e.g. an -ea AssertionError or OutOfMemoryError -- + // mid-prewarm. If we only caught RuntimeException the Error would + // propagate without running the cleanup below, leaking every + // already-built delegate's flock + mmap'd ring + I/O thread and + // resurrecting "sf slot already in use" on the next attempt. for (int i = 0; i < built; i++) { try { all.get(i).delegate().close(); - } catch (RuntimeException ignored) { + } catch (Throwable ignored) { + // Best-effort cleanup: a delegate close() can throw an + // Error (e.g. an -ea AssertionError) as well as a + // RuntimeException. Swallow either so we still close the + // remaining pre-warmed slots and rethrow the original + // construction failure below. } } throw e; } + // Prewarm succeeded. Recover any unacked data a previous run left in + // this pool's own managed slots that prewarm did not already re-adopt. + // The pooled QuestDB handle defers this to the housekeeper thread + // (deferStartupRecovery=true) so QuestDB.build() never blocks on a slow + // or reachable-but-not-acking server; direct constructions run it inline + // here, while still single-threaded and unpublished. + if (!deferStartupRecovery) { + runStartupRecoveryToCompletion(); + } + } + + /** + * Drives startup SF recovery to completion in a single call, bounded by one + * shared {@code acquireTimeoutMillis} wall-clock budget (and each individual + * drain by {@link #RECOVERY_DRAIN_BUDGET_MILLIS}). Used by the inline + * construction path -- single-threaded and unpublished -- and by manual / + * test drives. No-op when SF is off, the pool is shutting down, or recovery + * has already finished. Idempotent. + */ + void runStartupRecoveryToCompletion() { + if (!storeAndForward) { + return; + } + // One shared wall-clock budget for the WHOLE scan, not per slot: without + // it a reachable-but-not-acking server would pay a full drain timeout on + // every stranded slot. One acquire timeout is the ceiling already + // accepted for a single borrow, so we reuse it as the total budget; once + // spent, the remaining slots wait for a later attempt (data stays + // durable on disk). + final long deadlineNanos = + System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(acquireTimeoutMillis); + while (!closed && !recoveryComplete) { + long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime()); + if (remainingMillis <= 0) { + LOG.warn("startup SF recovery: {}ms budget exhausted; " + + "skipping remaining slots", acquireTimeoutMillis); + recoveryComplete = true; + return; + } + if (!recoverOneSlotStep(Math.min(remainingMillis, RECOVERY_DRAIN_BUDGET_MILLIS))) { + return; + } + } + } + + /** + * Recovers at most ONE stranded managed slot and reports whether more remain. + * Driven by the {@link PoolHousekeeper} one slot per step, back-to-back, on + * the reap-loop thread: each step performs at most one drain bounded by + * {@link #RECOVERY_DRAIN_BUDGET_MILLIS} -- kept below the housekeeper + * stop/join budget -- on a delegate whose initial connect is forced OFF + * ({@link #defaultRecoverySender}) so the build makes at most one connect + * attempt. The housekeeper re-checks {@code stop} between steps while + * {@link QuestDBImpl#close()} raises the {@code closed} shutdown signal (via + * {@link #markClosing()}) BEFORE stopping it, so a {@code close()} landing + * mid-recovery normally only has to wait out a single bounded drain. The one + * exception is an in-flight connect to a black-holed host, which blocks on + * the OS connect timeout -- see the residual-window note on + * {@link #recoverOneSlotStep}. + * No-op (returns {@code false}) when SF is off, the pool is shutting down, or + * recovery has already finished. + * + * @return {@code true} if recovery has more work (call again), {@code false} + * when recovery is complete or the pool is shutting down + */ + boolean runStartupRecoveryStep() { + if (!storeAndForward || closed || recoveryComplete) { + return false; + } + return recoverOneSlotStep(RECOVERY_DRAIN_BUDGET_MILLIS); + } + + /** + * Best-effort recovery of unacked data left in this pool's own managed SF + * slots by a previous run -- both the in-range slots {@code [0, maxSize)} + * (pass 1) and the same-base slots a larger previous run left OUT of range + * ({@code -i} with {@code i >= maxSize}, pass 2). Performs at most ONE + * actual drain per call, advancing the resumable scan cursor, and returns + * whether more stranded slots remain. + *

+ * Every pooled SF sender's orphan drainer deliberately excludes the whole + * {@code [0, maxSize)} managed range (via + * {@code orphanDrainExcludeManagedSlots}) so a sibling never adopts a slot + * dir/lock the pool intends to (re)create -- that exclusion is what keeps the + * per-slot ids from resurfacing "sf slot already in use". The trade-off is + * that an in-range slot left holding unacked data is otherwise recovered ONLY + * when the pool happens to (re)create that index; under steady low load the + * pool may never grow to a high index, stranding that slot's data (durable on + * disk, but undelivered) until a later restart or load spike. An out-of-range + * slot is worse off still: the pool never re-creates its index, and the + * per-sender drainer only adopts it when {@code drain_orphans=on}. This scan + * closes both gaps regardless of {@code drain_orphans}. + *

+ * The in-range pass reserves each slot index under {@code lock} for the + * duration of its recovery AND counts it in the borrow() capacity check (via + * {@code recoveringSlots}), so a concurrent borrow on the deferred path can + * neither target the dir being recovered nor over-allocate past + * {@code maxSize}. Prewarmed/borrowed slots (already live, holding their + * flock) are skipped, as are empty slots (a cheap directory probe); only a + * slot that actually holds stranded data spends the step's single drain. The + * out-of-range pass needs no reservation: those indices have no + * {@code slotInUse} entry and are never allocated by borrow(). + *

+ * Best-effort throughout: a build/close Error or a slow drain is logged and + * never propagates, since the data stays durable on disk for a later attempt; + * the first build failure or drain timeout latches {@code recoveryComplete} + * (the failure will very likely repeat for every remaining slot). + *

+ * Boundedness / residual window. Recovery is driven on the + * PoolHousekeeper thread, and {@code close()} relies on a step finishing + * within {@code PoolHousekeeper.STOP_TIMEOUT_MILLIS}. A step is build + + * drain + close. The drain is capped by {@link #RECOVERY_DRAIN_BUDGET_MILLIS} + * and the build forces {@code initial_connect_mode=OFF} (see + * {@link #defaultRecoverySender}), so it makes at most ONE connect attempt + * instead of a SYNC reconnect-budget retry loop. That removes the + * minutes-long block a {@code reconnect_*}-tuned config used to cause (M1). + * One residual window remains and is NOT closed here: a single in-flight + * connect to a black-holed/firewalled host blocks on the OS connect timeout + * (the transport exposes no application-level connect timeout to clamp it). + * If {@code close()} lands during that one connect the housekeeper join can + * still time out and the detached build releases the slot flock shortly + * after {@code close()} returns. No data is lost (the slot stays durable on + * disk); the exposure is a brief "sf slot already in use" window on an + * immediate reopen, bounded by a single OS connect timeout. + * + * @return {@code true} if a drain was performed and more slots may remain; + * {@code false} once the scan is complete or the pool is shutting down + */ + private boolean recoverOneSlotStep(long stepBudgetMillis) { + if (sfDir == null || !Files.exists(sfDir)) { + recoveryComplete = true; + return false; + } + final boolean[] flockHeld = new boolean[1]; + + // Pass 1: in-range managed slots [0, maxSize). Skip live and empty slots + // cheaply; spend the step on the first slot that actually holds data. + while (recoveryInRangeNext < maxSize) { + if (closed) { + return false; + } + int i = recoveryInRangeNext; + // Reserve this index unless prewarm (or a concurrent borrow, on the + // deferred path) already holds it live. Count the reservation in + // recoveringSlots so the borrow() cap check cannot over-allocate + // while this slot is held for recovery. + boolean reserved; + lock.lock(); + try { + reserved = slotInUse[i]; + if (!reserved) { + slotInUse[i] = true; + recoveringSlots++; + } + } finally { + lock.unlock(); + } + if (reserved) { + recoveryInRangeNext++; + continue; + } + String slotPath = sfDir + "/" + slotBaseId + "-" + i; + if (!OrphanScanner.isCandidateOrphan(slotPath)) { + // No stranded data: release the reservation and keep scanning; + // an empty slot must not cost a whole step. + lock.lock(); + try { + recoveringSlots--; + slotInUse[i] = false; + slotReleased.signal(); + } finally { + lock.unlock(); + } + recoveryInRangeNext++; + continue; + } + // A real candidate -> spend the step on it. Advance the cursor first + // so a resume never reprocesses this index. + recoveryInRangeNext++; + boolean stopScan = drainCandidateSlotForRecovery(i, slotPath, stepBudgetMillis, flockHeld); + lock.lock(); + try { + // Release the recovery reservation accounting; from here either + // leakedSlots (retire) or the freed index carries the cap math. + recoveringSlots--; + if (flockHeld[0]) { + // close() bailed early with the I/O thread still running and + // the flock still held. Retire the slot permanently (mirror + // discardBroken/reapIdle): keep slotInUse[i] set and count it + // in leakedSlots so the borrow() cap math accounts for the + // lost capacity and no later borrow ever reuses the + // still-locked dir. + leakedSlots++; + LOG.warn("startup SF recovery: slot {} retired permanently: delegate close() returned with " + + "the flock still held (I/O thread refused to stop); pool capacity reduced by 1, " + + "now {} of {} usable [leakedSlots={}]", + i, maxSize - leakedSlots, maxSize, leakedSlots); + } else { + slotInUse[i] = false; + // On the deferred path a borrow may be waiting on capacity + // this recovery held; the freed index can now admit a + // creation. + slotReleased.signal(); + } + } finally { + lock.unlock(); + } + if (stopScan) { + // A build failure or drain timeout that will very likely repeat + // for every remaining slot -- abort the scan; the data stays + // durable on disk for a later attempt. Do not start pass 2. + recoveryComplete = true; + return false; + } + return true; + } + + // Pass 1 done. Build the pass-2 work list once: same-base slots a + // previous run left OUT of the current index range (-i with + // i >= maxSize, from a run with a larger maxSize). The pool never + // re-creates these indices, and the per-sender drainer only adopts them + // when drain_orphans=on, so without this pass their unacked data would + // sit durable-but-undelivered under the default config. They are outside + // [0, maxSize), have no slotInUse entry, and never affect the borrow() + // cap math, so no reservation is needed. + if (recoveryOutOfRange == null) { + recoveryOutOfRange = OrphanScanner.listStrandedOutOfRangeManagedSlots( + sfDir, slotBaseId, maxSize); + recoveryOutOfRangeNext = 0; + } + while (recoveryOutOfRangeNext < recoveryOutOfRange.size()) { + if (closed) { + return false; + } + int idx = recoveryOutOfRange.getQuick(recoveryOutOfRangeNext++); + String slotPath = sfDir + "/" + slotBaseId + "-" + idx; + if (!OrphanScanner.isCandidateOrphan(slotPath)) { + continue; + } + boolean stopScan = drainCandidateSlotForRecovery(idx, slotPath, stepBudgetMillis, flockHeld); + if (flockHeld[0]) { + // Out of the pool's [0, maxSize) capacity range: there is no + // slotInUse entry to retire and no future borrow targets this + // dir, so a still-held flock only leaks this recoverer's I/O + // thread (a best-effort teardown loss, logged). Crucially we do + // NOT touch leakedSlots -- that would wrongly shrink the + // in-range pool capacity. + LOG.warn("startup SF recovery: out-of-range slot {} closed with the flock still held " + + "(I/O thread refused to stop); its data is durable on disk for a later attempt", + slotPath); + } + if (stopScan) { + recoveryComplete = true; + return false; + } + return true; + } + + recoveryComplete = true; + return false; + } + + /** + * Drains one candidate orphan slot dir within {@code remainingMillis}, + * best-effort and never throwing. Builds a recoverer on {@code slotIndex} + * (whose {@link #defaultSender} derives the dir {@code -slotIndex}), + * drains its unacked data, and closes the delegate. Shared by both recovery + * passes -- the in-range pass and the out-of-range pass -- which differ only + * in their slot bookkeeping, handled by the caller via {@code flockHeld}. + * + * @param flockHeld single-element out-param set to {@code true} iff a + * recoverer was built and its {@code close()} returned with + * the flock still held (the I/O thread refused to stop) + * @return {@code true} if a build/drain failure occurred that will very + * likely repeat for every remaining slot, so the caller should stop scanning + */ + private boolean drainCandidateSlotForRecovery(int slotIndex, String slotPath, + long remainingMillis, boolean[] flockHeld) { + flockHeld[0] = false; + // Hoisted so the flock check after the try can consult it: + // createRecoverer() takes the slot flock on -slotIndex, and + // delegate().close() can early-return with the I/O thread still running + // (flock still held). + PooledSender recoverer = null; + boolean stopScan = false; + try { + if (!OrphanScanner.isCandidateOrphan(slotPath)) { + return false; + } + try { + // Recovery delegate: forced OFF-mode initial connect (see + // createRecoverer / defaultRecoverySender), so this build does + // at most ONE connect attempt -- it never inherits the SYNC + // reconnect-budget retry loop that would block this + // (PoolHousekeeper) thread for minutes (M1). + recoverer = createRecoverer(slotIndex); + } catch (Throwable buildErr) { + // A build/connect failure (e.g. server unreachable) will very + // likely repeat for every remaining slot, so stop here rather + // than pay a connect timeout per slot. + LOG.warn("startup SF recovery: could not open slot {} ({}); " + + "skipping remaining slots", slotPath, buildErr.toString()); + return true; + } + try { + // Cap the drain at the remaining shared budget and short-circuit + // 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.drain(remainingMillis)) { + LOG.warn("startup SF recovery: drain did not ack slot {} " + + "within {}ms; skipping remaining slots", + slotPath, remainingMillis); + stopScan = true; + } + } catch (Throwable drainErr) { + LOG.warn("startup SF recovery: drain failed for slot {} ({})", + slotPath, drainErr.toString()); + } finally { + try { + recoverer.delegate().close(); + } catch (Throwable ignored) { + // Best-effort close: a teardown Error must not abort + // recovery of the remaining slots. + } + } + } catch (Throwable scanErr) { + LOG.warn("startup SF recovery: scan failed for slot {} ({})", + slotPath, scanErr.toString()); + } + if (recoverer != null) { + flockHeld[0] = !flockReleased(recoverer); + } + return stopScan; } public PooledSender borrow() { @@ -124,15 +640,31 @@ public PooledSender borrow() { s.markInUse(); return s; } - if (all.size() + inFlightCreations < maxSize) { + if (all.size() + inFlightCreations + closingSlots + leakedSlots + recoveringSlots < maxSize) { inFlightCreations++; + // Reserve a slot index under the lock so concurrent + // creations never target the same SF slot dir. -1 when + // SF is off (no per-slot identity needed). + int slotIndex = storeAndForward ? allocateSlotIndex() : -1; lock.unlock(); PooledSender created; try { - created = createUnlocked(); - } catch (RuntimeException e) { + created = createUnlocked(slotIndex); + } catch (Throwable e) { + // Catch Throwable, not just RuntimeException: + // createUnlocked() runs a heavy native build path + // (mmap, flock, WebSocket connect) that can throw an + // Error -- e.g. an -ea AssertionError or + // OutOfMemoryError. If we only caught RuntimeException + // the Error would propagate with inFlightCreations + // still incremented and the SF slot index still + // reserved (slotInUse[idx] stuck true), permanently + // lowering pool capacity. The cleanup below is + // idempotent, so undoing the reservation for any + // throwable is safe. lock.lock(); inFlightCreations--; + freeSlotIndex(slotIndex); slotReleased.signal(); lock.unlock(); throw e; @@ -143,9 +675,12 @@ public PooledSender borrow() { // Pool was closed mid-creation -- destroy the new connection // rather than leaking it. Other waiters have been signaled // by close() already. + freeSlotIndex(slotIndex); try { created.delegate().close(); - } catch (RuntimeException ignored) { + } catch (Throwable ignored) { + // Best-effort: an Error (e.g. -ea AssertionError) + // from teardown must not mask the closed-pool signal. } throw new LineSenderException("QuestDB handle is closed"); } @@ -171,14 +706,30 @@ public PooledSender borrow() { } } + /** + * Raises the shutdown signal early -- without tearing down live delegates -- + * so an in-flight startup-recovery step driven on the {@link PoolHousekeeper} + * thread stops promptly between slots. {@link QuestDBImpl#close()} calls this + * BEFORE stopping the housekeeper, so the housekeeper join cannot time out + * waiting on a fresh slot's recovery drain. Full delegate teardown still + * happens in {@link #close()} (guarded by {@code closeStarted}, so this early + * signal never short-circuits it). Idempotent; safe to call repeatedly. + */ + void markClosing() { + closed = true; + } + @Override public void close() { PooledSender[] snapshot; lock.lock(); try { - if (closed) { + if (closeStarted) { return; } + closeStarted = true; + // Raise the shutdown signal too (a direct, non-pooled caller may + // close() without a prior markClosing()); harmless if already set. closed = true; // Mark every pooled wrapper invalidated so pinToCurrentThread() // on other threads -- which never takes this lock -- can detect @@ -205,7 +756,9 @@ public void close() { for (int i = 0; i < snapshot.length; i++) { try { snapshot[i].delegate().close(); - } catch (RuntimeException ignored) { + } catch (Throwable ignored) { + // Best-effort: an Error from one delegate's teardown must not + // abort the loop and strand the remaining delegates unclosed. } } } @@ -241,21 +794,53 @@ void clearPinIfCurrent(PooledSender s) { */ void discardBroken(PooledSender s) { s.markInvalidated(); + boolean reserved = false; lock.lock(); try { if (closed) { return; } - all.remove(s); - // Wake one waiter -- the cap check in borrow() uses all.size(), - // so a freed slot may now allow a creation attempt. + boolean removed = all.remove(s); + // For an SF slot, keep its index reserved (move the reservation + // from `all` to `closingSlots`) until the delegate below releases + // the flock. Capacity stays accounted for, so a concurrent borrow + // cannot reclaim this slot dir while its lock is still held. + if (removed && s.slotIndex() >= 0) { + closingSlots++; + reserved = true; + } + // Wake one waiter -- the cap check in borrow() may now admit a + // creation attempt (on a *different* slot). slotReleased.signal(); } finally { lock.unlock(); } + // Close the delegate outside the lock (releases the SF flock). Always + // attempt it so native resources are freed even on the defensive path + // where the wrapper had already left `all`. try { s.delegate().close(); - } catch (RuntimeException ignored) { + } catch (Throwable ignored) { + // Best-effort teardown: a delegate close() can throw an Error + // (e.g. an -ea AssertionError) as well as a RuntimeException. + // Either way the slot accounting in the finally below MUST run, + // otherwise an SF slot stays reserved forever (slotInUse stuck + // true, closingSlots over-counted) and the pool leaks capacity + // until borrow() can only ever time out. + } finally { + if (reserved) { + lock.lock(); + try { + // Free the index only when the flock was released; a slot + // left locked is retired permanently. Signal a waiter only + // on the free path, where a new creation can now be admitted. + if (reclaimSlot(s, "")) { + slotReleased.signal(); + } + } finally { + lock.unlock(); + } + } } } @@ -314,6 +899,11 @@ public void reapIdle() { if (idleExpired || overAge) { it.remove(); all.remove(s); + // Keep the SF slot reserved until its flock is released + // below (see discardBroken for the rationale). + if (s.slotIndex() >= 0) { + closingSlots++; + } if (toClose == null) { toClose = new ArrayList<>(); } @@ -327,7 +917,30 @@ public void reapIdle() { for (int i = 0, n = toClose.size(); i < n; i++) { try { toClose.get(i).delegate().close(); - } catch (RuntimeException ignored) { + } catch (Throwable ignored) { + // Best-effort: a single delegate close() failure (including + // an Error such as an -ea AssertionError) must not abort the + // loop -- that would leave sibling flocks unreleased -- nor + // skip the slot-accounting release block below, which would + // strand every reaped index (slotInUse stuck true, + // closingSlots over-counted) and leak pool capacity. + } + } + // Return reserved SF slot indices to the free set -- but only for + // slots whose delegate confirmed the flock was released. A slot + // left locked (I/O thread refused to stop) is retired permanently. + if (storeAndForward) { + lock.lock(); + try { + for (int i = 0, n = toClose.size(); i < n; i++) { + PooledSender s = toClose.get(i); + if (s.slotIndex() >= 0) { + reclaimSlot(s, " during idle reaping"); + } + } + slotReleased.signalAll(); + } finally { + lock.unlock(); } } } @@ -353,6 +966,23 @@ public int totalSize() { } } + /** + * Snapshot of the number of SF slots permanently retired because a + * delegate {@code close()} returned with the slot flock still held (the + * I/O thread refused to stop). Each leaked slot permanently lowers the + * pool's effective capacity ({@code maxSize - leakedSlotCount()}). A + * non-zero, growing value explains a pool that has started timing out + * every {@code borrow()}. For metrics and tests. + */ + public int leakedSlotCount() { + lock.lock(); + try { + return leakedSlots; + } finally { + lock.unlock(); + } + } + public void releaseCurrentThread() { PooledSender pinned = threadAffine.get(); if (pinned == null) { @@ -366,8 +996,173 @@ public void releaseCurrentThread() { pinned.close(); } - private PooledSender createUnlocked() { - Sender raw = Sender.fromConfig(configurationString); - return new PooledSender(raw, this); + private PooledSender createUnlocked(int slotIndex) { + return new PooledSender(senderFactory.apply(slotIndex), this, slotIndex); + } + + /** + * Builds a {@link PooledSender} for startup recovery of one stranded slot. + * Routes through {@link #recoverySenderFactory}, which in production forces + * a non-blocking initial connect ({@link #defaultRecoverySender}) so a + * single recovery step stays bounded -- see that method and + * {@link #drainCandidateSlotForRecovery}. + */ + private PooledSender createRecoverer(int slotIndex) { + return new PooledSender(recoverySenderFactory.apply(slotIndex), this, slotIndex); + } + + private Sender defaultSender(int slotIndex) { + return buildManagedSlotSender(slotIndex, false); + } + + /** + * Same managed-slot delegate as {@link #defaultSender}, but with the + * initial connect forced to {@link Sender.InitialConnectMode#OFF}. Used + * only for startup recovery, which runs on the PoolHousekeeper thread: OFF + * makes the build do at most ONE connect attempt instead of retrying for + * the whole reconnect budget (SYNC, auto-enabled by any reconnect_* knob), + * keeping a recovery step bounded below + * {@code PoolHousekeeper.STOP_TIMEOUT_MILLIS}. See M1 / the residual-window + * note on {@link #recoverOneSlotStep}. + *

+ * Also forces {@code drain_orphans=off} (see + * {@link #buildManagedSlotSender}): a recovery delegate must never spin up a + * BackgroundDrainerPool, whose {@code close()} could block ~3s and overrun + * the step / {@code STOP_TIMEOUT_MILLIS} budget while still holding the slot + * flock. + */ + private Sender defaultRecoverySender(int slotIndex) { + return buildManagedSlotSender(slotIndex, true); + } + + private Sender buildManagedSlotSender(int slotIndex, boolean forRecovery) { + if (!storeAndForward) { + return Sender.fromConfig(configurationString); + } + // Give this pooled sender its own slot dir /- + // so concurrent SF senders sharing one sf_dir never collide on + // the slot flock. senderId() is only legal on WebSocket transport, + // which is exactly when storeAndForward is true. + // + // Also fence off the pool's own live slot set [0, maxSize) from + // orphan draining: the pool co-manages every - slot it + // can re-create and recovers each slot's unacked data when it + // (re)creates it, so a sibling's startup drainer must never adopt + // another live pool slot's dir/lock (that would resurrect "sf slot + // already in use"). The bound is maxSize, NOT the whole "-" + // prefix: a same-base slot at an index >= maxSize (left behind when + // a previous run used a larger maxSize) is out of the pool's index + // range forever, so it is left drainable here. Its unacked data is + // delivered by the pool's own startup recovery + // (recoverOneSlotStep, pass 2), which adopts these + // out-of-range same-base slots at construction REGARDLESS of + // drain_orphans -- so the default config never strands it. The + // per-sender drainer is an additional path that only runs when + // drain_orphans=on; foreign leftovers under other names are drained + // only by that path. + Sender.LineSenderBuilder builder = Sender.builder(configurationString) + .senderId(slotBaseId + "-" + slotIndex) + .orphanDrainExcludeManagedSlots(slotBaseId, maxSize); + if (forRecovery) { + // Force OFF so the recovery build never blocks on the reconnect + // budget (see defaultRecoverySender). An explicit mode wins over + // the SYNC auto-promotion the user's reconnect_* knobs would + // otherwise trigger. + builder.initialConnectMode(Sender.InitialConnectMode.OFF); + // Force drain_orphans OFF on recovery delegates regardless of the + // shared config string. A recovery delegate's sole job is to drain + // its OWN slot (the one recoverOneSlotStep is processing); it must + // never start a BackgroundDrainerPool for sibling/foreign orphans. + // If it did, the delegate's close() -- called from + // drainCandidateSlotForRecovery() on the PoolHousekeeper thread, + // BEFORE its cursorEngine.close() releases the slot flock -- would + // block in BackgroundDrainerPool.close() for up to + // GRACEFUL_DRAIN_MILLIS + STOP_GRACE_MILLIS (3s) against a + // reachable-but-not-acking server. That overruns a recovery step's + // budget (RECOVERY_DRAIN_BUDGET_MILLIS) and PoolHousekeeper + // .STOP_TIMEOUT_MILLIS, so a close() landing mid-step times out its + // join and returns while the recoverer still holds the slot flock + // -- resurrecting the "sf slot already in use" window this pool's + // per-slot ids exist to eliminate. Sibling in-range slots are + // covered by recoverOneSlotStep's own passes; foreign/out-of-range + // orphans are covered by the LIVE pooled senders' drainers (which + // keep drain_orphans=on and whose close() senderPool.close() awaits + // synchronously, so they release their flock before close() + // returns). + builder.drainOrphans(false); + } + return builder.build(); + } + + /** + * Reserves and returns the lowest free SF slot index. The borrow() cap + * check ({@code all.size() + inFlightCreations + closingSlots + leakedSlots + * < maxSize}) guarantees a free index exists whenever a creation is + * admitted, so this never fails in practice; the guard throws defensively rather than + * silently colliding two senders on one slot dir. Caller must hold + * {@code lock}. + */ + private int allocateSlotIndex() { + for (int i = 0; i < slotInUse.length; i++) { + if (!slotInUse[i]) { + slotInUse[i] = true; + return i; + } + } + throw new IllegalStateException( + "no free SF slot index -- pool capacity invariant violated"); + } + + /** + * Returns an SF slot index to the free set. No-op for non-SF pools and + * for the {@code -1} sentinel. Caller must hold {@code lock}. + */ + private void freeSlotIndex(int idx) { + if (idx >= 0 && slotInUse != null) { + slotInUse[idx] = false; + } + } + + /** + * Whether the delegate's {@code close()} released the SF slot flock. A + * non-QWP delegate never holds an SF flock, so it is always treated as + * released. A {@link QwpWebSocketSender} reports it via + * {@link QwpWebSocketSender#isSlotLockReleased()} -- false means close() + * bailed early with the I/O thread still running and the flock still held. + */ + private static boolean flockReleased(PooledSender s) { + Sender d = s.delegate(); + return !(d instanceof QwpWebSocketSender) || ((QwpWebSocketSender) d).isSlotLockReleased(); + } + + /** + * Reclaims one SF slot after its delegate's {@code close()} has been + * attempted. When the flock was released the index returns to the free + * set; when {@code close()} returned with the flock still held (the I/O + * thread refused to stop) the slot is retired permanently -- + * {@code leakedSlots++} and {@code slotInUse[idx]} stays set -- so the cap + * math accounts for the lost capacity and no later borrow ever reuses the + * still-locked dir. Either way {@code closingSlots} is decremented. + *

+ * Caller must hold {@code lock} and is responsible for signalling waiters + * (only the free path admits a new creation). Shared by + * {@link #discardBroken} and {@link #reapIdle}. + * + * @param s sender whose slot is being reclaimed ({@code slotIndex() >= 0}) + * @param context phrase woven into the retire WARN to name the reclaim + * path (e.g. {@code ""} or {@code " during idle reaping"}) + * @return {@code true} if the index was freed, {@code false} if retired + */ + private boolean reclaimSlot(PooledSender s, String context) { + closingSlots--; + if (flockReleased(s)) { + freeSlotIndex(s.slotIndex()); + return true; + } + leakedSlots++; + LOG.warn("SF slot {} retired permanently{}: delegate close() returned with the flock still held " + + "(I/O thread refused to stop); pool capacity reduced by 1, now {} of {} usable [leakedSlots={}]", + s.slotIndex(), context, maxSize - leakedSlots, maxSize, leakedSlots); + return false; } } diff --git a/core/src/main/java/io/questdb/client/std/Decimal64.java b/core/src/main/java/io/questdb/client/std/Decimal64.java index 6d37f567..0147b995 100644 --- a/core/src/main/java/io/questdb/client/std/Decimal64.java +++ b/core/src/main/java/io/questdb/client/std/Decimal64.java @@ -812,7 +812,7 @@ public String toString() { if (isNull()) { return ""; } - var sink = new StringSink(); + StringSink sink = new StringSink(); toSink(sink); return sink.toString(); } diff --git a/core/src/main/java/io/questdb/client/std/Numbers.java b/core/src/main/java/io/questdb/client/std/Numbers.java index cfc71140..c7b8acc9 100644 --- a/core/src/main/java/io/questdb/client/std/Numbers.java +++ b/core/src/main/java/io/questdb/client/std/Numbers.java @@ -27,7 +27,6 @@ import io.questdb.client.std.fastdouble.FastDoubleParser; import io.questdb.client.std.str.CharSink; import io.questdb.client.std.str.Utf8Sequence; -import jdk.internal.math.FDBigInteger; import java.util.Arrays; @@ -658,12 +657,12 @@ private static void appendDouble0( lowDigitDifference = (b << 1) - tens; } } else { - FDBigInteger sVal = FDBigInteger.valueOfPow52(S5, S2); + FdBig sVal = FdBig.valueOfPow52(S5, S2); final int shiftBias = sVal.getNormalizationBias(); sVal = sVal.leftShift(shiftBias); - FDBigInteger bVal = FDBigInteger.valueOfMulPow52(fractionBits, B5, B2 + shiftBias); - FDBigInteger mVal = FDBigInteger.valueOfPow52(B5 + 1, M2 + shiftBias + 1); - FDBigInteger tensVal = FDBigInteger.valueOfPow52(S5 + 1, S2 + shiftBias + 1); + FdBig bVal = FdBig.valueOfMulPow52(fractionBits, B5, B2 + shiftBias); + FdBig mVal = FdBig.valueOfPow52(B5 + 1, M2 + shiftBias + 1); + FdBig tensVal = FdBig.valueOfPow52(S5 + 1, S2 + shiftBias + 1); digitIndex = 0; q = bVal.quoRemIteration(sVal); low = bVal.cmp(mVal) < 0; @@ -1455,11 +1454,6 @@ private interface LongHexAppender { void append(CharSink sink, long value); } - static { - Module currentModule = Numbers.class.getModule(); - Unsafe.addExports(Unsafe.JAVA_BASE_MODULE, currentModule, "jdk.internal.math"); - } - static { pow10 = new long[20]; pow10[0] = 1; diff --git a/core/src/main/java/io/questdb/client/std/Unsafe.java b/core/src/main/java/io/questdb/client/std/Unsafe.java index 2df7f2d8..b2f13476 100644 --- a/core/src/main/java/io/questdb/client/std/Unsafe.java +++ b/core/src/main/java/io/questdb/client/std/Unsafe.java @@ -28,7 +28,6 @@ import java.lang.reflect.AccessibleObject; import java.lang.reflect.Field; -import java.lang.reflect.Method; import java.util.concurrent.atomic.LongAdder; public final class Unsafe { @@ -36,7 +35,6 @@ public final class Unsafe { // These are off-heap allocated atomic counters for memory usage tracking. public static final long BYTE_OFFSET; - public static final Module JAVA_BASE_MODULE = System.class.getModule(); private static final LongAdder[] COUNTERS = new LongAdder[MemoryTag.SIZE]; private static final long FREE_COUNT_ADDR; private static final long MALLOC_COUNT_ADDR; @@ -46,19 +44,10 @@ public final class Unsafe { private static final long REALLOC_COUNT_ADDR; private static final long RSS_MEM_USED_ADDR; private static final sun.misc.Unsafe UNSAFE; - private static final Method implAddExports; private Unsafe() { } - public static void addExports(Module from, Module to, String packageName) { - try { - implAddExports.invoke(from, packageName, to); - } catch (ReflectiveOperationException e) { - e.printStackTrace(System.out); - } - } - public static int byteArrayGetInt(byte[] array, int index) { assert index > -1 && index < array.length - 3; return Unsafe.getUnsafe().getInt(array, BYTE_OFFSET + index); @@ -266,11 +255,9 @@ private static boolean isJava8Or11() { BYTE_OFFSET = Unsafe.getUnsafe().arrayBaseOffset(byte[].class); OVERRIDE = AccessibleObject_override_fieldOffset(); - implAddExports = Module.class.getDeclaredMethod("implAddExports", String.class, Module.class); } catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); } - makeAccessible(implAddExports); // A single allocation for all the off-heap native memory counters. // Might help with locality, given they're often incremented together. diff --git a/core/src/main/java11/io/questdb/client/std/Compat.java b/core/src/main/java11/io/questdb/client/std/Compat.java new file mode 100644 index 00000000..d0c6b1a6 --- /dev/null +++ b/core/src/main/java11/io/questdb/client/std/Compat.java @@ -0,0 +1,74 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.std; + +import java.lang.reflect.Method; + +/** + * JDK-version-specific helpers. This is the Java 9+ variant; the parallel copy + * under {@code src/main/java8} provides Java 8 implementations of the same API. + */ +public final class Compat { + + private Compat() { + } + + /** + * Returns the current process id, or {@code -1} if it cannot be determined. + * Diagnostic use only — callers must never depend on a real value. + */ + public static long currentPid() { + try { + return ProcessHandle.current().pid(); + } catch (Throwable ignored) { + return -1L; + } + } + + /** + * Spin-loop pause hint. Delegates to {@code Thread.onSpinWait()} on Java 9+; + * the Java 8 variant is a no-op (no such hint exists there). + */ + public static void onSpinWait() { + Thread.onSpinWait(); + } + + /** + * Opens {@code java.base/jdk.internal.math} to this module so that + * {@code FDBigInteger} is reachable at runtime, mirroring the + * {@code --add-exports} flag used at compile time. + */ + static void exportFdBigInteger() { + try { + Module base = System.class.getModule(); + Module current = Compat.class.getModule(); + Method implAddExports = Module.class.getDeclaredMethod("implAddExports", String.class, Module.class); + Unsafe.makeAccessible(implAddExports); + implAddExports.invoke(base, "jdk.internal.math", current); + } catch (ReflectiveOperationException e) { + e.printStackTrace(System.out); + } + } +} diff --git a/core/src/main/java11/io/questdb/client/std/FdBig.java b/core/src/main/java11/io/questdb/client/std/FdBig.java new file mode 100644 index 00000000..0f019ab2 --- /dev/null +++ b/core/src/main/java11/io/questdb/client/std/FdBig.java @@ -0,0 +1,82 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.std; + +import jdk.internal.math.FDBigInteger; + +/** + * Thin bridge over the JDK's internal {@code FDBigInteger}, used only by the + * {@link Numbers} double-to-string slow path. The bignum class lives in + * {@code jdk.internal.math} on Java 9+; the parallel copy of this file under + * {@code src/main/java8} targets {@code sun.misc.FDBigInteger}. Keeping the + * type behind this wrapper lets {@code Numbers} carry a single, JDK-agnostic + * copy of the algorithm. + */ +final class FdBig { + static { + // Mirror the compile-time --add-exports: grant this module runtime + // access to jdk.internal.math before any FDBigInteger reference is + // resolved. No-op on Java 8 (sun.misc is open). + Compat.exportFdBigInteger(); + } + + private final FDBigInteger value; + + private FdBig(FDBigInteger value) { + this.value = value; + } + + static FdBig valueOfMulPow52(long value, int p5, int p2) { + return new FdBig(FDBigInteger.valueOfMulPow52(value, p5, p2)); + } + + static FdBig valueOfPow52(int p5, int p2) { + return new FdBig(FDBigInteger.valueOfPow52(p5, p2)); + } + + int addAndCmp(FdBig x, FdBig y) { + return value.addAndCmp(x.value, y.value); + } + + int cmp(FdBig other) { + return value.cmp(other.value); + } + + int getNormalizationBias() { + return value.getNormalizationBias(); + } + + FdBig leftShift(int shift) { + return new FdBig(value.leftShift(shift)); + } + + FdBig multBy10() { + return new FdBig(value.multBy10()); + } + + int quoRemIteration(FdBig s) { + return value.quoRemIteration(s.value); + } +} diff --git a/core/src/main/java8/io/questdb/client/std/Compat.java b/core/src/main/java8/io/questdb/client/std/Compat.java new file mode 100644 index 00000000..3d9073e7 --- /dev/null +++ b/core/src/main/java8/io/questdb/client/std/Compat.java @@ -0,0 +1,71 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.std; + +import java.lang.management.ManagementFactory; + +/** + * JDK-version-specific helpers. This is the Java 8 variant; the parallel copy + * under {@code src/main/java11} provides Java 9+ implementations of the same + * API. + */ +public final class Compat { + + private Compat() { + } + + /** + * Returns the current process id, or {@code -1} if it cannot be determined. + * Diagnostic use only — callers must never depend on a real value. Java 8 + * has no {@code ProcessHandle}, so the pid is parsed from the + * {@code pid@host} runtime name. + */ + public static long currentPid() { + try { + String name = ManagementFactory.getRuntimeMXBean().getName(); + int at = name.indexOf('@'); + if (at > 0) { + return Long.parseLong(name.substring(0, at)); + } + } catch (Throwable ignored) { + // fall through + } + return -1L; + } + + /** + * Spin-loop pause hint. No-op on Java 8 — {@code Thread.onSpinWait()} does + * not exist there. The parallel Java 9+ variant delegates to it. + */ + public static void onSpinWait() { + } + + /** + * No-op on Java 8: {@code sun.misc} is open, so {@code FDBigInteger} needs + * no module export. Present for symmetry with the Java 9+ variant. + */ + static void exportFdBigInteger() { + } +} diff --git a/core/src/main/java8/io/questdb/client/std/FdBig.java b/core/src/main/java8/io/questdb/client/std/FdBig.java new file mode 100644 index 00000000..7ca6600e --- /dev/null +++ b/core/src/main/java8/io/questdb/client/std/FdBig.java @@ -0,0 +1,81 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.std; + +import sun.misc.FDBigInteger; + +/** + * Thin bridge over the JDK's internal {@code FDBigInteger}, used only by the + * {@link Numbers} double-to-string slow path. This is the Java 8 variant: the + * bignum class lives in {@code sun.misc}. The parallel copy under + * {@code src/main/java11} targets {@code jdk.internal.math.FDBigInteger}. The + * public surface is identical across both JDKs, so {@code Numbers} carries a + * single, JDK-agnostic copy of the algorithm. + */ +final class FdBig { + static { + // No-op on Java 8: sun.misc is open, no module export is needed. Kept + // for symmetry with the Java 9+ variant. + Compat.exportFdBigInteger(); + } + + private final FDBigInteger value; + + private FdBig(FDBigInteger value) { + this.value = value; + } + + static FdBig valueOfMulPow52(long value, int p5, int p2) { + return new FdBig(FDBigInteger.valueOfMulPow52(value, p5, p2)); + } + + static FdBig valueOfPow52(int p5, int p2) { + return new FdBig(FDBigInteger.valueOfPow52(p5, p2)); + } + + int addAndCmp(FdBig x, FdBig y) { + return value.addAndCmp(x.value, y.value); + } + + int cmp(FdBig other) { + return value.cmp(other.value); + } + + int getNormalizationBias() { + return value.getNormalizationBias(); + } + + FdBig leftShift(int shift) { + return new FdBig(value.leftShift(shift)); + } + + FdBig multBy10() { + return new FdBig(value.multBy10()); + } + + int quoRemIteration(FdBig s) { + return value.quoRemIteration(s.value); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/v4/QwpAllocationTestClient.java b/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/v4/QwpAllocationTestClient.java index 25a9d275..83cc9a0c 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/v4/QwpAllocationTestClient.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/v4/QwpAllocationTestClient.java @@ -187,7 +187,7 @@ public static void main(String[] args) { System.out.println("Skipping table drop (--no-drop)"); } runTest(protocol, host, port, totalRows, batchSize, flushBytes, flushIntervalMs, - inFlightWindow, maxDatagramSize, warmupRows, reportInterval, targetThroughput); + maxDatagramSize, warmupRows, reportInterval, targetThroughput); } catch (Exception e) { System.err.println("Error: " + e.getMessage()); e.printStackTrace(System.err); @@ -202,7 +202,6 @@ private static Sender createSender( int batchSize, int flushBytes, long flushIntervalMs, - int inFlightWindow, int maxDatagramSize ) { switch (protocol) { @@ -330,13 +329,13 @@ private static void recreateTable(String host) throws Exception { private static void runTest(String protocol, String host, int port, int totalRows, int batchSize, int flushBytes, long flushIntervalMs, - int inFlightWindow, int maxDatagramSize, + int maxDatagramSize, int warmupRows, int reportInterval, int targetThroughput) throws IOException { System.out.println("Connecting to " + host + ":" + port + "..."); try (Sender sender = createSender(protocol, host, port, batchSize, flushBytes, flushIntervalMs, - inFlightWindow, maxDatagramSize)) { + maxDatagramSize)) { System.out.println("Connected! Protocol: " + protocol); System.out.println(); @@ -378,7 +377,7 @@ private static void runTest(String protocol, String host, int port, int totalRow if (nanosPerRow > 0 && (i + 1) % paceCheckInterval == 0) { long expectedElapsedNanos = (long) ((i + 1) * nanosPerRow); while (System.nanoTime() - startTime < expectedElapsedNanos) { - Thread.onSpinWait(); + io.questdb.client.std.Compat.onSpinWait(); } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseSafetyNetTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseSafetyNetTest.java index a874063a..fe3bb059 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseSafetyNetTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseSafetyNetTest.java @@ -30,6 +30,7 @@ import io.questdb.client.SenderErrorHandler; import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import org.jetbrains.annotations.NotNull; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; @@ -61,7 +62,7 @@ public class CloseSafetyNetTest { public final TemporaryFolder sfDir = TemporaryFolder.builder().assureDeletion().build(); @Test(timeout = 30_000) - public void testCloseRethrowsUnsurfacedTerminalWithoutCustomHandler() throws Exception { + public void testCloseRethrowsUnsurfacedTerminalWithoutCustomHandler() { // No server, no handler, tight reconnect budget: the I/O thread // latches a never-connected budget-exhaustion terminal that nothing // has surfaced to the user. close() must throw it. @@ -115,7 +116,7 @@ private static void awaitLatchedTerminal(QwpWebSocketSender sender) { if (System.nanoTime() > deadlineNanos) { throw new AssertionError("I/O thread did not latch a terminal within 10s"); } - Thread.onSpinWait(); + io.questdb.client.std.Compat.onSpinWait(); } } @@ -142,7 +143,7 @@ SenderError get() { } @Override - public void onError(SenderError err) { + public void onError(@NotNull SenderError err) { if (ref.compareAndSet(null, err)) { latch.countDown(); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseTerminalConflationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseTerminalConflationTest.java index 1a60c2e3..2dd22d59 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseTerminalConflationTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseTerminalConflationTest.java @@ -1,4 +1,4 @@ -/******************************************************************************* +/*+***************************************************************************** * ___ _ ____ ____ * / _ \ _ _ ___ ___| |_| _ \| __ ) * | | | | | | |/ _ \/ __| __| | | | _ \ @@ -31,6 +31,7 @@ import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; import io.questdb.client.cutlass.qwp.client.WebSocketResponse; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.jetbrains.annotations.NotNull; import org.junit.Assert; import org.junit.Test; @@ -144,7 +145,7 @@ private static void awaitLatchedTerminal(QwpWebSocketSender sender) { if (System.nanoTime() > deadlineNanos) { throw new AssertionError("I/O thread did not latch a terminal within 10s"); } - Thread.onSpinWait(); + io.questdb.client.std.Compat.onSpinWait(); } } @@ -206,7 +207,7 @@ SenderError get() { } @Override - public void onError(SenderError err) { + public void onError(@NotNull SenderError err) { if (ref.compareAndSet(null, err)) { latch.countDown(); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaSymbolDictionaryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaSymbolDictionaryTest.java index ffbc16b5..e163b22e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaSymbolDictionaryTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaSymbolDictionaryTest.java @@ -230,7 +230,7 @@ public void testEdgeCase_veryLongSymbol() throws Exception { GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); // Create a very long symbol (1000 chars) - String longSymbol = "X".repeat(1000); + String longSymbol = io.questdb.client.test.tools.TestUtils.repeat("X", 1000); int id = globalDict.getOrAddSymbol(longSymbol); Assert.assertEquals(0, id); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InitialConnectAsyncTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InitialConnectAsyncTest.java index 4e28b14b..0733de8f 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InitialConnectAsyncTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InitialConnectAsyncTest.java @@ -28,6 +28,7 @@ import io.questdb.client.SenderError; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.jetbrains.annotations.NotNull; import org.junit.Assert; import org.junit.Test; @@ -330,7 +331,7 @@ private static void awaitAtLeastOneConnectAttempt(QwpWebSocketSender wss) { throw new AssertionError( "I/O thread did not log a connect attempt within 5s"); } - Thread.onSpinWait(); + io.questdb.client.std.Compat.onSpinWait(); } } @@ -427,7 +428,7 @@ SenderError get() { } @Override - public void onError(SenderError err) { + public void onError(@NotNull SenderError err) { if (ref.compareAndSet(null, err)) { latch.countDown(); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpBindEncoderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpBindEncoderTest.java index 19ddd2fc..9d422ee9 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpBindEncoderTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpBindEncoderTest.java @@ -862,7 +862,7 @@ public void testEncodeVarcharUnicode() throws Exception { public void testEncoderGrowsBufferBeyondDefault() throws Exception { assertMemoryLeak(() -> { try (QwpBindValues binds = new QwpBindValues()) { - String big = "x".repeat(20_000); + String big = io.questdb.client.test.tools.TestUtils.repeat("x", 20_000); binds.setVarchar(0, big); Assert.assertEquals(1, binds.count()); // type(1) + flag(1) + offset0(4) + len(4) + 20000 bytes = 20010 diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpUdpSenderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpUdpSenderTest.java index 067a8a97..f16bc267 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpUdpSenderTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpUdpSenderTest.java @@ -44,6 +44,7 @@ import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -218,7 +219,7 @@ public void testArrayWrapperStagingSnapshotsMutationAndCancelRow() throws Except } assertRowsEqual( - List.of(decodedRow( + Collections.singletonList(decodedRow( "arrays", "la", longArrayValue(shape(2), 9, 10), "da", doubleArrayValue(shape(2), 9.5, 10.5) @@ -232,7 +233,7 @@ public void testArrayWrapperStagingSnapshotsMutationAndCancelRow() throws Except public void testAtMicrosOversizeFailureRollsBackWithoutLeakingTimestampState() throws Exception { assertMemoryLeak(() -> { String large = repeat('x', 5000); - List oversizedRow = List.of( + List oversizedRow = Collections.singletonList( row("t", sender -> sender.table("t") .longColumn("a", 2) .stringColumn("s", large) @@ -272,7 +273,7 @@ public void testAtMicrosOversizeFailureRollsBackWithoutLeakingTimestampState() t public void testAtNanosOversizeFailureRollsBackWithoutLeakingTimestampState() throws Exception { assertMemoryLeak(() -> { String large = repeat('x', 5000); - List oversizedRow = List.of( + List oversizedRow = Collections.singletonList( row("tn", sender -> sender.table("tn") .longColumn("a", 2) .stringColumn("s", large) @@ -335,7 +336,7 @@ public void testAtNowAllowsTimestampOnlyRowWhenTableHasColumns() throws Exceptio public void testAtNowOversizeFailureRollsBackWithoutExplicitCancel() throws Exception { assertMemoryLeak(() -> { String large = repeat('x', 5000); - List oversizedRow = List.of( + List oversizedRow = Collections.singletonList( row("t", sender -> sender.table("t") .longColumn("a", 2) .stringColumn("s", large) @@ -389,7 +390,7 @@ public void testAtNowRejectsEmptyRowOnFirstRow() throws Exception { sender.flush(); } - assertRowsEqual(List.of( + assertRowsEqual(Collections.singletonList( decodedRow("t", "x", 1L) ), decodeRows(nf.packets)); }); @@ -875,7 +876,7 @@ public void testCloseDropsInProgressRowButFlushesCommittedRows() throws Exceptio } Assert.assertEquals(1, nf.sendCount); - assertRowsEqual(List.of(decodedRow("t", "x", 1L)), decodeRows(nf.packets)); + assertRowsEqual(Collections.singletonList(decodedRow("t", "x", 1L)), decodeRows(nf.packets)); }); } @@ -918,7 +919,7 @@ public void testEstimateMatchesActualEncodedSize() throws Exception { @Test public void testFirstRowAllowsMultipleNewColumnsAndEncodesRow() throws Exception { assertMemoryLeak(() -> { - List rows = List.of( + List rows = Collections.singletonList( row("t", sender -> sender.table("t") .longColumn("a", 1) .doubleColumn("b", 2.0) @@ -973,7 +974,7 @@ public void testFlushWhileRowInProgressThrowsAndPreservesRow() throws Exception } Assert.assertEquals(1, nf.sendCount); - assertRowsEqual(List.of(decodedRow("t", "x", 1L)), decodeRows(nf.packets)); + assertRowsEqual(Collections.singletonList(decodedRow("t", "x", 1L)), decodeRows(nf.packets)); }); } @@ -993,7 +994,7 @@ public void testIrregularArrayRejectedDuringStagingAndSenderRemainsUsable() thro } Assert.assertEquals(1, nf.sendCount); - assertRowsEqual(List.of(decodedRow("ok", "x", 1L)), decodeRows(nf.packets)); + assertRowsEqual(Collections.singletonList(decodedRow("ok", "x", 1L)), decodeRows(nf.packets)); }); } @@ -1012,7 +1013,7 @@ public void testIrregularArrayRejectedDuringStagingDoesNotLeakColumnIntoSameTabl } Assert.assertEquals(1, nf.sendCount); - assertRowsEqual(List.of(decodedRow("arrays", "x", 1L)), decodeRows(nf.packets)); + assertRowsEqual(Collections.singletonList(decodedRow("arrays", "x", 1L)), decodeRows(nf.packets)); }); } @@ -1034,7 +1035,7 @@ public void testLongArrayWrapperStagingSnapshotsMutation() throws Exception { } assertRowsEqual( - List.of(decodedRow( + Collections.singletonList(decodedRow( "arrays", "la", longArrayValue(shape(2, 2), 1, 2, 3, 4) )), @@ -1158,7 +1159,7 @@ public void testOversizedArrayRowRejectedUsesActualEncodedSize() throws Exceptio doubleValues[i] = i + 0.25; } - List rows = List.of( + List rows = Collections.singletonList( row("arrays", sender -> sender.table("arrays") .longColumn("x", 1) .longArray("la", longValues) @@ -1189,7 +1190,7 @@ public void testOversizedArrayRowRejectedUsesActualEncodedSize() throws Exceptio public void testOversizedRowAfterMidRowSchemaChangeCancelDoesNotLeakSchema() throws Exception { assertMemoryLeak(() -> { String large = repeat('x', 5000); - List oversizedRow = List.of( + List oversizedRow = Collections.singletonList( row("t", sender -> sender.table("t") .longColumn("a", 2) .stringColumn("s", large) @@ -1230,7 +1231,7 @@ public void testOversizedSingleRowRejectedAfterReplayUsesActualEncodedSize() thr assertMemoryLeak(() -> { String small = repeat('s', 32); String large = repeat('x', 5000); - List largeRow = List.of( + List largeRow = Collections.singletonList( row("t", sender -> sender.table("t") .longColumn("x", 2) .stringColumn("s", large) @@ -1257,7 +1258,7 @@ public void testOversizedSingleRowRejectedAfterReplayUsesActualEncodedSize() thr Assert.assertEquals(1, nf.sendCount); assertPacketsWithinLimit(new RunResult(nf.packets, nf.lengths, nf.sendCount), maxDatagramSize); assertRowsEqual( - List.of(decodedRow("t", "x", 1L, "s", small)), + Collections.singletonList(decodedRow("t", "x", 1L, "s", small)), decodeRows(nf.packets) ); }); @@ -1267,7 +1268,7 @@ public void testOversizedSingleRowRejectedAfterReplayUsesActualEncodedSize() thr public void testOversizedSingleRowRejectedBeforeReplayUsesActualEncodedSize() throws Exception { assertMemoryLeak(() -> { String large = repeat('x', 5000); - List rows = List.of( + List rows = Collections.singletonList( row("t", sender -> sender.table("t") .longColumn("x", 1) .stringColumn("s", large) @@ -1561,7 +1562,7 @@ public void testSimpleLongRowUsesScatterSendPath() throws Exception { Assert.assertEquals(1, nf.scatterSendCount); Assert.assertEquals(0, nf.rawSendCount); Assert.assertTrue("expected multiple segments for header/schema/data", nf.segmentCounts.get(0) > 1); - assertRowsEqual(List.of(decodedRow("t", "x", 42L)), decodeRows(nf.packets)); + assertRowsEqual(Collections.singletonList(decodedRow("t", "x", 42L)), decodeRows(nf.packets)); }); } @@ -1693,7 +1694,7 @@ public void testTableRejectsEmptyName() throws Exception { } Assert.assertEquals(1, nf.sendCount); - assertRowsEqual(List.of( + assertRowsEqual(Collections.singletonList( decodedRow("valid", "x", 1L) ), decodeRows(nf.packets)); }); @@ -1742,7 +1743,7 @@ public void testTableRejectsNullName() throws Exception { } Assert.assertEquals(1, nf.sendCount); - assertRowsEqual(List.of( + assertRowsEqual(Collections.singletonList( decodedRow("valid", "x", 1L) ), decodeRows(nf.packets)); }); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java index 271501b5..b7318a87 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java @@ -326,7 +326,7 @@ public void testEncodeLongString() throws Exception { try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { - String sb = "a".repeat(10_000); + String sb = io.questdb.client.test.tools.TestUtils.repeat("a", 10_000); QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("data", TYPE_VARCHAR, true); col.addString(sb); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RecoveryReplayTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RecoveryReplayTest.java index 32d2725d..e868dafc 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RecoveryReplayTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RecoveryReplayTest.java @@ -172,7 +172,7 @@ private static int countPopulatedSegmentFiles(String dir) { } private static String repeat(String c, int n) { - return String.valueOf(c).repeat(Math.max(0, n)); + return io.questdb.client.test.tools.TestUtils.repeat(String.valueOf(c), Math.max(0, n)); } private static void rmDirRec(String dir) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java new file mode 100644 index 00000000..85522e9d --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java @@ -0,0 +1,246 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.LockSupport; + +/** + * Pins the {@link QwpWebSocketSender#isSlotLockReleased()} contract that the + * {@link io.questdb.client.impl.SenderPool} leaked-slot accounting depends on: + *

    + *
  • Happy path — a clean {@code close()} that winds the I/O thread + * down reports {@code isSlotLockReleased() == true}.
  • + *
  • Leak path — a {@code close()} that early-returns because the I/O + * thread refused to stop ({@code ioThreadStopped == false}) reports + * {@code isSlotLockReleased() == false}.
  • + *
+ * The pool's {@code flockReleased(s)} treats {@code false} as "flock still held, + * retire the slot permanently". Before this test that contract was driven only + * by tests that forged the {@code slotLockReleased} field directly, so + * the real production write path (and the direction of the getter) was never + * exercised: an inverted getter or a misplaced {@code slotLockReleased = true} + * would have gone unnoticed. These tests drive the real {@code close()} paths. + */ +public class SlotLockReleasedContractTest { + + /** + * Happy path: a live sender has not released its slot lock; a clean + * {@code close()} (I/O thread stops normally) flips it to released. + */ + @Test + public void testSlotLockReleasedAfterCleanClose() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler())) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String cfg = "ws::addr=localhost:" + port + ";close_flush_timeout_millis=2000;"; + QwpWebSocketSender wss = (QwpWebSocketSender) Sender.fromConfig(cfg); + // A live, never-closed sender holds its slot: must report + // NOT-released (guards against the flag defaulting/initialising + // to true). + Assert.assertFalse( + "a live sender must not report its slot lock released", + wss.isSlotLockReleased()); + + wss.table("t").longColumn("v", 1L).atNow(); + wss.flush(); + wss.close(); + + // Clean close => I/O thread stopped => slot lock released. + Assert.assertTrue( + "a clean close() (I/O thread stopped) must release the slot lock", + wss.isSlotLockReleased()); + } + }); + } + + /** + * Leak path: when {@code close()} cannot wind the I/O loop down it bails + * out via the {@code !ioThreadStopped} early-return and must leave the slot + * lock reported as NOT released. + *

+ * We can't make a real {@link CursorWebSocketSendLoop#close()} throw + * (the loop is {@code final}, its latch is {@code final}, and every throw + * inside is caught), so we forge the exact production precondition the catch + * at {@code QwpWebSocketSender.close()} reacts to: a loop whose + * {@code close()} throws while the I/O thread is still alive. After cleanly + * stopping the real I/O thread (so it doesn't leak), we null the loop's + * shutdown latch and point its {@code ioThread} at a live thread, so + * {@code shutdownLatch.await()} NPEs — exactly the "Error closing cursor + * send loop" path that sets {@code ioThreadStopped = false}. + */ + @Test + public void testSlotLockNotReleasedWhenIoThreadRefusesToStop() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler())) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + // close_flush_timeout_millis=0 => close() skips the drain step; + // we write no rows, so the drain block has nothing to do. + String cfg = "ws::addr=localhost:" + port + ";close_flush_timeout_millis=0;"; + QwpWebSocketSender wss = (QwpWebSocketSender) Sender.fromConfig(cfg); + Assert.assertFalse("precondition: live sender, lock not released", + wss.isSlotLockReleased()); + + CursorWebSocketSendLoop loop = readField(wss, "cursorSendLoop", CursorWebSocketSendLoop.class); + Assert.assertNotNull("connected sender must have an I/O loop", loop); + + try { + // 1) Cleanly stop the REAL I/O thread (so it does not leak); + // this also counts its shutdown latch down to zero. + Thread realIo = readField(loop, "ioThread", Thread.class); + setField(loop, "running", Boolean.FALSE); + if (realIo != null) { + LockSupport.unpark(realIo); + realIo.join(TimeUnit.SECONDS.toMillis(5)); + Assert.assertFalse("real I/O thread must have stopped", realIo.isAlive()); + } + + // 2) Forge "the I/O thread refuses to stop": with the latch + // nulled and ioThread pointing at a live thread, + // loop.close()'s shutdownLatch.await() throws NPE -- the + // uncaught throwable production catches as + // ioThreadStopped=false. + setField(loop, "shutdownLatch", null); + setField(loop, "ioThread", Thread.currentThread()); + + // 3) Drive the real close() early-return. It rethrows the + // swallowed teardown error after the guard. + try { + wss.close(); + Assert.fail("close() must surface the loop-teardown failure"); + } catch (Throwable expected) { + // expected: rethrowTerminal() surfaces the captured NPE. + } + + // 4) Contract under test: the I/O thread refused to stop, so + // the slot lock must NOT be reported released. + Assert.assertFalse( + "isSlotLockReleased() must be false when close() early-returns " + + "with the I/O thread still running", + wss.isSlotLockReleased()); + } finally { + // close()'s early-return deliberately leaks the resources the + // still-running I/O thread might touch (to avoid SIGSEGV). + // Free them here -- the same set the post-guard close() tail + // would have freed -- so assertMemoryLeak passes. + freeFieldQuietly(wss, "buffer0"); + freeFieldQuietly(wss, "buffer1"); + freeFieldQuietly(wss, "client"); + if (Boolean.TRUE.equals(readField(wss, "ownsCursorEngine", Boolean.class))) { + freeFieldQuietly(wss, "cursorEngine"); + } + freeFieldQuietly(wss, "errorDispatcher"); + freeFieldQuietly(wss, "progressDispatcher"); + freeFieldQuietly(wss, "connectionDispatcher"); + } + } + }); + } + + // ------------------------------------------------------------------ utils + + private static void freeFieldQuietly(Object target, String name) { + try { + Object v = readField(target, name, Object.class); + if (v != null) { + v.getClass().getMethod("close").invoke(v); + } + } catch (Throwable ignored) { + // Best-effort cleanup of a leaked resource: a double-close (or an + // -ea AssertionError) must not mask the assertions above. + } + } + + private static T readField(Object target, String name, Class type) throws Exception { + Class cls = target.getClass(); + while (cls != null) { + try { + Field f = cls.getDeclaredField(name); + f.setAccessible(true); + return type.cast(f.get(target)); + } catch (NoSuchFieldException e) { + cls = cls.getSuperclass(); + } + } + throw new NoSuchFieldException(name); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Class cls = target.getClass(); + while (cls != null) { + try { + Field f = cls.getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + return; + } catch (NoSuchFieldException e) { + cls = cls.getSuperclass(); + } + } + throw new NoSuchFieldException(name); + } + + /** ACKs every binary frame with a running sequence so flush/close drain cleanly. */ + private static final class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(); + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static byte[] buildAck(long seq) { + byte[] buf = new byte[1 + 8 + 2]; + ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN); + bb.put((byte) 0x00); + bb.putLong(seq); + bb.putShort((short) 0); + return buf; + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WriteFailoverTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WriteFailoverTest.java index bb33eb1c..8ab444e8 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WriteFailoverTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WriteFailoverTest.java @@ -337,7 +337,7 @@ public void testUpgradeException421CarriesRoleHeader() throws Exception { // sits on the cause chain via initCause(). TestWebSocketServer replica = new TestWebSocketServer(new AckHandler()); int port = replica.getPort(); - try (replica) { + try (TestWebSocketServer replicaResource = replica) { replica.setRejectWithRole("REPLICA"); replica.start(); Assert.assertTrue(replica.awaitStart(5, TimeUnit.SECONDS)); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopReconnectLeakTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopReconnectLeakTest.java index 759ea873..806682fe 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopReconnectLeakTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopReconnectLeakTest.java @@ -1,4 +1,4 @@ -/******************************************************************************* +/*+***************************************************************************** * ___ _ ____ ____ * / _ \ _ _ ___ ___| |_| _ \| __ ) * | | | | | | |/ _ \/ __| __| | | | _ \ @@ -99,7 +99,7 @@ public void testCloseClosesLivePostReconnectClient() throws Exception { + wss.getTotalReconnectsSucceeded() + " successful reconnects"); } - Thread.onSpinWait(); + io.questdb.client.std.Compat.onSpinWait(); } // Reach into the loop to capture the live client BEFORE we @@ -200,7 +200,7 @@ private void awaitAckProcessed(long baseline) { "client never reported processing the ACK within 5s " + "(baseline=" + baseline + ", current=" + s.getTotalAcks() + ")"); } - Thread.onSpinWait(); + io.questdb.client.std.Compat.onSpinWait(); } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/OrphanScannerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/OrphanScannerTest.java index bf9f084a..162474fe 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/OrphanScannerTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/OrphanScannerTest.java @@ -26,6 +26,7 @@ import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner; import io.questdb.client.std.Files; +import io.questdb.client.std.IntList; import io.questdb.client.std.ObjList; import io.questdb.client.test.tools.TestUtils; import org.junit.After; @@ -148,6 +149,162 @@ public void testMultipleOrphansReturned() throws Exception { }); } + // ---------------------------------------------------------------------- + // Bounded (managed-slot) exclusion: scan(sfDir, exclude, base, count). + // This is how a connection pool fences off its own live siblings while + // still draining same-base leftovers, and the fix for the + // "shrinking maxSize strands unacked SF data" bug. + // ---------------------------------------------------------------------- + + @Test + public void testBoundedScanExcludesInRangeManagedSlots() throws Exception { + TestUtils.assertMemoryLeak(() -> { + // Pool at maxSize=2 co-manages default-0 and default-1; a startup + // scan must not list a live sibling as an orphan. A foreign slot + // is still reported. + for (String name : new String[]{"default-0", "default-1"}) { + String slot = sfDir + "/" + name; + assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); + touchFile(slot + "/sf-0001.sfa"); + } + String foreign = sfDir + "/legacy"; + assertEquals(0, Files.mkdir(foreign, Files.DIR_MODE_DEFAULT)); + touchFile(foreign + "/sf-0001.sfa"); + + // caller is default-0; managed set is [0, 2) + ObjList orphans = OrphanScanner.scan(sfDir, "default-0", "default", 2); + assertEquals("only the foreign slot is a candidate", 1, orphans.size()); + assertEquals(foreign, orphans.get(0)); + }); + } + + @Test + public void testBoundedScanDrainsOutOfRangeSameBaseSlotsAfterShrink() throws Exception { + TestUtils.assertMemoryLeak(() -> { + // The bug: a previous run used maxSize=4 (default-0..3 hold unacked + // data); this run restarts at maxSize=2. default-0/1 are re-created + // and self-recovered (excluded), but default-2/3 are OUT of the new + // [0,2) index range forever -- they must be drainable, not stranded. + for (String name : new String[]{"default-0", "default-1", "default-2", "default-3"}) { + String slot = sfDir + "/" + name; + assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); + touchFile(slot + "/sf-0001.sfa"); + } + // caller is default-0; managed set is [0, 2) + ObjList orphans = OrphanScanner.scan(sfDir, "default-0", "default", 2); + assertEquals("default-2 and default-3 must be drainable orphans", 2, orphans.size()); + boolean has2 = false, has3 = false; + for (int i = 0; i < orphans.size(); i++) { + String p = orphans.get(i); + if (p.equals(sfDir + "/default-2")) has2 = true; + if (p.equals(sfDir + "/default-3")) has3 = true; + } + assertTrue("default-2 stranded", has2); + assertTrue("default-3 stranded", has3); + }); + } + + @Test + public void testListStrandedOutOfRangeManagedSlots() throws Exception { + TestUtils.assertMemoryLeak(() -> { + // A previous run used maxSize=2; default-0/1 are in range, default-2/3 + // are out of range and hold unacked data. A non-canonical same-base + // name (default-04) and a foreign base (other-9) must NOT be listed: + // the pool only ever minted canonical default-, so only those are + // its own stranded out-of-range slots. + for (String name : new String[]{"default-0", "default-1", "default-2", "default-3", + "default-04", "other-9"}) { + String slot = sfDir + "/" + name; + assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); + touchFile(slot + "/sf-0001.sfa"); + } + // An out-of-range slot already flagged .failed must be skipped. + String failed = sfDir + "/default-5"; + assertEquals(0, Files.mkdir(failed, Files.DIR_MODE_DEFAULT)); + touchFile(failed + "/sf-0001.sfa"); + OrphanScanner.markFailed(failed, "test-induced"); + // An out-of-range slot with no segment file must be skipped. + String empty = sfDir + "/default-6"; + assertEquals(0, Files.mkdir(empty, Files.DIR_MODE_DEFAULT)); + + IntList stranded = OrphanScanner.listStrandedOutOfRangeManagedSlots(sfDir, "default", 2); + assertEquals("only canonical same-base i>=2 with unacked data", 2, stranded.size()); + boolean has2 = false, has3 = false; + for (int i = 0; i < stranded.size(); i++) { + if (stranded.getQuick(i) == 2) has2 = true; + if (stranded.getQuick(i) == 3) has3 = true; + } + assertTrue("default-2 must be listed", has2); + assertTrue("default-3 must be listed", has3); + + // count <= 0 / null / empty base disables the bound. + assertEquals(0, OrphanScanner.listStrandedOutOfRangeManagedSlots(sfDir, null, 2).size()); + assertEquals(0, OrphanScanner.listStrandedOutOfRangeManagedSlots(sfDir, "", 2).size()); + }); + } + + @Test + public void testBoundedScanDrainsNonCanonicalSameBaseNames() throws Exception { + TestUtils.assertMemoryLeak(() -> { + // The pool only ever mints canonical Integer.toString suffixes + // ("0","1",...). A same-base dir with a leading-zero or non-numeric + // suffix is not a managed slot, so it is drained like any foreign + // leftover -- even if its numeric value would fall inside [0,count). + for (String name : new String[]{"default-00", "default-01", "default-foo", "default-"}) { + String slot = sfDir + "/" + name; + assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); + touchFile(slot + "/sf-0001.sfa"); + } + // also a genuinely-managed slot that must stay excluded + String managed = sfDir + "/default-1"; + assertEquals(0, Files.mkdir(managed, Files.DIR_MODE_DEFAULT)); + touchFile(managed + "/sf-0001.sfa"); + + ObjList orphans = OrphanScanner.scan(sfDir, "default-0", "default", 4); + assertEquals("all non-canonical same-base names are drainable", 4, orphans.size()); + for (int i = 0; i < orphans.size(); i++) { + assertFalse("managed slot must not appear", + orphans.get(i).equals(managed)); + } + }); + } + + @Test + public void testBoundedScanDisabledWhenCountNonPositive() throws Exception { + TestUtils.assertMemoryLeak(() -> { + // count <= 0 or null/empty base disables the exclusion: every + // sibling with data (except the explicit excludeSlotName) is a + // candidate. + String sibling = sfDir + "/default-1"; + assertEquals(0, Files.mkdir(sibling, Files.DIR_MODE_DEFAULT)); + touchFile(sibling + "/sf-0001.sfa"); + + assertEquals(1, OrphanScanner.scan(sfDir, "default-0", "default", 0).size()); + assertEquals(1, OrphanScanner.scan(sfDir, "default-0", null, 4).size()); + assertEquals(1, OrphanScanner.scan(sfDir, "default-0", "", 4).size()); + }); + } + + @Test + public void testIsManagedSlotPredicate() throws Exception { + TestUtils.assertMemoryLeak(() -> { + // In-range canonical indices are managed. + assertTrue(OrphanScanner.isManagedSlot("default-0", "default-", 2)); + assertTrue(OrphanScanner.isManagedSlot("default-1", "default-", 2)); + // At-or-above the count is NOT managed (drainable). + assertFalse(OrphanScanner.isManagedSlot("default-2", "default-", 2)); + assertFalse(OrphanScanner.isManagedSlot("default-10", "default-", 2)); + // Non-canonical / foreign suffixes are NOT managed. + assertFalse(OrphanScanner.isManagedSlot("default-00", "default-", 4)); + assertFalse(OrphanScanner.isManagedSlot("default-01", "default-", 4)); + assertFalse(OrphanScanner.isManagedSlot("default-foo", "default-", 4)); + assertFalse(OrphanScanner.isManagedSlot("default-", "default-", 4)); + assertFalse(OrphanScanner.isManagedSlot("default--1", "default-", 4)); + assertFalse(OrphanScanner.isManagedSlot("other-0", "default-", 4)); + assertFalse(OrphanScanner.isManagedSlot("default", "default-", 4)); + }); + } + @Test public void testIsCandidateOrphanDirect() throws Exception { TestUtils.assertMemoryLeak(() -> { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java index c8168d3c..eed6bc87 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java @@ -1,4 +1,4 @@ -/******************************************************************************* +/*+***************************************************************************** * ___ _ ____ ____ * / _ \ _ _ ___ ___| |_| _ \| __ ) * | | | | | | |/ _ \/ __| __| | | | _ \ @@ -86,7 +86,7 @@ public void testManagerHonorsCapAgainstRecoveredSegmentsOnRegister() throws Exce Assert.assertNotNull("recovery should produce a ring", ring); SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 1_000_000L /* 1ms */, cap); - try (manager) { + try (SegmentManager ignored = manager) { manager.start(); manager.register(ring, slotDir); // Give the manager several ticks. With the bug, it provisions diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java index 3ee4301b..0c102fce 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java @@ -1,4 +1,4 @@ -/******************************************************************************* +/*+***************************************************************************** * ___ _ ____ ____ * / _ \ _ _ ___ ___| |_| _ \| __ ) * | | | | | | |/ _ \/ __| __| | | | _ \ @@ -118,7 +118,7 @@ public void testProducerCanRotateAcrossManySegmentsWithoutBackpressure() throws throw new AssertionError( "stuck waiting for spare at i=" + i + ", needsSpare=" + ring.needsHotSpare()); } - Thread.onSpinWait(); + io.questdb.client.std.Compat.onSpinWait(); } assertEquals(i, fsn); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java index 431dc25e..644b2709 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java @@ -189,7 +189,7 @@ private static void awaitParked(Thread t) { if (System.nanoTime() > deadline) { throw new AssertionError("worker did not park within 5 s; state=" + s); } - Thread.onSpinWait(); + io.questdb.client.std.Compat.onSpinWait(); } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java index 85efd40c..8cc05407 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java @@ -213,7 +213,7 @@ private static void awaitParked(Thread t) { if (System.nanoTime() > deadline) { throw new AssertionError("worker did not park within 5 s; state=" + s); } - Thread.onSpinWait(); + io.questdb.client.std.Compat.onSpinWait(); } } @@ -223,7 +223,7 @@ private static void awaitSpare(SegmentRing ring, String where) { if (System.nanoTime() > deadline) { throw new AssertionError("spare never installed " + where); } - Thread.onSpinWait(); + io.questdb.client.std.Compat.onSpinWait(); } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingRecoveryUnlinkTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingRecoveryUnlinkTest.java index 172d118f..9f533fc7 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingRecoveryUnlinkTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingRecoveryUnlinkTest.java @@ -1,4 +1,4 @@ -/******************************************************************************* +/*+***************************************************************************** * ___ _ ____ ____ * / _ \ _ _ ___ ___| |_| _ \| __ ) * | | | | | | |/ _ \/ __| __| | | | _ \ @@ -129,7 +129,7 @@ public void testRecoveryUnlinksEmptyOrphansAlongsideValidSegments() throws Excep Assert.assertTrue("setup: orphan .sfa should exist", Files.exists(orphanPath)); SegmentRing recovered = SegmentRing.openExisting(tmpDir, SEGMENT_SIZE); - try (recovered) { + try (SegmentRing ignored = recovered) { Assert.assertNotNull("recovery dropped the valid segment", recovered); Assert.assertTrue( "recovery should keep the valid segment on disk", diff --git a/core/src/test/java/io/questdb/client/test/impl/QueryClientPoolErrorSafetyTest.java b/core/src/test/java/io/questdb/client/test/impl/QueryClientPoolErrorSafetyTest.java new file mode 100644 index 00000000..3994a1d2 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/QueryClientPoolErrorSafetyTest.java @@ -0,0 +1,261 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.impl; + +import io.questdb.client.QueryException; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; +import io.questdb.client.impl.QueryClientPool; +import io.questdb.client.impl.QueryWorker; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +// Error-safety of the three QueryClientPool creation paths the teardown-hardening +// fix widened from catch (RuntimeException) to catch (Throwable). The native +// build/connect path runs under -ea and can throw an Error (AssertionError, +// OutOfMemoryError); the old catches let that Error skip cleanup. +// +// QwpQueryClient is a concrete class with no fake seam, so these tests inject an +// Error at the real connect step via the package-private connectHook constructor +// (reached by reflection -- the main module is declared `open`). fromConfig() +// still runs for real, committing the NATIVE_DEFAULT scratch the cleanup must +// reclaim, so the memory assertions are meaningful. +public class QueryClientPoolErrorSafetyTest { + + // ws config that fromConfig() parses without opening a socket; the injected + // connectHook replaces connect(), so the port is never dialled. + private static final String CFG = "ws::addr=127.0.0.1:9000;"; + + // Site: acquire() inner catch around client.connect(). createUnlocked() must + // close the half-built client when connect throws an Error, otherwise the + // field-initialised QwpBindValues scratch (NATIVE_DEFAULT) leaks. + // RED: catch (RuntimeException) -> Error skips client.close() -> leak. + // GREEN: catch (Throwable) -> client.close() runs -> no leak. + @Test(timeout = 30_000) + public void acquireDoesNotLeakNativeScratchOnErrorFromConnect() throws Exception { + QueryClientPool pool = newPool(CFG, 0, 1, 250, alwaysThrow()); + try { + long baseline = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + try { + pool.acquire(); + Assert.fail("expected acquire() to propagate the injected Error"); + } catch (Throwable expected) { + // wrapped or raw -- the leak check is the discriminator + } + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + Assert.assertEquals( + "acquire() leaked NATIVE_DEFAULT scratch on an Error from connect()", + baseline, after); + } finally { + pool.close(); + } + } + + // Site: acquire() outer catch around createUnlocked()/start(). An Error must + // still run inFlightCreations--, otherwise the reserved slot is leaked and + // (maxSize == 1) the pool is wedged forever. + // RED: catch (RuntimeException) -> inFlightCreations stuck at 1. + // GREEN: catch (Throwable) -> inFlightCreations restored to 0. + @Test(timeout = 30_000) + public void acquireRestoresInFlightCreationsOnErrorFromConnect() throws Exception { + QueryClientPool pool = newPool(CFG, 0, 1, 250, alwaysThrow()); + try { + try { + pool.acquire(); + Assert.fail("expected acquire() to propagate the injected Error"); + } catch (Throwable expected) { + // expected + } + + Assert.assertEquals( + "acquire() leaked an in-flight creation slot on an Error from connect()", + 0, inFlightCreations(pool)); + + // Corollary: capacity is usable again -- the next acquire() must + // reach the creation path (and fail there) rather than time out. + try { + pool.acquire(); + Assert.fail("expected second acquire() to re-attempt creation"); + } catch (QueryException e) { + Assert.assertFalse( + "pool wedged: second acquire() timed out -> capacity permanently lost (" + + e.getMessage() + ")", + e.getMessage() != null && e.getMessage().contains("timed out")); + } catch (Throwable injectedAgain) { + // also fine: the Error surfaced again from the re-attempt + } + } finally { + pool.close(); + } + } + + // Site: constructor prewarm outer catch. An Error mid-prewarm must run the + // cleanup loop that shuts down already-built workers, otherwise the first + // worker's client (NATIVE_DEFAULT) and I/O thread leak. + // RED: catch (RuntimeException) -> first worker's client never closed. + // GREEN: catch (Throwable) -> cleanup loop closes it -> no leak. + @Test(timeout = 30_000) + public void preWarmDoesNotLeakNativeScratchOnErrorFromConnect() throws Exception { + long baseline = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + // First connect() succeeds (no-op, leaves the client unconnected but + // built); the second throws an Error mid-prewarm. + AtomicInteger calls = new AtomicInteger(); + Consumer hook = client -> { + if (calls.incrementAndGet() >= 2) { + throw new AssertionError("injected native connect failure"); + } + }; + try { + newPool(CFG, 2, 2, 250, hook); + Assert.fail("expected prewarm to propagate the injected Error"); + } catch (Throwable expected) { + // expected -- construction aborts + } + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + Assert.assertEquals( + "prewarm leaked NATIVE_DEFAULT scratch of an already-built worker on an Error", + baseline, after); + } + + // Site: acquire() outer catch around createUnlocked()/start(). When start() + // throws *after* createUnlocked() returned a fully connected client, that + // client (NATIVE_DEFAULT scratch + socket + I/O thread) must be torn down, + // otherwise it is stranded -- nothing else references it. This is the + // start()-throws path connectHook cannot reach: connect() runs (no-op here, + // committing the scratch) and the failure is injected at thread start. + // RED: catch does inFlightCreations-- but never created.shutdown() -> leak. + // GREEN: catch calls created.shutdown() -> client.close() -> no leak. + @Test(timeout = 30_000) + public void acquireDoesNotLeakNativeScratchOnErrorFromStart() throws Exception { + QueryClientPool pool = newPool(CFG, 0, 1, 250, noConnect(), alwaysThrowStart()); + try { + long baseline = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + try { + pool.acquire(); + Assert.fail("expected acquire() to propagate the injected start Error"); + } catch (Throwable expected) { + // wrapped or raw -- the leak check is the discriminator + } + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + Assert.assertEquals( + "acquire() leaked NATIVE_DEFAULT scratch on an Error from start()", + baseline, after); + // The reservation must also be restored so the pool is not wedged. + Assert.assertEquals( + "acquire() leaked an in-flight creation slot on an Error from start()", + 0, inFlightCreations(pool)); + } finally { + pool.close(); + } + } + + // Site: constructor prewarm. When start() throws after createUnlocked() + // returned a fully connected client, the just-built worker is not yet in + // `all`, so the cleanup loop (which only walks `all`) would skip it. Its + // client (NATIVE_DEFAULT) must still be torn down. + // RED: cleanup loop walks `all` only -> the worker that failed at start() + // is never closed -> leak. + // GREEN: the pending-worker teardown closes it -> no leak. + @Test(timeout = 30_000) + public void preWarmDoesNotLeakNativeScratchOnErrorFromStart() throws Exception { + long baseline = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + // First worker is admitted to `all` (start is a no-op here -- the test + // never runs queries, and an unstarted thread keeps the later + // shutdown()'s join() instant); the second throws at start() after its + // client is fully built. That second worker is the stranded one: it + // never made it into `all`, so only the new pending-worker teardown + // closes it. The assertion catches a leak of EITHER worker's scratch. + AtomicInteger calls = new AtomicInteger(); + Consumer startHook = w -> { + if (calls.incrementAndGet() >= 2) { + throw new AssertionError("injected thread-start failure"); + } + // first worker: leave the dispatch thread unstarted (see above) + }; + try { + newPool(CFG, 2, 2, 250, noConnect(), startHook); + Assert.fail("expected prewarm to propagate the injected start Error"); + } catch (Throwable expected) { + // expected -- construction aborts + } + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + Assert.assertEquals( + "prewarm leaked NATIVE_DEFAULT scratch of a start()-failed worker", + baseline, after); + } + + private static Consumer alwaysThrow() { + return client -> { + throw new AssertionError("injected native connect failure"); + }; + } + + // connectHook that connects nothing: fromConfig() has already committed the + // NATIVE_DEFAULT scratch, so the client is half-built (scratch, no socket) + // -- exactly the state createUnlocked() returns before start() runs. + private static Consumer noConnect() { + return client -> { + }; + } + + private static Consumer alwaysThrowStart() { + return worker -> { + throw new AssertionError("injected thread-start failure"); + }; + } + + private static int inFlightCreations(QueryClientPool pool) throws Exception { + Method m = QueryClientPool.class.getDeclaredMethod("inFlightCreations"); + m.setAccessible(true); + return (int) m.invoke(pool); + } + + private static QueryClientPool newPool( + String cfg, int min, int max, long acquireMs, Consumer connectHook + ) throws Exception { + Constructor c = QueryClientPool.class.getDeclaredConstructor( + String.class, int.class, int.class, long.class, long.class, long.class, Consumer.class); + c.setAccessible(true); + return c.newInstance(cfg, min, max, acquireMs, Long.MAX_VALUE, Long.MAX_VALUE, connectHook); + } + + private static QueryClientPool newPool( + String cfg, int min, int max, long acquireMs, + Consumer connectHook, Consumer startHook + ) throws Exception { + Constructor c = QueryClientPool.class.getDeclaredConstructor( + String.class, int.class, int.class, long.class, long.class, long.class, + Consumer.class, Consumer.class); + c.setAccessible(true); + return c.newInstance(cfg, min, max, acquireMs, Long.MAX_VALUE, Long.MAX_VALUE, + connectHook, startHook); + } +} diff --git a/core/src/test/java/io/questdb/client/test/impl/QuestDBImplErrorSafetyTest.java b/core/src/test/java/io/questdb/client/test/impl/QuestDBImplErrorSafetyTest.java new file mode 100644 index 00000000..93b10301 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/QuestDBImplErrorSafetyTest.java @@ -0,0 +1,154 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.impl; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; +import io.questdb.client.impl.QuestDBImpl; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.IntFunction; + +// Error-safety of the QuestDBImpl constructor that ties SenderPool and +// QueryClientPool together. The constructor builds the SenderPool first, then +// the QueryClientPool; both run heavy native build/connect paths that, under -ea, +// can throw an Error (AssertionError, OutOfMemoryError). If the orchestrator's +// cleanup catch is narrower than the pools' own (catch (RuntimeException) vs +// catch (Throwable)), an Error from QueryClientPool construction propagates +// without closing the already-built SenderPool, stranding its prewarmed +// delegates (flock + mmap'd ring + I/O thread). +// +// Sender is an interface, faked with a Proxy whose close() flips a flag, injected +// via the SenderPool senderFactory seam. The connect Error is injected via the +// QueryClientPool connectHook seam. Both are passed through the package-private +// QuestDBImpl seam constructor (reached by reflection -- the main module is +// declared `open`); production callers pass null for both. +public class QuestDBImplErrorSafetyTest { + + // Non-SF http config: the SenderPool factory replaces the build, but the + // constructor's eager config probe must still parse it. + private static final String SENDER_CFG = "http::addr=127.0.0.1:1;protocol_version=2;auto_flush=off;"; + // ws config that fromConfig() parses without opening a socket; the injected + // connectHook replaces connect(), so the port is never dialled. + private static final String QUERY_CFG = "ws::addr=127.0.0.1:9000;"; + + // RED: catch (RuntimeException) -> the Error from QueryClientPool construction + // skips the cleanup block -> the prewarmed SenderPool is never closed -> + // its fake delegate's close() is never called. + // GREEN: catch (Throwable) -> cleanup closes the SenderPool -> the fake + // delegate's close() runs. + @Test(timeout = 30_000) + public void ctorClosesBuiltSenderPoolWhenQueryPoolConstructionThrowsError() throws Exception { + AtomicBoolean senderClosed = new AtomicBoolean(false); + // senderMin = 1 -> SenderPool prewarms one observable delegate. + IntFunction senderFactory = slotIndex -> fakeSender(senderClosed); + // queryMin = 1 -> QueryClientPool prewarm reaches connect(), which throws. + Consumer connectHook = client -> { + throw new AssertionError("injected native connect failure"); + }; + + try { + newQuestDB(senderFactory, connectHook); + Assert.fail("expected QuestDBImpl construction to propagate the injected Error"); + } catch (Throwable expected) { + // expected -- construction aborts + } + + Assert.assertTrue( + "QuestDBImpl ctor leaked the already-built SenderPool on an Error from " + + "QueryClientPool construction: the prewarmed delegate's close() was never called", + senderClosed.get()); + } + + private static Sender fakeSender(AtomicBoolean closedFlag) { + return (Sender) Proxy.newProxyInstance( + Sender.class.getClassLoader(), + new Class[]{Sender.class}, + (proxy, method, args) -> { + switch (method.getName()) { + case "close": + closedFlag.set(true); + return null; + case "toString": + return "FakeSender"; + case "hashCode": + return System.identityHashCode(proxy); + case "equals": + return proxy == args[0]; + default: + Class rt = method.getReturnType(); + if (rt == boolean.class) return false; + if (rt == byte.class) return (byte) 0; + if (rt == short.class) return (short) 0; + if (rt == int.class) return 0; + if (rt == long.class) return 0L; + if (rt == float.class) return 0f; + if (rt == double.class) return 0d; + if (rt == char.class) return (char) 0; + if (rt == void.class) return null; + if (rt.isInstance(proxy)) return proxy; + return null; + } + }); + } + + private static QuestDBImpl newQuestDB( + IntFunction senderFactory, Consumer connectHook + ) throws Exception { + Constructor c = QuestDBImpl.class.getDeclaredConstructor( + String.class, String.class, int.class, int.class, int.class, int.class, + long.class, long.class, long.class, long.class, + IntFunction.class, Consumer.class); + c.setAccessible(true); + try { + return c.newInstance( + SENDER_CFG, QUERY_CFG, + /*senderMin*/ 1, /*senderMax*/ 1, + /*queryMin*/ 1, /*queryMax*/ 1, + /*acquireTimeoutMillis*/ 250L, + /*idleTimeoutMillis*/ Long.MAX_VALUE, + /*maxLifetimeMillis*/ Long.MAX_VALUE, + /*housekeeperIntervalMillis*/ Long.MAX_VALUE, + senderFactory, connectHook); + } catch (InvocationTargetException e) { + // Unwrap so the caller sees the real construction failure (Error or + // RuntimeException), matching a direct constructor invocation. + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw e; + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolErrorSafetyTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolErrorSafetyTest.java new file mode 100644 index 00000000..b7b56e7a --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolErrorSafetyTest.java @@ -0,0 +1,255 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.impl; + +import io.questdb.client.Sender; +import io.questdb.client.impl.SenderPool; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Proxy; +import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntFunction; + +// Error-safety of the SenderPool constructor prewarm catch that the +// teardown-hardening fix widened from catch (RuntimeException) to +// catch (Throwable). createUnlocked() runs a heavy native build path (mmap, +// flock, WebSocket connect) that can throw an Error under -ea; if the prewarm +// catch does not fire, the cleanup loop never runs and every already-built +// delegate leaks its flock + mmap'd ring + I/O thread -- resurrecting +// "sf slot already in use" on the next attempt. +// +// Sender is an interface, so the build path is faked with a Proxy whose close() +// flips a flag. The fake is injected via the package-private senderFactory +// constructor (reached by reflection -- the main module is declared `open`). +public class SenderPoolErrorSafetyTest { + + // Non-SF http config: fromConfig is never reached (the factory replaces the + // build), but the constructor's eager config probe must still parse it. + private static final String CFG = "http::addr=127.0.0.1:1;protocol_version=2;auto_flush=off;"; + + // RED: catch (RuntimeException) -> the Error from the 2nd build skips the + // cleanup loop -> the 1st delegate is never closed. + // GREEN: catch (Throwable) -> the cleanup loop closes the 1st delegate. + @Test(timeout = 30_000) + public void preWarmClosesBuiltDelegatesWhenBuildThrowsError() throws Exception { + AtomicBoolean firstClosed = new AtomicBoolean(false); + AtomicInteger calls = new AtomicInteger(); + IntFunction factory = slotIndex -> { + if (calls.incrementAndGet() >= 2) { + throw new AssertionError("injected native build failure"); + } + return fakeSender(firstClosed); + }; + + try { + newPool(CFG, 2, 2, 250, factory); + Assert.fail("expected prewarm to propagate the injected Error"); + } catch (Throwable expected) { + // expected -- construction aborts + } + + Assert.assertTrue( + "prewarm leaked an already-built delegate: its close() was never called on an Error", + firstClosed.get()); + } + + // Companion to the catch (RuntimeException) -> track-normal-completion fix in + // PooledSender.close(). flush() can exit with an Error (AssertionError under + // -ea, OutOfMemoryError, ...) as well as a RuntimeException; the wrapper is + // unsafe to recycle either way because Sender does not clear its buffer on a + // failed flush and WebSocket transport latches the failure. + // + // RED (catch (RuntimeException)): the AssertionError from flush() is not + // caught, broken stays false, the finally runs giveBack() -> the broken + // wrapper is recycled -> the next borrow() hands back the SAME instance. + // GREEN (track normal completion): flush() throwing leaves flushed=false -> + // discardBroken() -> the next borrow() builds a fresh wrapper. + @Test(timeout = 30_000) + public void flushErrorDiscardsBrokenSenderInsteadOfRecycling() throws Exception { + IntFunction factory = slotIndex -> flushThrowingSender(); + + try (SenderPool pool = newPool(CFG, 1, 1, 1_000, factory)) { + Sender first = pool.borrow(); + try { + first.close(); + Assert.fail("close() must propagate the Error thrown by flush()"); + } catch (AssertionError expected) { + // expected: the original throwable propagates naturally + } + + Sender second = pool.borrow(); + try { + Assert.assertNotSame( + "a sender whose flush() exited with an Error must be discarded, not recycled", + first, second); + } finally { + // second's flush() also throws on close(); swallow on teardown. + try { + second.close(); + } catch (AssertionError ignored) { + // expected + } + } + } + } + + // Like fakeSender(), but flush() throws an Error to drive the + // PooledSender.close() abnormal-exit branch. + private static Sender flushThrowingSender() { + return (Sender) Proxy.newProxyInstance( + Sender.class.getClassLoader(), + new Class[]{Sender.class}, + (proxy, method, args) -> { + switch (method.getName()) { + case "flush": + throw new AssertionError("injected flush failure"); + case "close": + return null; + case "toString": + return "FlushThrowingSender"; + case "hashCode": + return System.identityHashCode(proxy); + case "equals": + return proxy == args[0]; + default: + Class rt = method.getReturnType(); + if (rt == boolean.class) return false; + if (rt == byte.class) return (byte) 0; + if (rt == short.class) return (short) 0; + if (rt == int.class) return 0; + if (rt == long.class) return 0L; + if (rt == float.class) return 0f; + if (rt == double.class) return 0d; + if (rt == char.class) return (char) 0; + if (rt == void.class) return null; + // fluent ArraySender methods return Sender + if (rt.isInstance(proxy)) return proxy; + return null; + } + }); + } + + // Companion to the SF slot-index reservation in borrow(): when + // createUnlocked() throws on a borrow-triggered grow, the reserved slot + // index MUST be returned via freeSlotIndex(). Otherwise slotInUse[idx] is + // stuck true, pool capacity is permanently lowered, and the next borrow() + // either trips the "no free SF slot index" invariant in allocateSlotIndex() + // or eventually only times out -- the exact failure mode this PR fixes. + // + // The other SenderPool error-injection test fails in the constructor + // pre-warm loop with a non-SF config (slotIndex == -1), so neither the + // borrow-path freeSlotIndex nor the SF (slotIndex >= 0) case is otherwise + // covered. + // + // RED (freeSlotIndex(slotIndex) removed from the borrow catch): the 2nd + // borrow() throws IllegalStateException out of allocateSlotIndex(). + // GREEN (slot index returned): the 2nd borrow() reuses the slot and + // succeeds, proving capacity survived the failed grow. + @Test(timeout = 30_000) + public void borrowReleasesSfSlotIndexWhenCreationFails() throws Exception { + // Unique, non-existent sf_dir: minSize=0 means no pre-warm, so the dir + // is never created and the constructor's startup SF recovery is a no-op. + // The factory replaces createUnlocked(), so localhost:1 is never dialed. + String sfDir = Paths.get(System.getProperty("java.io.tmpdir"), + "qdb-sf-borrowfail-" + System.nanoTime()).toString(); + String sfCfg = "ws::addr=localhost:1;sf_dir=" + sfDir + ";"; + + AtomicInteger calls = new AtomicInteger(); + IntFunction factory = slotIndex -> { + // First borrow-triggered build fails (the slot index reserved for + // it must be released); later builds succeed. + if (calls.getAndIncrement() == 0) { + throw new AssertionError("injected native build failure on first grow"); + } + return fakeSender(new AtomicBoolean()); + }; + + try (SenderPool pool = newPool(sfCfg, 0, 1, 2_000, factory)) { + try { + pool.borrow(); + Assert.fail("borrow() must propagate the Error from the failed build"); + } catch (AssertionError expected) { + // expected: the original throwable propagates out of borrow() + } + + // The single SF slot index must have been returned to the free set. + // If it leaked, this borrow() trips the capacity invariant (or, in + // the timeout-only variant, exhausts the acquire budget). + Sender second = pool.borrow(); + try { + Assert.assertNotNull( + "after a failed grow the SF slot index must be reusable", second); + } finally { + second.close(); + } + } + } + + private static Sender fakeSender(AtomicBoolean closedFlag) { + return (Sender) Proxy.newProxyInstance( + Sender.class.getClassLoader(), + new Class[]{Sender.class}, + (proxy, method, args) -> { + switch (method.getName()) { + case "close": + closedFlag.set(true); + return null; + case "toString": + return "FakeSender"; + case "hashCode": + return System.identityHashCode(proxy); + case "equals": + return proxy == args[0]; + default: + Class rt = method.getReturnType(); + if (rt == boolean.class) return false; + if (rt == byte.class) return (byte) 0; + if (rt == short.class) return (short) 0; + if (rt == int.class) return 0; + if (rt == long.class) return 0L; + if (rt == float.class) return 0f; + if (rt == double.class) return 0d; + if (rt == char.class) return (char) 0; + if (rt == void.class) return null; + // fluent ArraySender methods return Sender + if (rt.isInstance(proxy)) return proxy; + return null; + } + }); + } + + private static SenderPool newPool( + String cfg, int min, int max, long acquireMs, IntFunction senderFactory + ) throws Exception { + Constructor c = SenderPool.class.getDeclaredConstructor( + String.class, int.class, int.class, long.class, long.class, long.class, IntFunction.class); + c.setAccessible(true); + return c.newInstance(cfg, min, max, acquireMs, Long.MAX_VALUE, Long.MAX_VALUE, senderFactory); + } +} 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 new file mode 100644 index 00000000..e4b2b49a --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java @@ -0,0 +1,2063 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.impl; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner; +import io.questdb.client.impl.PooledSender; +import io.questdb.client.impl.SenderPool; +import io.questdb.client.std.Files; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Paths; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.IntFunction; + +/** + * Exhaustive tests for {@link SenderPool} interaction with store-and-forward + * (SF) slots. + *

+ * The pool reuses one immutable config string for every sender it builds, so + * before the slot-id fix every SF sender inherited the same {@code sender_id}, + * pointed at the same {@code /} slot, and the second sender + * to start died with "sf slot already in use by another process". These tests + * pin down the fix: each pooled SF sender gets a distinct, stable slot id + * {@code -}; indices are reused deterministically; a slot is only + * returned to the free set once its delegate releases the {@code flock}; and + * the cross-writer guard that the slot lock exists for is still enforced + * between independent pools. + */ +public class SenderPoolSfTest { + + private String sfDir; + + @Before + public void setUp() { + sfDir = Paths.get(System.getProperty("java.io.tmpdir"), + "qdb-sf-pool-" + System.nanoTime()).toString(); + } + + @After + public void tearDown() { + rmDir(sfDir); + } + + // ---------------------------------------------------------------------- + // Core fix: the original claim repro -- two concurrent SF senders. + // ---------------------------------------------------------------------- + + @Test + public void testTwoConcurrentSfSendersGetDistinctSlots() throws Exception { + // The exact scenario from the bug report: a maxSize=2 SF pool must + // hand out two live senders. Pre-fix, the second borrow() blew up on + // the slot flock. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(config, 1, 2, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + try { + Assert.assertNotSame("two borrows must be distinct wrappers", a, b); + Assert.assertTrue("slot default-0 must exist", Files.exists(slot("default-0"))); + Assert.assertTrue("slot default-1 must exist", Files.exists(slot("default-1"))); + Assert.assertEquals("exactly two slot dirs", 2, countSlotDirs()); + } finally { + b.close(); + a.close(); + } + } + } + }); + } + + @Test + public void testGrowToMaxAllSfSendersCoexist() throws Exception { + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(config, 1, 4, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + PooledSender c = pool.borrow(); + PooledSender d = pool.borrow(); + try { + Assert.assertEquals(4, pool.totalSize()); + for (int i = 0; i < 4; i++) { + Assert.assertTrue("slot default-" + i + " must exist", + Files.exists(slot("default-" + i))); + } + Assert.assertEquals(4, countSlotDirs()); + // At max -- the 5th borrow must time out, not collide. + try { + pool.borrow(); + Assert.fail("5th borrow must time out at max=4"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); + } + } finally { + d.close(); + c.close(); + b.close(); + a.close(); + } + } + } + }); + } + + @Test + public void testConfiguredSenderIdUsedAsSlotBase() throws Exception { + // A sender_id in the config string becomes the base prefix; the pool + // appends - per slot. This is the knob that lets two pools (or + // two processes) share one sf_dir without colliding. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";sender_id=myapp;"; + try (SenderPool pool = new SenderPool(config, 2, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + Assert.assertTrue(Files.exists(slot("myapp-0"))); + Assert.assertTrue(Files.exists(slot("myapp-1"))); + Assert.assertFalse("no default-* slots when sender_id is set", + Files.exists(slot("default-0"))); + Assert.assertEquals(2, countSlotDirs()); + } + } + }); + } + + // ---------------------------------------------------------------------- + // Slot lifecycle: reuse, reap-and-reuse, deterministic index recycling. + // ---------------------------------------------------------------------- + + @Test + public void testReturnedSenderReusesSameSlot() throws Exception { + // Borrow, return, borrow again: the wrapper (and thus its slot) is + // recycled, NOT grown into a new slot dir. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(config, 1, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender first = pool.borrow(); + first.close(); + PooledSender second = pool.borrow(); + try { + Assert.assertSame("returned slot must be recycled", first, second); + Assert.assertEquals("no new slot dir on recycle", 1, countSlotDirs()); + Assert.assertTrue(Files.exists(slot("default-0"))); + } finally { + second.close(); + } + } + } + }); + } + + @Test + public void testReapIdleFreesSlotAndIndexIsReused() throws Exception { + // Grow to max, return all, reap the over-min idle slots, then borrow + // again. The reaped slot indices must be returned to the free set and + // re-used -- no new index beyond the original high-water mark, and no + // "no free SF slot index" / "sf slot already in use" failure. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(config, 1, 3, 1_000, 80, Long.MAX_VALUE)) { + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + PooledSender c = pool.borrow(); + Assert.assertEquals(3, pool.totalSize()); + a.close(); + b.close(); + c.close(); + + Thread.sleep(150); + pool.reapIdle(); + Assert.assertEquals("reap shrinks to min", 1, pool.totalSize()); + + // High-water mark of slot dirs created so far is 3. + int dirsAfterReap = countSlotDirs(); + Assert.assertTrue("slot dirs persist on disk after reap (>=1)", dirsAfterReap >= 1); + + // Borrow back up to max. Must reuse the freed indices: no + // new slot dir beyond default-0..2 is ever created. + PooledSender x = pool.borrow(); + PooledSender y = pool.borrow(); + PooledSender z = pool.borrow(); + try { + Assert.assertEquals(3, pool.totalSize()); + Assert.assertEquals("indices recycled -- no 4th slot dir", + 3, countSlotDirs()); + Assert.assertFalse("default-3 must never be created", + Files.exists(slot("default-3"))); + } finally { + x.close(); + y.close(); + z.close(); + } + } + } + }); + } + + @Test + public void testRepeatedSaturationNeverExhaustsSlotIndices() throws Exception { + // Regression guard for the cap/slot-allocator invariant: hammer the + // pool through many full saturate/return/reap cycles. If freeing and + // the closingSlots accounting ever drifted, allocateSlotIndex() would + // throw "no free SF slot index" or a borrow would collide on a flock. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(config, 1, 3, 2_000, 1, Long.MAX_VALUE)) { + for (int cycle = 0; cycle < 20; cycle++) { + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + PooledSender c = pool.borrow(); + Assert.assertEquals(3, pool.totalSize()); + a.close(); + b.close(); + c.close(); + pool.reapIdle(); + } + // Never grew past max -- indices stayed within [0,3). + Assert.assertEquals(3, countSlotDirs()); + Assert.assertFalse(Files.exists(slot("default-3"))); + } + } + }); + } + + // ---------------------------------------------------------------------- + // End-to-end ingest through pooled SF senders. + // ---------------------------------------------------------------------- + + @Test + public void testEndToEndIngestThroughPooledSenders() throws Exception { + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(config, 1, 3, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + try { + a.table("t1").longColumn("v", 1L).atNow(); + a.flush(); + b.table("t2").longColumn("v", 2L).atNow(); + b.flush(); + Assert.assertTrue("server must receive frames from both pooled senders", + awaitAtLeast(handler.frames, 2, 5_000)); + } finally { + b.close(); + a.close(); + } + } + } + }); + } + + // ---------------------------------------------------------------------- + // Cross-writer guard is preserved between independent pools / processes. + // ---------------------------------------------------------------------- + + @Test + public void testSecondPoolSameSfDirSameBaseFailsFast() throws Exception { + // The slot flock is the multi-writer footgun guard. Two pools sharing + // one sf_dir with the same base would both try slot -0; the + // second must fail fast rather than interleave FSNs on disk. The pool + // fix must NOT weaken this contract. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool1 = new SenderPool(config, 1, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + Assert.assertTrue(Files.exists(slot("default-0"))); + try { + SenderPool pool2 = new SenderPool(config, 1, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE); + pool2.close(); + Assert.fail("second pool on same sf_dir+base must fail on the slot lock"); + } catch (IllegalStateException e) { + Assert.assertTrue("message must name the slot-in-use contract, was: " + e.getMessage(), + e.getMessage().contains("sf slot already in use")); + } + } + } + }); + } + + @Test + public void testTwoPoolsDistinctBaseShareSfDir() throws Exception { + // Distinct sender_id base per pool -> distinct slot dirs -> both pools + // coexist on one sf_dir and both can ingest. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String configA = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";sender_id=appA;"; + String configB = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";sender_id=appB;"; + try (SenderPool poolA = new SenderPool(configA, 1, 2, 5_000, Long.MAX_VALUE, Long.MAX_VALUE); + SenderPool poolB = new SenderPool(configB, 1, 2, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender a = poolA.borrow(); + PooledSender b = poolB.borrow(); + try { + Assert.assertTrue(Files.exists(slot("appA-0"))); + Assert.assertTrue(Files.exists(slot("appB-0"))); + a.table("t").longColumn("v", 1L).atNow(); + a.flush(); + b.table("t").longColumn("v", 2L).atNow(); + b.flush(); + Assert.assertTrue(awaitAtLeast(handler.frames, 2, 5_000)); + } finally { + b.close(); + a.close(); + } + } + } + }); + } + + @Test + public void testCloseReleasesAllSlots() throws Exception { + // After the pool closes, every slot flock must be released so the + // dirs can be re-acquired -- by a fresh pool or a standalone sender. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + SenderPool pool = new SenderPool(config, 2, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE); + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + // Leave them borrowed: close() must still release their flocks. + pool.close(); + + // A fresh pool over the same dirs must re-acquire slot 0 and 1. + try (SenderPool reopened = new SenderPool(config, 2, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + Assert.assertEquals(2, reopened.totalSize()); + Assert.assertTrue(Files.exists(slot("default-0"))); + Assert.assertTrue(Files.exists(slot("default-1"))); + } + } + }); + } + + @Test + public void testSlotLeakedWhenDelegateCloseDoesNotReleaseFlock() throws Exception { + // Latent-fragility guard (M2): the pool returns a slot index to the + // free set ONLY after the delegate's close() has released the SF + // flock. If close() returns with the flock still held (it bailed out + // early with the I/O thread still running), the index must stay + // reserved forever -- otherwise the pool would hand the still-locked + // dir to the next borrow and resurrect "sf slot already in use". + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(config, 1, 2, 500, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender a = pool.borrow(); + Assert.assertTrue(Files.exists(slot("default-0"))); + + // Tear the delegate down for real first (so the test leaks + // no native resources), then forge the exact symptom: + // close() returned WITHOUT clearing slotLockReleased. + // close() is idempotent, so the discardBroken re-close + // below is a no-op and leaves the forged flag in place. + Sender delegate = getDelegate(a); + delegate.close(); + setBooleanField(delegate, "slotLockReleased", false); + + // Route the wrapper through the pool's broken-eviction path. + invokeDiscardBroken(pool, a); + + // The leaked index must NOT be returned to the free set, + // and capacity must be accounted as permanently consumed. + Assert.assertEquals("one slot must be retired as leaked", + 1, getIntField(pool, "leakedSlots")); + boolean[] slotInUse = (boolean[]) getField(pool, "slotInUse"); + Assert.assertTrue("leaked slot index 0 must stay reserved", slotInUse[0]); + + // The next borrow must take a fresh index -- never reuse the + // still-locked default-0 dir. + PooledSender b = pool.borrow(); + try { + Assert.assertTrue("new borrow must use a fresh slot dir", + Files.exists(slot("default-1"))); + Assert.assertEquals(2, countSlotDirs()); + + // Capacity is permanently reduced by the leaked slot: + // max=2, one leaked + one live => the next borrow times + // out rather than colliding on the locked dir. + try { + pool.borrow(); + Assert.fail("capacity must be reduced by the leaked slot"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); + } + } finally { + b.close(); + } + } + } + }); + } + + @Test + public void testLeakedSlotIsObservable() throws Exception { + // M1 (observability): when a delegate's close() returns with the SF + // flock still held, the pool retires the slot forever and silently + // shrinks capacity. SenderPool has no logger today, so a pool that + // bleeds capacity this way degrades to "every borrow() times out" + // with nothing in the logs to explain why. Pin the contract: the + // leakedSlots++ path MUST emit a WARN (or louder) that names the + // retired slot. This test is RED until that log line is added. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + // Capture everything SenderPool logs (logback is the SLF4J + // binding on the test classpath). + Logger poolLogger = (Logger) LoggerFactory.getLogger(SenderPool.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + Level savedLevel = poolLogger.getLevel(); + poolLogger.setLevel(Level.ALL); + poolLogger.addAppender(appender); + try { + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(config, 1, 2, 500, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender a = pool.borrow(); + + // Forge the exact leak symptom: close() returned with + // the flock still held (I/O thread refused to stop). + // Tear the delegate down for real first so the test + // leaks no native resources; close() is idempotent so + // discardBroken's re-close leaves the forged flag set. + Sender delegate = getDelegate(a); + delegate.close(); + setBooleanField(delegate, "slotLockReleased", false); + invokeDiscardBroken(pool, a); + + // Sanity: the slot really was retired as leaked. + Assert.assertEquals("precondition: one slot must leak", + 1, getIntField(pool, "leakedSlots")); + // The leak must be observable via public API (metric). + Assert.assertEquals("leaked slot must be observable via leakedSlotCount()", + 1, pool.leakedSlotCount()); + + // Contract under test: the leak must be observable. + boolean warned = appender.list.stream().anyMatch(e -> + e.getLevel().isGreaterOrEqual(Level.WARN) + && e.getFormattedMessage().toLowerCase().contains("slot")); + Assert.assertTrue( + "leakedSlots++ must emit a WARN naming the retired slot, " + + "otherwise capacity loss is invisible; captured events=" + + appender.list, + warned); + } + } finally { + poolLogger.detachAppender(appender); + poolLogger.setLevel(savedLevel); + appender.stop(); + } + } + }); + } + + @Test + public void testSlotLeakedWhenDelegateCloseDoesNotReleaseFlockDuringReap() throws Exception { + // Coverage twin of testSlotLeakedWhenDelegateCloseDoesNotReleaseFlock, + // but the close-and-reclaim is driven through reapIdle()'s leaked + // branch -- reclaimSlot(s, " during idle reaping") -- rather than + // discardBroken(). reapIdle is the only one of the three + // close-and-reclaim paths whose flockReleased()==false branch had no + // test: the existing reap tests use live QWP delegates (flock IS + // released => free path), and testReapIdleSurvivesDelegateCloseError + // is HTTP (storeAndForward off => reclaimSlot never runs). Pin it: an + // idle delegate whose close() leaves the flock held must retire the + // slot permanently (leakedSlots++, slotInUse stays set), never hand + // the still-locked dir to a later borrow. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + // minSize=0 so reapIdle is free to evict; idleTimeout=1ms so a + // returned slot is immediately reap-eligible. + try (SenderPool pool = new SenderPool(config, 0, 2, 500, 1, Long.MAX_VALUE)) { + PooledSender a = pool.borrow(); + Assert.assertTrue(Files.exists(slot("default-0"))); + + // Return it to the idle set with a LIVE delegate, then + // forge the exact leak symptom: tear the delegate down for + // real (so no native resources leak) and clear + // slotLockReleased. close() is idempotent, so reapIdle's + // re-close is a no-op that leaves the flock "still held". + pool.giveBack(a); + Sender delegate = getDelegate(a); + delegate.close(); + setBooleanField(delegate, "slotLockReleased", false); + + // Drive the sweep: the idle timeout has elapsed. + Thread.sleep(10); + pool.reapIdle(); + + // The reap leaked branch must have fired. + Assert.assertEquals("reapIdle must retire the still-locked slot as leaked", + 1, getIntField(pool, "leakedSlots")); + boolean[] slotInUse = (boolean[]) getField(pool, "slotInUse"); + Assert.assertTrue("leaked slot index 0 must stay reserved", slotInUse[0]); + Assert.assertEquals("leaked slot must be observable via leakedSlotCount()", + 1, pool.leakedSlotCount()); + + // The next borrow must take a fresh index -- never reuse + // the still-locked default-0 dir. + PooledSender b = pool.borrow(); + try { + Assert.assertTrue("new borrow must use a fresh slot dir", + Files.exists(slot("default-1"))); + Assert.assertEquals(2, countSlotDirs()); + + // Capacity is permanently reduced by the leaked slot: + // max=2, one leaked + one live => the next borrow times + // out rather than colliding on the locked dir. + try { + pool.borrow(); + Assert.fail("capacity must be reduced by the leaked slot"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); + } + } finally { + b.close(); + } + } + } + }); + } + + // ---------------------------------------------------------------------- + // Recovery: stable slot ids let a re-created pool re-adopt unacked data. + // ---------------------------------------------------------------------- + + @Test + public void testRecoveryReplayThroughPooledSlot() throws Exception { + // Phase 1: write rows to a slot against a silent server (no acks), so + // the data persists unacked on disk under default-0. Close. + // Phase 2: a new pool against an ack-ing server re-adopts default-0 + // (stable index) and replays the unacked frames. Stable, deterministic + // slot ids are exactly what make this recovery possible. + TestUtils.assertMemoryLeak(() -> { + // Phase 1 -- silent server. + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String cfg1 = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=500;"; + try (SenderPool pool = new SenderPool(cfg1, 1, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender s = pool.borrow(); + for (int i = 0; i < 3; i++) { + s.table("recover").longColumn("v", i).atNow(); + s.flush(); + } + s.close(); + } + } + // Data must be on disk, unacked, under default-0. + Assert.assertTrue("unacked data must persist on disk", hasSegmentFile(slot("default-0"))); + + // Phase 2 -- ack-ing server, brand-new pool, same sf_dir. + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + int ackPort = ack.getPort(); + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + String cfg2 = "ws::addr=localhost:" + ackPort + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(cfg2, 1, 1, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender s = pool.borrow(); + try { + // Drain replays the recovered, unacked frames. + s.drain(5_000); + Assert.assertTrue("recovered frames must be replayed to the new server", + awaitAtLeast(handler.frames, 1, 5_000)); + } finally { + s.close(); + } + } + } + }); + } + + @Test + public void testRecoveryDelegateForcesOffInitialConnectMode() throws Exception { + // M1 regression: a startup-recovery delegate runs on the PoolHousekeeper + // thread, so its build() must NOT inherit the user's SYNC initial-connect + // mode (auto-enabled by any reconnect_* knob). SYNC would retry the + // connect for the whole reconnect budget inside build() -- far past + // PoolHousekeeper.STOP_TIMEOUT_MILLIS -- so a close() landing during that + // build would make the housekeeper join time out and leave the recoverer + // holding the slot flock after close() returned. The recovery factory + // forces initial_connect_mode=OFF (at most one connect attempt); the + // normal factory must still honour the user's promoted SYNC mode. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + int ackPort = ack.getPort(); + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + // reconnect_max_duration_millis set, initial_connect_mode unset + // -> the builder promotes to SYNC for ordinary senders. + String cfg = "ws::addr=localhost:" + ackPort + ";sf_dir=" + sfDir + + ";reconnect_max_duration_millis=30000;"; + // min=0 (no prewarm connect), no stranded data (recovery no-op). + try (SenderPool pool = new SenderPool(cfg, 0, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + // Normal managed-slot delegate: inherits the promoted SYNC. + Sender normal = invokeBuildSlotDelegate(pool, "defaultSender", 0); + try { + Assert.assertEquals( + "ordinary pooled sender must honour the user's promoted SYNC mode", + Sender.InitialConnectMode.SYNC, readInitialConnectMode(normal)); + } finally { + normal.close(); + } + // Recovery delegate on a different slot: forced OFF. + Sender recoverer = invokeBuildSlotDelegate(pool, "defaultRecoverySender", 1); + try { + Assert.assertEquals( + "recovery delegate must force OFF so build() makes at most one connect attempt", + Sender.InitialConnectMode.OFF, readInitialConnectMode(recoverer)); + } finally { + recoverer.close(); + } + } + } + }); + } + + @Test + public void testStartupRecoveryRetiresSlotWhenRecovererCloseLeavesFlockHeld() throws Exception { + // C1 regression: the startup recovery loop MUST mirror discardBroken / + // reapIdle. When a recoverer's delegate close() returns with the SF + // flock still held (the I/O thread refused to stop), the recovered slot + // index must be retired permanently (leakedSlots++, slotInUse stays + // set) -- NOT freed. Freeing it would let a later borrow re-pick the + // still-locked dir and resurrect "sf slot already in use", the exact + // failure class this PR exists to kill. Pre-fix the recovery finally + // set slotInUse[i]=false unconditionally; this test is RED until it + // consults flockReleased() like the other two close-and-reclaim paths. + TestUtils.assertMemoryLeak(() -> { + // Phase 1: leave unacked data on disk under default-0 so startup + // recovery treats it as a candidate orphan and builds a recoverer. + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String cfg1 = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=500;"; + try (SenderPool pool = new SenderPool(cfg1, 1, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender s = pool.borrow(); + for (int i = 0; i < 3; i++) { + s.table("recover").longColumn("v", i).atNow(); + s.flush(); + } + s.close(); + } + } + Assert.assertTrue("unacked data must persist under default-0", + hasSegmentFile(slot("default-0"))); + + // Phase 2: ack-ing server + a new pool whose injected factory forges + // the exact leak symptom for the recovery build of slot 0. The + // factory returns a real, flock-holding QwpWebSocketSender but + // pre-sets closed=true, so the recovery close() is a complete no-op + // (checkNotClosed short-circuits drain too): the flock stays held + // and slotLockReleased never flips -- precisely a refused I/O-thread + // stop. flockReleased(recoverer) must therefore report false. + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + int ackPort = ack.getPort(); + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + String cfg2 = "ws::addr=localhost:" + ackPort + ";sf_dir=" + sfDir + ";"; + + AtomicReference forged = new AtomicReference<>(); + IntFunction factory = idx -> { + Sender real = Sender.builder(cfg2).senderId("default-" + idx).build(); + if (idx == 0) { + try { + setBooleanField(real, "closed", true); + } catch (Exception e) { + throw new RuntimeException(e); + } + forged.set(real); + } + return real; + }; + + // minSize=0 so prewarm never adopts slot 0 -- recovery is the + // only builder of slot 0. maxSize=2 so a later borrow can still + // get a fresh slot (default-1), proving capacity dropped by one. + SenderPool pool = newPoolWithFactory(cfg2, 0, 2, 500, factory); + try { + // The forge must actually have reached recovery's build. + Assert.assertNotNull("recovery must have built slot 0", forged.get()); + // The retire branch must have fired during construction. + Assert.assertEquals("recovery must retire the still-locked slot as leaked", + 1, getIntField(pool, "leakedSlots")); + boolean[] slotInUse = (boolean[]) getField(pool, "slotInUse"); + Assert.assertTrue("retired slot 0 must stay reserved", slotInUse[0]); + Assert.assertFalse("slot 1 must remain free", slotInUse[1]); + + // A later borrow must take the fresh slot 1, never re-pick + // the still-locked default-0 (which would throw "sf slot + // already in use"). + PooledSender b = pool.borrow(); + try { + Assert.assertTrue("borrow must use a fresh slot dir", + Files.exists(slot("default-1"))); + // Capacity is permanently reduced by the leaked slot: + // max=2, one leaked + one live => the next borrow times + // out rather than colliding on the locked dir. + try { + pool.borrow(); + Assert.fail("capacity must be reduced by the leaked slot"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out")); + } + } finally { + b.close(); + } + } finally { + pool.close(); + // Release the forged recoverer's real flock + native + // resources: pool.close() never saw it (recovery never + // added the recoverer to `all`), so un-forge closed and + // close it for real, otherwise assertMemoryLeak trips. + Sender leaked = forged.get(); + if (leaked != null) { + setBooleanField(leaked, "closed", false); + leaked.close(); + } + } + } + }); + } + + // ---------------------------------------------------------------------- + // Concurrency stress: borrow/return churn must never collide on a slot. + // ---------------------------------------------------------------------- + + @Test + public void testConcurrentBorrowReturnStress() throws Exception { + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + final int threads = 6; + final int iterations = 25; + try (SenderPool pool = new SenderPool(config, 1, 4, 10_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + final CountDownLatch start = new CountDownLatch(1); + final CountDownLatch done = new CountDownLatch(threads); + final AtomicReference failure = new AtomicReference<>(); + for (int t = 0; t < threads; t++) { + final int id = t; + Thread worker = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < iterations; i++) { + PooledSender s = pool.borrow(); + try { + s.table("stress").longColumn("thread", id) + .longColumn("i", i).atNow(); + s.flush(); + } finally { + s.close(); + } + } + } catch (Throwable e) { + failure.compareAndSet(null, e); + } finally { + done.countDown(); + } + }); + worker.start(); + } + start.countDown(); + Assert.assertTrue("workers must finish", done.await(60, TimeUnit.SECONDS)); + if (failure.get() != null) { + throw new AssertionError("concurrent borrow/return failed", failure.get()); + } + // Invariants after the storm. + Assert.assertTrue("totalSize within max", pool.totalSize() <= 4); + Assert.assertTrue("available <= total", + pool.availableSize() <= pool.totalSize()); + Assert.assertTrue("no slot dir beyond max created", countSlotDirs() <= 4); + } + } + }); + } + + @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 + // 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 + // pool. Post-fix, a benign creation race is treated as success. + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + 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. + 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", + Files.exists(sfDir)); + + final int threads = 4; + final CyclicBarrier barrier = new CyclicBarrier(threads); + final CountDownLatch done = new CountDownLatch(threads); + final AtomicReference failure = new AtomicReference<>(); + final PooledSender[] borrowed = new PooledSender[threads]; + for (int t = 0; t < threads; t++) { + final int id = t; + Thread worker = new Thread(() -> { + try { + // Align all first borrows so they race into the + // shared parent mkdir simultaneously. + barrier.await(); + borrowed[id] = pool.borrow(); + } catch (Throwable e) { + failure.compareAndSet(null, e); + } finally { + done.countDown(); + } + }); + worker.start(); + } + Assert.assertTrue("workers must finish", done.await(30, TimeUnit.SECONDS)); + try { + if (failure.get() != null) { + throw new AssertionError( + "concurrent first borrows must not race on sf_dir", failure.get()); + } + Assert.assertEquals(threads, pool.totalSize()); + Assert.assertEquals("one slot dir per borrow", threads, countSlotDirs()); + } finally { + for (PooledSender s : borrowed) { + if (s != null) { + s.close(); + } + } + } + } + } + }); + } + + @Test + public void testFirstBorrowToleratesPreExistingSfDir() throws Exception { + // Deterministic complement to testConcurrentFirstBorrowsWithMinZeroRaceOnSfDir. + // That test only RAISES the probability of two threads racing into + // Files.mkdir(sfDir); on a run where one thread wins the mkdir cleanly + // before the others reach the exists() check, the benign-race branch + // in Sender.build() is never hit and the test would pass even on + // pre-fix code. This test removes the timing dependency: it pre-creates + // sfDir so EVERY first borrow finds the parent already present and must + // build successfully without throwing "could not create sf_dir". + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + // Pre-create the shared parent: now build()'s mkdir guard is + // skipped on every borrow, deterministically asserting that a + // pre-existing sf_dir is tolerated rather than fatal. + Assert.assertEquals("pre-create sf_dir must succeed", + 0, Files.mkdir(sfDir, Files.DIR_MODE_DEFAULT)); + Assert.assertTrue(Files.exists(sfDir)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(config, 0, 4, 10_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + try { + Assert.assertEquals(2, pool.totalSize()); + Assert.assertTrue(Files.exists(slot("default-0"))); + Assert.assertTrue(Files.exists(slot("default-1"))); + } finally { + a.close(); + b.close(); + } + } + } + }); + } + + // ---------------------------------------------------------------------- + // drain_orphans=on + pool: the pool must NOT treat its own sibling slots + // as drainable orphans, but MUST still drain genuine foreign leftovers. + // ---------------------------------------------------------------------- + + @Test + public void testDrainOrphansPoolDoesNotCannibalizeSiblingSlots() throws Exception { + // Regression guard. The pool gives each SF sender a sibling slot + // -. With drain_orphans=on, every pooled build runs an + // orphan scan -- and before the namespace-exclusion fix that scan + // listed the pool's OWN siblings (default-1 holds unacked .sfa) as + // orphans and dispatched a background drainer at them. That drainer + // could win a sibling's flock and re-surface the exact + // "sf slot already in use" collision the per-slot ids were added to + // prevent. After the fix the pool fences off its whole "-" + // namespace, so building a drain_orphans pool over pre-existing + // sibling data is clean and the data is recovered by the pool itself. + TestUtils.assertMemoryLeak(() -> { + // Phase 1: seed unacked data into default-0 AND default-1 via a + // plain (no drain_orphans) pool against a silent server. + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String seedCfg = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=500;"; + try (SenderPool seed = new SenderPool(seedCfg, 2, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender a = seed.borrow(); + PooledSender b = seed.borrow(); + a.table("recover").longColumn("v", 1L).atNow(); + a.flush(); + b.table("recover").longColumn("v", 2L).atNow(); + b.flush(); + b.close(); + a.close(); + } + } + Assert.assertTrue("default-0 must hold unacked data", hasSegmentFile(slot("default-0"))); + Assert.assertTrue("default-1 must hold unacked data", hasSegmentFile(slot("default-1"))); + + // Phase 2: a drain_orphans=on pool over the same sf_dir. Pre-fix + // this construction could throw "sf slot already in use"; post-fix + // it is deterministically clean -- no drainer targets a sibling. + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + int ackPort = ack.getPort(); + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + ackPort + ";sf_dir=" + sfDir + + ";drain_orphans=on;"; + try (SenderPool pool = new SenderPool(cfg, 2, 2, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + Assert.assertEquals(2, pool.totalSize()); + Assert.assertEquals("no extra slot dirs spawned by a rogue drainer", + 2, countSlotDirs()); + // A drainer must NOT have given up on a sibling slot. + Assert.assertFalse("sibling must not be flagged .failed", + Files.exists(slot("default-0") + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + Assert.assertFalse("sibling must not be flagged .failed", + Files.exists(slot("default-1") + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + + // The pool owns and recovers both slots: borrowing + draining + // replays the recovered frames through the legitimate senders. + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + try { + a.drain(5_000); + b.drain(5_000); + Assert.assertTrue("both slots' recovered data must replay", + awaitAtLeast(handler.frames, 2, 5_000)); + } finally { + b.close(); + a.close(); + } + } + } + }); + } + + @Test + public void testDrainOrphansPoolStillDrainsForeignOrphan() throws Exception { + // The fix excludes only the pool's OWN "-" namespace -- a + // genuine foreign leftover (a different sender_id base) must still be + // adopted and drained, otherwise we would have silently disabled the + // drain_orphans feature for pooled deployments. + TestUtils.assertMemoryLeak(() -> { + // Phase 1: a standalone sender with a DIFFERENT base leaves unacked + // data under /legacy. + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + // close_flush_timeout_millis=0 => close() does not drain, so + // the flushed-but-unacked frames stay on disk (silent server + // never acks) and close() never throws a drain timeout. + String ghostCfg = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";sender_id=legacy;close_flush_timeout_millis=0;"; + try (Sender ghost = Sender.fromConfig(ghostCfg)) { + for (int i = 0; i < 3; i++) { + ghost.table("foreign").longColumn("v", i).atNow(); + ghost.flush(); + } + } catch (Exception ignored) { + // best-effort: we only need the unacked .sfa on disk + } + } + Assert.assertTrue("foreign leftover must hold unacked data", + hasSegmentFile(slot("legacy"))); + + // Phase 2: a drain_orphans=on pool with the default base. Its + // pooled senders are default-*, so "legacy" is NOT in the excluded + // namespace -- the background drainer must adopt it, replay its + // frames to the ack server, and clear the slot. + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + int ackPort = ack.getPort(); + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + ackPort + ";sf_dir=" + sfDir + + ";drain_orphans=on;"; + try (SenderPool pool = new SenderPool(cfg, 1, 2, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender s = pool.borrow(); + try { + Assert.assertTrue("foreign orphan frames must be replayed by a drainer", + awaitAtLeast(handler.frames, 1, 10_000)); + Assert.assertTrue("foreign orphan slot must be drained (no unacked .sfa left)", + awaitNoSegmentFile(slot("legacy"), 10_000)); + } finally { + s.close(); + } + } + } + }); + } + + @Test + public void testShrinkingMaxSizeDrainsStrandedOutOfRangeSlots() throws Exception { + // The bug: a deployment that previously ran at maxSize=4 leaves unacked + // data in default-0..3. Restarting at maxSize=2 means default-2 and + // default-3 are out of the new [0,2) index range forever -- the pool + // never re-creates them. Before the fix the pool also fenced off the + // WHOLE "default-" prefix from draining, so default-2/3 were neither + // re-created nor drained: their store-and-forward data was silently + // stranded even with drain_orphans=on. After the fix the exclusion is + // bounded to [0,maxSize), so the out-of-range slots are adopted by a + // background drainer and recovered. + TestUtils.assertMemoryLeak(() -> { + // Phase 1: seed unacked data into default-0..3 via a maxSize=4 pool + // against a silent (never-acks) server. close_flush_timeout_millis=0 + // so close() leaves the flushed-but-unacked .sfa on disk. + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String seedCfg = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=0;"; + try (SenderPool seed = new SenderPool(seedCfg, 4, 4, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender[] s = new PooledSender[4]; + for (int i = 0; i < 4; i++) { + s[i] = seed.borrow(); + } + for (int i = 0; i < 4; i++) { + s[i].table("recover").longColumn("v", i).atNow(); + s[i].flush(); + } + for (int i = 3; i >= 0; i--) { + s[i].close(); + } + } + } + for (int i = 0; i < 4; i++) { + Assert.assertTrue("default-" + i + " must hold unacked data", + hasSegmentFile(slot("default-" + i))); + } + + // Phase 2: restart at maxSize=2 with drain_orphans=on against an ack + // server. The pool re-creates + self-recovers default-0/1; the + // out-of-range default-2/3 must be drained, not stranded. + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + int ackPort = ack.getPort(); + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + ackPort + ";sf_dir=" + sfDir + + ";drain_orphans=on;"; + try (SenderPool pool = new SenderPool(cfg, 1, 2, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + try { + // The regression: the out-of-range slots must be adopted + // by a background drainer and emptied. + Assert.assertTrue("default-2 unacked data must be recovered, not stranded", + awaitNoSegmentFile(slot("default-2"), 15_000)); + Assert.assertTrue("default-3 unacked data must be recovered, not stranded", + awaitNoSegmentFile(slot("default-3"), 15_000)); + Assert.assertFalse("out-of-range slot must not be abandoned as .failed", + Files.exists(slot("default-2") + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + Assert.assertFalse("out-of-range slot must not be abandoned as .failed", + Files.exists(slot("default-3") + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + } finally { + b.close(); + a.close(); + } + } + } + }); + } + + @Test + public void testDefaultConfigRecoversOutOfRangeSlotsAfterShrink() throws Exception { + // Regression for review claim M1: with the out-of-the-box config + // (drain_orphans defaults to OFF), shrinking maxSize across a restart + // must STILL deliver the unacked data left in the now-out-of-range + // slots. recoverOneSlotStep() pass 2 adopts -i for + // i >= maxSize at construction, independent of drain_orphans. + TestUtils.assertMemoryLeak(() -> { + // Phase 1: seed unacked data into default-0..3 via a maxSize=4 pool + // against a silent (never-acks) server. + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String seedCfg = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=0;"; + try (SenderPool seed = new SenderPool(seedCfg, 4, 4, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender[] s = new PooledSender[4]; + for (int i = 0; i < 4; i++) { + s[i] = seed.borrow(); + } + for (int i = 0; i < 4; i++) { + s[i].table("recover").longColumn("v", i).atNow(); + s[i].flush(); + } + for (int i = 3; i >= 0; i--) { + s[i].close(); + } + } + } + for (int i = 0; i < 4; i++) { + Assert.assertTrue("default-" + i + " must hold unacked data", + hasSegmentFile(slot("default-" + i))); + } + + // Phase 2: restart at maxSize=2 with the DEFAULT config (NO + // drain_orphans) against a healthy ack server. minSize=0 so startup + // recovery -- not prewarm "normal use" -- is the recovery path for + // the in-range slots, and pass 2 is the only path for default-2/3. + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + int ackPort = ack.getPort(); + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + ackPort + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(cfg, 0, 2, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + // In-range [0,2): startup recovery pass 1 drains these. + Assert.assertTrue("in-range default-0 must be recovered at startup", + awaitNoSegmentFile(slot("default-0"), 15_000)); + Assert.assertTrue("in-range default-1 must be recovered at startup", + awaitNoSegmentFile(slot("default-1"), 15_000)); + + // Out-of-range [2,4): pass 2 must deliver these too, under + // the default config (no drain_orphans). + Assert.assertTrue("default-2 unacked data must be recovered, not stranded", + awaitNoSegmentFile(slot("default-2"), 15_000)); + Assert.assertTrue("default-3 unacked data must be recovered, not stranded", + awaitNoSegmentFile(slot("default-3"), 15_000)); + Assert.assertFalse("out-of-range slot must not be abandoned as .failed", + Files.exists(slot("default-2") + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + Assert.assertFalse("out-of-range slot must not be abandoned as .failed", + Files.exists(slot("default-3") + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + + // Sanity: the pool is still usable for normal borrows. + PooledSender a = pool.borrow(); + a.close(); + } + } + }); + } + + @Test + public void testInRangeIdleSlotIsRecoveredAtStartupUnderSteadyLowLoad() throws Exception { + // The drain exclusion is bounded to [0, maxSize) so a sibling's drainer + // never adopts a slot dir the pool intends to (re)create -- that is what + // prevents "sf slot already in use" (see + // testDrainOrphansPoolDoesNotCannibalizeSiblingSlots). The trade-off was + // that an in-range slot left holding unacked data by a previous run was + // recovered ONLY when the pool happened to (re)create that index: the + // pool pre-warms [0, minSize) and builds [minSize, maxSize) lazily on + // demand, so under steady low load a high in-range index was never + // rebuilt -- neither drained (excluded) nor recovered -- and its data + // was stranded on disk until a restart or load spike. + // + // The fix has the pool recover its own stranded managed slots once, at + // construction, under its own slot reservation (so the cannibalization + // race the exclusion guards against still cannot happen). This test + // seeds a busy run, then restarts under steady low load and asserts the + // idle in-range slots are recovered anyway. + TestUtils.assertMemoryLeak(() -> { + // Phase 1: a busy run at maxSize=4 seeds unacked data into + // default-0..3 (silent server never acks; close_flush_timeout=0 so + // close() leaves the flushed-but-unacked .sfa on disk). + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String seedCfg = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=0;"; + try (SenderPool seed = new SenderPool(seedCfg, 4, 4, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender[] s = new PooledSender[4]; + for (int i = 0; i < 4; i++) { + s[i] = seed.borrow(); + } + for (int i = 0; i < 4; i++) { + s[i].table("recover").longColumn("v", i).atNow(); + s[i].flush(); + } + for (int i = 3; i >= 0; i--) { + s[i].close(); + } + } + } + for (int i = 0; i < 4; i++) { + Assert.assertTrue("default-" + i + " must hold unacked data", + hasSegmentFile(slot("default-" + i))); + } + + // Phase 2: restart at the SAME maxSize=4 (so default-0..3 stay in + // range) with steady low load. minSize=0 means prewarm builds + // nothing and the lowest-free allocator would never reach the high + // indices under a single in-flight borrow -- yet startup recovery + // must still empty every in-range slot. + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + int ackPort = ack.getPort(); + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + ackPort + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(cfg, 0, 4, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + // All four in-range slots must be recovered by the startup + // pass, even though steady low load never grows the pool to + // their indices. + for (int i = 0; i < 4; i++) { + Assert.assertTrue("in-range idle default-" + i + + " must be recovered at startup, not stranded", + awaitNoSegmentFile(slot("default-" + i), 15_000)); + Assert.assertFalse("recovered slot must not be flagged .failed", + Files.exists(slot("default-" + i) + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + } + // The recovered frames must have actually reached the server. + Assert.assertTrue("recovered frames must be replayed to the server", + awaitAtLeast(handler.frames, 4, 15_000)); + + // Sanity: the pool is still usable for normal borrows. + PooledSender a = pool.borrow(); + a.close(); + } + } + }); + } + + @Test + public void testStartupRecoveryIsBoundedByASharedBudget() throws Exception { + // Regression for the startup-recovery budget (M1). + // recoverOneSlotStep() runs synchronously in the SenderPool + // constructor. A previous run can strand unacked data in EVERY in-range + // slot, and if the server is reachable but does not ack, each slot's + // drain blocks for the full acquireTimeoutMillis. Without a shared, + // whole-scan budget (and a short-circuit on the first drain that fails + // to ack) construction blocks for (maxSize - minSize) * + // acquireTimeoutMillis -- here 4 * 1s = 4s -- so QuestDB.build() stalls + // proportionally to the recovery backlog. The fix caps the TOTAL + // recovery at ~one acquireTimeoutMillis and stops scanning the moment a + // drain fails to ack, so construction must return well inside that + // ceiling no matter how many slots are stranded. + final long acquireTimeoutMillis = 1_000L; + final int maxSize = 4; + + TestUtils.assertMemoryLeak(() -> { + // Phase 1: seed unacked data into default-0..3 against a silent + // server (never acks; close_flush_timeout=0 leaves the + // flushed-but-unacked .sfa on disk). + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String seedCfg = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=0;"; + try (SenderPool seed = new SenderPool(seedCfg, maxSize, maxSize, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender[] s = new PooledSender[maxSize]; + for (int i = 0; i < maxSize; i++) { + s[i] = seed.borrow(); + } + for (int i = 0; i < maxSize; i++) { + s[i].table("recover").longColumn("v", i).atNow(); + s[i].flush(); + } + for (int i = maxSize - 1; i >= 0; i--) { + s[i].close(); + } + } + } + for (int i = 0; i < maxSize; i++) { + Assert.assertTrue("default-" + i + " must hold unacked data", + hasSegmentFile(slot("default-" + i))); + } + + // Phase 2: restart against a STILL-silent (reachable but + // never-acking) server. Construction triggers startup recovery over + // all four stranded slots. close_flush_timeout=0 makes each + // recoverer's close() a fast close, so the measured window isolates + // the drain budget: pre-fix it is maxSize * acquireTimeoutMillis; + // post-fix it is bounded by ~one acquireTimeoutMillis. + try (TestWebSocketServer silent2 = new TestWebSocketServer(new SilentHandler())) { + int port = silent2.getPort(); + silent2.start(); + Assert.assertTrue(silent2.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=0;"; + + long startNanos = System.nanoTime(); + SenderPool pool = new SenderPool(cfg, 0, maxSize, acquireTimeoutMillis, Long.MAX_VALUE, Long.MAX_VALUE); + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + try { + // Headline guarantee: total recovery is bounded by the + // shared budget, NOT maxSize * acquireTimeoutMillis. The 3x + // ceiling leaves generous CI margin over the ~1x post-fix + // cost while still decisively failing the pre-fix 4x stall. + Assert.assertTrue( + "startup recovery must be bounded by a shared budget, not per-slot: took " + + elapsedMillis + "ms with acquireTimeout=" + acquireTimeoutMillis + + "ms over " + maxSize + " stranded slots (pre-fix ~" + + (maxSize * acquireTimeoutMillis) + "ms)", + elapsedMillis < 3 * acquireTimeoutMillis); + + // Durability, not loss: the silent server never acked, so + // the stranded data is deferred (still on disk for a later + // attempt), never dropped. + for (int i = 0; i < maxSize; i++) { + Assert.assertTrue( + "stranded data must be preserved on disk, not lost: default-" + i, + hasSegmentFile(slot("default-" + i))); + } + } finally { + pool.close(); + } + } + }); + } + + @Test + public void testRecoveryStepStaysBoundedWithDrainOrphansAgainstNonAckingServer() throws Exception { + // M-A regression: a startup-recovery delegate must NOT inherit + // drain_orphans=on. If it did, building the recoverer (which connects + // OK against a reachable server because initial_connect_mode is forced + // OFF) would run an orphan scan and dispatch a BackgroundDrainerPool at + // any foreign/out-of-range orphan. Against a reachable-but-not-acking + // server those background drainers never reach their target, so the + // recoverer's close() -- called inside the recovery step, on the + // housekeeper thread, BEFORE cursorEngine.close() releases the slot + // flock -- blocks in BackgroundDrainerPool.close() for ~3s + // (GRACEFUL_DRAIN_MILLIS + STOP_GRACE_MILLIS). That makes one step + // ~1s drain + ~3s drainerPool.close ~= 4s, far past + // RECOVERY_DRAIN_BUDGET_MILLIS and PoolHousekeeper.STOP_TIMEOUT_MILLIS + // (2s) -- and a close() landing mid-step would return with the flock + // still held. After the fix recovery delegates force drain_orphans=off, + // so no drainer pool is created and the step stays bounded by the drain + // budget alone. + final long acquireTimeoutMillis = 1_000L; + final int maxSize = 2; + + TestUtils.assertMemoryLeak(() -> { + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String addr = "localhost:" + silentPort; + + // Phase 1a: strand unacked data in an in-range managed slot + // (default-0) so the recovery step has a slot to process. + String seedCfg = "ws::addr=" + addr + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=0;"; + try (SenderPool seed = new SenderPool(seedCfg, 1, maxSize, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender s = seed.borrow(); + s.table("recover").longColumn("v", 1L).atNow(); + s.flush(); + s.close(); + } + Assert.assertTrue("default-0 must hold unacked data", hasSegmentFile(slot("default-0"))); + + // Phase 1b: strand a FOREIGN orphan (different base) so a + // recovery delegate that inherited drain_orphans=on would + // dispatch a background drainer at it. + String ghostCfg = "ws::addr=" + addr + ";sf_dir=" + sfDir + + ";sender_id=legacy;close_flush_timeout_millis=0;"; + try (Sender ghost = Sender.fromConfig(ghostCfg)) { + for (int i = 0; i < 3; i++) { + ghost.table("foreign").longColumn("v", i).atNow(); + ghost.flush(); + } + } catch (Exception ignored) { + // best-effort: we only need the unacked .sfa on disk + } + Assert.assertTrue("foreign leftover must hold unacked data", hasSegmentFile(slot("legacy"))); + + // Phase 2: a deferred drain_orphans=on pool against the SAME + // reachable-but-not-acking server. Drive ONE recovery step and + // time it: the step builds a recovery delegate on default-0, + // which pre-fix would dispatch a drainer at the foreign orphan + // and then block ~3s in drainerPool.close(). + String cfg = "ws::addr=" + addr + ";sf_dir=" + sfDir + + ";drain_orphans=on;close_flush_timeout_millis=0;"; + SenderPool pool = newDeferredPool(cfg, 0, maxSize, acquireTimeoutMillis); + try { + long startNanos = System.nanoTime(); + invokeRunStartupRecoveryStep(pool); + long stepMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + + // Headline guarantee: a recovery delegate must not stand up a + // BackgroundDrainerPool, so the step is bounded by the drain + // budget (~1s) -- comfortably under STOP_TIMEOUT_MILLIS. The + // 2.5s ceiling clears the ~1s post-fix cost with CI margin + // while decisively failing the pre-fix ~4s overrun. + Assert.assertTrue( + "recovery step must stay bounded with drain_orphans=on against a " + + "non-acking server (no BackgroundDrainerPool overrun): took " + + stepMillis + "ms", + stepMillis < 2_500L); + + // Durability, not loss: the foreign orphan stays on disk for + // a later live-sender drainer; the recovery delegate must + // not have abandoned it as .failed either. + Assert.assertTrue("foreign orphan data must be preserved on disk, not lost", + hasSegmentFile(slot("legacy"))); + Assert.assertFalse("foreign orphan must not be flagged .failed by a recovery delegate", + Files.exists(slot("legacy") + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + } finally { + pool.close(); + } + } + }); + } + + @Test + public void testDeferredStartupRecoveryDoesNotBlockConstruction() throws Exception { + // M2 fix: when recovery is deferred (the pooled QuestDB handle's path), + // constructing the SenderPool must NOT run startup recovery inline, so + // build() never blocks on a reachable-but-not-acking server -- not even + // when every in-range slot holds stranded data. Recovery is driven later, + // off the build() thread (by the housekeeper in production; explicitly + // here). + final long acquireTimeoutMillis = 1_000L; + final int maxSize = 4; + + TestUtils.assertMemoryLeak(() -> { + // Phase 1: seed unacked data into default-0..3 against a silent + // server (never acks; close_flush_timeout=0 leaves the .sfa on disk). + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String seedCfg = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=0;"; + try (SenderPool seed = new SenderPool(seedCfg, maxSize, maxSize, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender[] s = new PooledSender[maxSize]; + for (int i = 0; i < maxSize; i++) { + s[i] = seed.borrow(); + } + for (int i = 0; i < maxSize; i++) { + s[i].table("recover").longColumn("v", i).atNow(); + s[i].flush(); + } + for (int i = maxSize - 1; i >= 0; i--) { + s[i].close(); + } + } + } + for (int i = 0; i < maxSize; i++) { + Assert.assertTrue("default-" + i + " must hold unacked data", + hasSegmentFile(slot("default-" + i))); + } + + // Phase 2: construct a DEFERRED pool against a STILL-silent (reachable + // but never-acking) server. Pre-fix, inline recovery would block the + // constructor for ~one acquireTimeoutMillis here; deferred, it returns + // effectively immediately because recovery has not run yet. + try (TestWebSocketServer silent2 = new TestWebSocketServer(new SilentHandler())) { + int port = silent2.getPort(); + silent2.start(); + Assert.assertTrue(silent2.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=0;"; + + long startNanos = System.nanoTime(); + SenderPool pool = newDeferredPool(cfg, 0, maxSize, acquireTimeoutMillis); + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + try { + // Headline: deferred construction does not pay the recovery + // drain budget. A whole acquireTimeout of margin is plenty -- + // pre-fix this would have been ~acquireTimeoutMillis. + Assert.assertTrue( + "deferred construction must not block on recovery: took " + elapsedMillis + + "ms with acquireTimeout=" + acquireTimeoutMillis + "ms", + elapsedMillis < acquireTimeoutMillis); + + // Recovery has not been driven yet, so every stranded slot is + // still on disk -- proving construction skipped inline recovery. + for (int i = 0; i < maxSize; i++) { + Assert.assertTrue( + "deferred construction must NOT recover inline: default-" + i, + hasSegmentFile(slot("default-" + i))); + } + + // Driving recovery against the still-silent server is bounded + // by the shared budget and preserves the data (durable, not + // lost) for a later attempt -- exercising the deferred path's + // concurrency-safe slot reservation too. + long recoverStart = System.nanoTime(); + invokeRunStartupRecoveryOnce(pool); + long recoverMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - recoverStart); + Assert.assertTrue( + "driven recovery must be bounded by the shared budget: took " + recoverMillis + + "ms (acquireTimeout=" + acquireTimeoutMillis + "ms)", + recoverMillis < 3 * acquireTimeoutMillis); + for (int i = 0; i < maxSize; i++) { + Assert.assertTrue( + "stranded data must be preserved on disk, not lost: default-" + i, + hasSegmentFile(slot("default-" + i))); + } + } finally { + pool.close(); + } + } + }); + } + + @Test + public void testDeferredStartupRecoveryDeliversWhenDriven() throws Exception { + // Deferring recovery off the constructor must not lose it: once driven + // (by the housekeeper in production; explicitly here) against an acking + // server, a deferred pool recovers its stranded managed slots exactly as + // the inline path would. The drive is also idempotent. + TestUtils.assertMemoryLeak(() -> { + // Phase 1: seed unacked data into default-0 (silent server). + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String seedCfg = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=0;"; + try (SenderPool seed = new SenderPool(seedCfg, 1, 1, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender s = seed.borrow(); + s.table("recover").longColumn("v", 1).atNow(); + s.flush(); + s.close(); + } + } + Assert.assertTrue("default-0 must hold unacked data", hasSegmentFile(slot("default-0"))); + + // Phase 2: deferred pool against an ACKING server. + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer ack = new TestWebSocketServer(handler)) { + int ackPort = ack.getPort(); + ack.start(); + Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS)); + String cfg = "ws::addr=localhost:" + ackPort + ";sf_dir=" + sfDir + ";"; + SenderPool pool = newDeferredPool(cfg, 0, 2, 5_000); + try { + // Deferred: not recovered until driven. + Assert.assertTrue("deferred construction must NOT recover inline: default-0", + hasSegmentFile(slot("default-0"))); + + // Drive it (what the housekeeper does on its first tick). + invokeRunStartupRecoveryOnce(pool); + Assert.assertTrue("driven recovery must empty default-0", + awaitNoSegmentFile(slot("default-0"), 15_000)); + Assert.assertTrue("recovered frames must reach the server", + awaitAtLeast(handler.frames, 1, 15_000)); + Assert.assertFalse("recovered slot must not be flagged .failed", + Files.exists(slot("default-0") + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + + // Idempotent: a second drive is a no-op and must not throw. + invokeRunStartupRecoveryOnce(pool); + Assert.assertFalse("default-0 stays recovered", hasSegmentFile(slot("default-0"))); + + // Pool still usable for normal borrows. + PooledSender a = pool.borrow(); + a.close(); + } finally { + pool.close(); + } + } + }); + } + + @Test(timeout = 60_000) + public void testCloseDuringDeferredRecoveryStopsBuildingOnClosingPool() throws Exception { + // C1 regression. The housekeeper drives startup recovery one slot per + // step on its own thread. QuestDBImpl.close() raises the pool's shutdown + // signal (markClosing) BEFORE stopping the housekeeper, and every step + // re-checks it, so a close() landing while a recoverer is mid-drain must + // stop recovery from building the NEXT slot -- no more "keeps building + // senders on a logically-closed pool". A fake recoverer parks slot-0's + // drain so we can raise the signal deterministically inside the window. + TestUtils.assertMemoryLeak(() -> { + // Seed REAL unacked data into default-0 and default-1 (two candidates). + try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) { + int silentPort = silent.getPort(); + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String seedCfg = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir + + ";close_flush_timeout_millis=0;"; + try (SenderPool seed = new SenderPool(seedCfg, 2, 2, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender a = seed.borrow(); + PooledSender b = seed.borrow(); + a.table("recover").longColumn("v", 0).atNow(); + a.flush(); + b.table("recover").longColumn("v", 1).atNow(); + b.flush(); + b.close(); + a.close(); + } + } + Assert.assertTrue(hasSegmentFile(slot("default-0"))); + Assert.assertTrue(hasSegmentFile(slot("default-1"))); + + final CountDownLatch slot0DrainStarted = new CountDownLatch(1); + final CountDownLatch releaseSlot0Drain = new CountDownLatch(1); + final java.util.List builtSlots = + java.util.Collections.synchronizedList(new java.util.ArrayList<>()); + IntFunction factory = idx -> { + builtSlots.add(idx); + return blockingFakeSender(idx, slot0DrainStarted, releaseSlot0Drain); + }; + + // minSize=0 -> recovery is the only factory caller. Generous + // acquireTimeout so the ONLY reason slot 1 could be skipped is the + // shutdown signal being honoured. + final SenderPool pool = newDeferredPoolWithFactory( + "ws::addr=localhost:1;sf_dir=" + sfDir + ";", 0, 2, 30_000, factory); + // Mimic the housekeeper: drive steps back-to-back until done/closing. + Thread recovery = new Thread(() -> { + try { + while (invokeRunStartupRecoveryStep(pool)) { + // keep stepping + } + } catch (Exception ignored) { + } + }, "test-recovery"); + recovery.start(); + + Assert.assertTrue("slot-0 recoverer must reach drain()", + slot0DrainStarted.await(10, TimeUnit.SECONDS)); + + // Raise the shutdown signal mid-drain, exactly as QuestDBImpl.close() + // does before stopping the housekeeper. + invokeMarkClosing(pool); + releaseSlot0Drain.countDown(); + recovery.join(TimeUnit.SECONDS.toMillis(10)); + Assert.assertFalse("recovery thread must finish", recovery.isAlive()); + + Assert.assertTrue("sanity: the in-flight slot-0 recoverer was built", + builtSlots.contains(0)); + Assert.assertFalse( + "recovery built a recoverer for slot 1 after the pool was signalled " + + "closing; builtSlots=" + builtSlots, + builtSlots.contains(1)); + + pool.close(); + }); + } + + // ---------------------------------------------------------------------- + // Helpers. + // ---------------------------------------------------------------------- + + private String slot(String name) { + return sfDir + "/" + name; + } + + private int countSlotDirs() { + if (!Files.exists(sfDir)) { + return 0; + } + int count = 0; + long find = Files.findFirst(sfDir); + if (find <= 0) { + return 0; + } + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + rc = Files.findNext(find); + if (name == null || ".".equals(name) || "..".equals(name)) { + continue; + } + // Slot dirs are the only children the pool creates under sfDir. + if (Files.exists(sfDir + "/" + name + "/.lock")) { + count++; + } + } + } finally { + Files.findClose(find); + } + return count; + } + + private static boolean hasSegmentFile(String slotPath) { + if (!Files.exists(slotPath)) { + return false; + } + long find = Files.findFirst(slotPath); + if (find <= 0) { + return false; + } + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + rc = Files.findNext(find); + if (name != null && name.endsWith(".sfa")) { + return true; + } + } + } finally { + Files.findClose(find); + } + return false; + } + + private static boolean awaitAtLeast(AtomicInteger counter, int target, long timeoutMillis) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < deadline) { + if (counter.get() >= target) { + return true; + } + Thread.sleep(10); + } + return counter.get() >= target; + } + + private static boolean awaitNoSegmentFile(String slotPath, long timeoutMillis) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < deadline) { + if (!hasSegmentFile(slotPath)) { + return true; + } + Thread.sleep(10); + } + return !hasSegmentFile(slotPath); + } + + private static void rmDir(String dir) { + if (dir == null || !Files.exists(dir)) { + return; + } + long find = Files.findFirst(dir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + String child = dir + "/" + name; + if (!Files.remove(child)) { + rmDir(child); + } + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Files.remove(dir); + } + + private static Sender getDelegate(PooledSender ps) throws Exception { + Field f = PooledSender.class.getDeclaredField("delegate"); + f.setAccessible(true); + return (Sender) f.get(ps); + } + + // Invokes one of the pool's private managed-slot delegate factories + // (defaultSender / defaultRecoverySender) so a test can inspect the raw + // delegate it would build for a given slot index. + private static Sender invokeBuildSlotDelegate(SenderPool pool, String methodName, int slotIndex) + throws Exception { + Method m = SenderPool.class.getDeclaredMethod(methodName, int.class); + m.setAccessible(true); + return (Sender) m.invoke(pool, slotIndex); + } + + // Reads the resolved initial-connect mode a built QwpWebSocketSender delegate + // is using (the value after the builder's SYNC auto-promotion / explicit + // override has been applied). + private static Sender.InitialConnectMode readInitialConnectMode(Sender delegate) throws Exception { + Field f = delegate.getClass().getDeclaredField("initialConnectMode"); + f.setAccessible(true); + return (Sender.InitialConnectMode) f.get(delegate); + } + + private static void setBooleanField(Object target, String name, boolean value) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.setBoolean(target, value); + } + + private static int getIntField(Object target, String name) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + return f.getInt(target); + } + + private static Object getField(Object target, String name) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } + + private static void invokeDiscardBroken(SenderPool pool, PooledSender ps) throws Exception { + Method m = SenderPool.class.getDeclaredMethod("discardBroken", PooledSender.class); + m.setAccessible(true); + m.invoke(pool, ps); + } + + // Reaches the package-private senderFactory test seam by reflection so a + // test can inject a fake/forged delegate (mirrors SenderPoolErrorSafetyTest). + private static SenderPool newPoolWithFactory( + String cfg, int min, int max, long acquireMs, IntFunction senderFactory + ) throws Exception { + Constructor c = SenderPool.class.getDeclaredConstructor( + String.class, int.class, int.class, long.class, long.class, long.class, IntFunction.class); + c.setAccessible(true); + return c.newInstance(cfg, min, max, acquireMs, Long.MAX_VALUE, Long.MAX_VALUE, senderFactory); + } + + // Reaches the package-private 8-arg constructor (deferStartupRecovery=true) + // by reflection so a test can build a pool whose SF startup recovery is NOT + // run inline -- mirroring the pooled QuestDB handle, which defers it to the + // housekeeper. senderFactory=null -> the real defaultSender(). + private static SenderPool newDeferredPool(String cfg, int min, int max, long acquireMs) throws Exception { + Constructor c = SenderPool.class.getDeclaredConstructor( + String.class, int.class, int.class, long.class, long.class, long.class, + IntFunction.class, boolean.class); + c.setAccessible(true); + return c.newInstance(cfg, min, max, acquireMs, Long.MAX_VALUE, Long.MAX_VALUE, null, true); + } + + // Drives a deferred pool's startup recovery to completion (the housekeeper + // drives it one slot per tick; tests drive the whole backlog in one call). + private static void invokeRunStartupRecoveryOnce(SenderPool pool) throws Exception { + Method m = SenderPool.class.getDeclaredMethod("runStartupRecoveryToCompletion"); + m.setAccessible(true); + m.invoke(pool); + } + + // Drives a SINGLE recovery step (the housekeeper's per-tick unit); returns + // whether more stranded slots remain. + private static boolean invokeRunStartupRecoveryStep(SenderPool pool) throws Exception { + Method m = SenderPool.class.getDeclaredMethod("runStartupRecoveryStep"); + m.setAccessible(true); + return (Boolean) m.invoke(pool); + } + + // Raises the pool's shutdown signal early, exactly as QuestDBImpl.close() + // does before stopping the housekeeper. + private static void invokeMarkClosing(SenderPool pool) throws Exception { + Method m = SenderPool.class.getDeclaredMethod("markClosing"); + m.setAccessible(true); + m.invoke(pool); + } + + // Deferred pool (deferStartupRecovery=true) WITH an injected factory, so a + // test can drive the housekeeper recovery path against fully controlled + // (fake) recoverers. + private static SenderPool newDeferredPoolWithFactory( + String cfg, int min, int max, long acquireMs, IntFunction factory) throws Exception { + Constructor c = SenderPool.class.getDeclaredConstructor( + String.class, int.class, int.class, long.class, long.class, long.class, + IntFunction.class, boolean.class); + c.setAccessible(true); + return c.newInstance(cfg, min, max, acquireMs, Long.MAX_VALUE, Long.MAX_VALUE, factory, true); + } + + // Fake Sender whose drain() (for slot 0 only) parks until released, opening a + // deterministic shutdown-during-recovery window. Holds no native resources. + private static Sender blockingFakeSender(int idx, CountDownLatch drainStarted, CountDownLatch release) { + return (Sender) java.lang.reflect.Proxy.newProxyInstance( + Sender.class.getClassLoader(), + new Class[]{Sender.class}, + (proxy, method, args) -> { + switch (method.getName()) { + case "drain": + if (idx == 0) { + drainStarted.countDown(); + release.await(); + } + return true; + case "close": + return null; + case "toString": + return "BlockingFakeSender-" + idx; + case "hashCode": + return System.identityHashCode(proxy); + case "equals": + return proxy == args[0]; + default: + Class rt = method.getReturnType(); + if (rt == boolean.class) return false; + if (rt == int.class) return 0; + if (rt == long.class) return 0L; + if (rt.isInstance(proxy)) return proxy; + return null; + } + }); + } + + /** + * Acks every binary frame with a per-connection running sequence (each + * pooled sender is its own WebSocket connection, so each needs its own + * FSN counter), and counts total frames received across all connections. + */ + private static final class CountingAckHandler implements TestWebSocketServer.WebSocketServerHandler { + final AtomicInteger frames = new AtomicInteger(); + private final Map seqByClient = + new ConcurrentHashMap<>(); + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + frames.incrementAndGet(); + AtomicLong seq = seqByClient.computeIfAbsent(client, c -> new AtomicLong(0)); + try { + client.sendBinary(buildAck(seq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + static byte[] buildAck(long seq) { + byte[] buf = new byte[1 + 8 + 2]; + ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN); + bb.put((byte) 0x00); // STATUS_OK + bb.putLong(seq); + bb.putShort((short) 0); + return buf; + } + } + + private static final class SilentHandler implements TestWebSocketServer.WebSocketServerHandler { + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + // No ack -- frames stay unacked on disk for the recovery test. + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolTest.java index 7c58943a..85952f85 100644 --- a/core/src/test/java/io/questdb/client/test/impl/SenderPoolTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolTest.java @@ -26,12 +26,17 @@ import io.questdb.client.Sender; import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.impl.PooledSender; import io.questdb.client.impl.SenderPool; +import io.questdb.client.test.tools.TestUtils; import org.junit.Assert; import org.junit.Test; +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; /** @@ -487,4 +492,115 @@ public void testThreadAffinityIsPerThread() throws InterruptedException { pool.releaseCurrentThread(); } } + + // ---------------------------------------------------------------------- + // Teardown robustness: a delegate close() can throw an Error (e.g. an + // -ea AssertionError), not just a RuntimeException. The pool's best-effort + // teardown paths used to catch only RuntimeException, so such an Error + // escaped: reapIdle() aborted mid-loop (stranding siblings) and the cap + // accounting was skipped, permanently understating capacity. These tests + // pin the fix: an Error during delegate close() must not abort the loop, + // must not propagate, and must leave the pool fully usable. + // ---------------------------------------------------------------------- + + @Test + public void testReapIdleSurvivesDelegateCloseError() throws Exception { + TestUtils.assertMemoryLeak(() -> { + // idleTimeout=1ms so every idle slot is reap-eligible after a short sleep. + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 0, 3, 1_000, 1, Long.MAX_VALUE)) { + AtomicInteger closeAttempts = new AtomicInteger(); + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + PooledSender c = pool.borrow(); + installFailingCloseDelegate(a, closeAttempts); + installFailingCloseDelegate(b, closeAttempts); + installFailingCloseDelegate(c, closeAttempts); + pool.giveBack(a); + pool.giveBack(b); + pool.giveBack(c); + Assert.assertEquals(3, pool.totalSize()); + + Thread.sleep(10); + // Must NOT throw even though every delegate.close() raises an Error. + pool.reapIdle(); + + // The loop attempted to close ALL three delegates -- it did not abort + // after the first Error. + Assert.assertEquals("reap loop must not abort on a delegate close() Error", + 3, closeAttempts.get()); + // Every reaped slot left `all`; capacity was not stranded. + Assert.assertEquals(0, pool.totalSize()); + Assert.assertEquals(0, pool.availableSize()); + + // Pool still grows back to full capacity -- no permanent leak. + PooledSender d = pool.borrow(); + PooledSender e = pool.borrow(); + PooledSender f = pool.borrow(); + Assert.assertEquals(3, pool.totalSize()); + pool.giveBack(d); + pool.giveBack(e); + pool.giveBack(f); + } + }); + } + + @Test + public void testCloseSurvivesDelegateCloseError() throws Exception { + TestUtils.assertMemoryLeak(() -> { + SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 2, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE); + AtomicInteger closeAttempts = new AtomicInteger(); + PooledSender a = pool.borrow(); + PooledSender b = pool.borrow(); + installFailingCloseDelegate(a, closeAttempts); + installFailingCloseDelegate(b, closeAttempts); + pool.giveBack(a); + pool.giveBack(b); + + // close() must not propagate an Error and must attempt every delegate. + pool.close(); + Assert.assertEquals("close() must attempt to close every delegate despite an Error", + 2, closeAttempts.get()); + // Idempotent second close stays clean. + pool.close(); + }); + } + + /** + * Reflectively replaces the wrapper's delegate with a {@link Proxy} that + * throws an {@link AssertionError} (an {@link Error}, not a + * {@link RuntimeException}) on {@code close()} and forwards every other + * call to the real delegate. Models an -ea assertion firing during native + * teardown. + *

+ * The proxy still forwards {@code close()} to the real delegate (freeing + * its native {@code HttpClient} buffers) before throwing, so the + * pool sees the injected {@link Error} exactly as it would in production + * while the test does not leak native memory. + */ + private static void installFailingCloseDelegate(PooledSender ps, AtomicInteger closeAttempts) throws Exception { + Field f = PooledSender.class.getDeclaredField("delegate"); + f.setAccessible(true); + Sender real = (Sender) f.get(ps); + Sender failing = (Sender) Proxy.newProxyInstance( + Sender.class.getClassLoader(), + new Class[]{Sender.class}, + (proxy, method, args) -> { + if ("close".equals(method.getName()) && (args == null || args.length == 0)) { + closeAttempts.incrementAndGet(); + // Free the real delegate's native resources first, then + // surface the injected Error -- the pool's teardown loop + // still observes it, but no native memory is leaked. + try { + real.close(); + } catch (Throwable ignore) { + // real.close() on an empty HTTP buffer is a no-op; + // swallow anything so the injected Error is what the + // pool sees. + } + throw new AssertionError("injected delegate close() Error"); + } + return method.invoke(real, args); + }); + f.set(ps, failing); + } } diff --git a/core/src/test/java/io/questdb/client/test/network/JavaTlsClientSocketHandshakeOverflowTest.java b/core/src/test/java/io/questdb/client/test/network/JavaTlsClientSocketHandshakeOverflowTest.java index 6d76ab49..25b138bd 100644 --- a/core/src/test/java/io/questdb/client/test/network/JavaTlsClientSocketHandshakeOverflowTest.java +++ b/core/src/test/java/io/questdb/client/test/network/JavaTlsClientSocketHandshakeOverflowTest.java @@ -124,7 +124,7 @@ private static JavaTlsClientSocket newSocket() throws Exception { public static final class HandshakeOverflowProvider extends Provider { public HandshakeOverflowProvider() { - super(PROVIDER_NAME, "1.0", "test-only"); + super(PROVIDER_NAME, 1.0, "test-only"); put("SSLContext.TLS", HandshakeOverflowSslContextSpi.class.getName()); } } diff --git a/core/src/test/java/io/questdb/client/test/network/JavaTlsClientSocketTest.java b/core/src/test/java/io/questdb/client/test/network/JavaTlsClientSocketTest.java index 7b2736e3..506ce783 100644 --- a/core/src/test/java/io/questdb/client/test/network/JavaTlsClientSocketTest.java +++ b/core/src/test/java/io/questdb/client/test/network/JavaTlsClientSocketTest.java @@ -204,7 +204,7 @@ private static void invoke(Object obj, String methodName) throws Exception { } private static JavaTlsClientSocket newSocket() throws Exception { - var constructor = JavaTlsClientSocket.class.getDeclaredConstructor( + java.lang.reflect.Constructor constructor = JavaTlsClientSocket.class.getDeclaredConstructor( NetworkFacade.class, org.slf4j.Logger.class, ClientTlsConfiguration.class diff --git a/core/src/test/java/io/questdb/client/test/std/Decimal256Test.java b/core/src/test/java/io/questdb/client/test/std/Decimal256Test.java index f86f1f17..1c461525 100644 --- a/core/src/test/java/io/questdb/client/test/std/Decimal256Test.java +++ b/core/src/test/java/io/questdb/client/test/std/Decimal256Test.java @@ -753,7 +753,7 @@ public void testFitsInStorageSizeCombinatorics() { {Long.MIN_VALUE, Long.MIN_VALUE, Long.MIN_VALUE, Long.MIN_VALUE, false, false, false, false, false, true}, }; Decimal256 d = new Decimal256(); - for (var combination : combinations) { + for (Object[] combination : combinations) { d.of((long) combination[0], (long) combination[1], (long) combination[2], (long) combination[3], 0); Assert.assertEquals("Expected " + d + " to fit in a byte", combination[4], d.fitsInStorageSizePow2(0)); Assert.assertEquals("Expected " + d + " to fit in a short", combination[5], d.fitsInStorageSizePow2(1)); diff --git a/core/src/test/java/io/questdb/client/test/tools/TestUtils.java b/core/src/test/java/io/questdb/client/test/tools/TestUtils.java index ffb1366e..60cbc9ef 100644 --- a/core/src/test/java/io/questdb/client/test/tools/TestUtils.java +++ b/core/src/test/java/io/questdb/client/test/tools/TestUtils.java @@ -172,10 +172,21 @@ public static String ipv4ToString(int ip) { return ((ip >> 24) & 0xff) + "." + ((ip >> 16) & 0xff) + "." + ((ip >> 8) & 0xff) + "." + (ip & 0xff); } + /** + * Java 8 stand-in for {@code String.repeat(int)} (added in Java 11). + */ + public static String repeat(CharSequence s, int count) { + StringBuilder sb = new StringBuilder(s.length() * Math.max(0, count)); + for (int i = 0; i < count; i++) { + sb.append(s); + } + return sb.toString(); + } + // Useful for debugging @SuppressWarnings("unused") public static String reverseBeHex(String hex) { - var sb = new char[hex.length()]; + char[] sb = new char[hex.length()]; for (int i = 0; i < hex.length(); i += 2) { sb[hex.length() - i - 1] = hex.charAt(i + 1); sb[hex.length() - i - 2] = hex.charAt(i); diff --git a/core/src/test/java/module-info.java b/core/src/test/java/module-info.java index e9997b3d..e42b73ff 100644 --- a/core/src/test/java/module-info.java +++ b/core/src/test/java/module-info.java @@ -34,6 +34,7 @@ requires org.postgresql.jdbc; requires jmh.core; requires ch.qos.logback.classic; + requires ch.qos.logback.core; exports io.questdb.client.test; exports io.questdb.client.test.cairo;