Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c772c91
fix(pool): give pooled store-and-forward senders distinct slot ids
bluestreak01 Jun 17, 2026
49b598a
Harden pool teardown against Errors + exclude pool slot namespace fro…
bluestreak01 Jun 18, 2026
2311b98
Fix SF pool freeing a slot index while its flock is still held
bluestreak01 Jun 18, 2026
5422b67
Merge remote-tracking branch 'origin/main' into fix/pool-sf-slot-coll…
bluestreak01 Jun 18, 2026
90ecef2
test(pool): migrate SenderPoolSfTest to ephemeral-port TestWebSocketS…
bluestreak01 Jun 18, 2026
b1b8dfa
Make leaked SF slots observable + harden pool construction
bluestreak01 Jun 18, 2026
9f00b31
fix(pool): catch Throwable on all pool creation paths, not just Runti…
bluestreak01 Jun 18, 2026
3024b5e
fix(pool): bound orphan-drain exclusion to [0,maxSize) so shrinking m…
bluestreak01 Jun 18, 2026
7118264
fix(pool): discard pooled sender when flush() exits with an Error
bluestreak01 Jun 18, 2026
9bdda71
fix(pool): widen QuestDBImpl ctor cleanup catch to Throwable
bluestreak01 Jun 18, 2026
a2610a6
fix(pool): recover stranded in-range SF slots at pool startup
bluestreak01 Jun 18, 2026
e7d893a
test(pool): cover flush() Error -> discardBroken regression
bluestreak01 Jun 18, 2026
b504e16
test(pool): cover borrow-path SF slot-index release on creation failure
bluestreak01 Jun 18, 2026
d0c03f5
fix(pool): bound startup SF recovery to a shared acquire-timeout budget
bluestreak01 Jun 20, 2026
2709cba
refactor(sf): drop superseded prefix-exclusion OrphanScanner.scan ove…
bluestreak01 Jun 20, 2026
edc38dd
fix(pool): retire stranded SF slot on startup when recoverer keeps flock
bluestreak01 Jun 20, 2026
48c33c8
test(pool): harden teardown coverage and fix native leak in teardown …
bluestreak01 Jun 20, 2026
363a933
fix(pool): guard each QuestDBImpl teardown step against Throwable
bluestreak01 Jun 22, 2026
eb7a04f
test(pool): wrap native-resource teardown tests in assertMemoryLeak
bluestreak01 Jun 22, 2026
ac0bd0f
fix(pool): defer SF startup recovery to the housekeeper thread
bluestreak01 Jun 22, 2026
4acaaf1
fix(pool): make SF startup recovery cancellable and bounded on close()
bluestreak01 Jun 22, 2026
b2dd170
fix(pool): bound SF startup recovery build with forced OFF connect
bluestreak01 Jun 22, 2026
4ab7ce3
Fix NPE prewarming query pool from production path
bluestreak01 Jun 23, 2026
2a64708
jdk8
bluestreak01 Jun 23, 2026
3fb7d28
fix(build): align javadoc source level with active JDK profile
bluestreak01 Jun 23, 2026
2a279cd
ci: build/test/javadoc on JDK 8, compile-smoke on JDK 25
bluestreak01 Jun 23, 2026
db44da8
ci: also build javadoc on the JDK 25 job
bluestreak01 Jun 23, 2026
c851317
fix(pool): force drain_orphans=off on SF startup-recovery delegates
bluestreak01 Jun 23, 2026
d59a573
docs: align CLAUDE.md with dual Java 8/11+ target
bluestreak01 Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 30 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
85 changes: 78 additions & 7 deletions core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
<test.exclude>None</test.exclude>
<test.include>%regex[.*[^o].class]</test.include><!-- exclude module-info.class-->
<jmh.version>1.37</jmh.version>
<!-- JDK-version-specific source root; overridden in the java8 profile -->
<compat.src.dir>src/main/java11</compat.src.dir>
<!-- logback 1.5.x needs Java 11; the java8 profile drops to the 1.3.x line -->
<logback.version>1.5.25</logback.version>
</properties>

<version>1.3.5-SNAPSHOT</version>
Expand Down Expand Up @@ -184,6 +188,27 @@
</excludes>
</configuration>
</plugin>
<!-- Adds the JDK-version-specific source root (src/main/java11 or src/main/java8)
so the FDBigInteger bridge / module-access shim compiles on each JDK. -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>add-compat-source</id>
<phase>generate-sources</phase>
<goals>
<goal>add-source</goal>
</goals>
<configuration>
<sources>
<source>${compat.src.dir}</source>
</sources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

Expand All @@ -206,11 +231,11 @@
</executions>
<configuration>
<doclint>none</doclint>
<source>11</source>
<source>${javac.compile.source}</source>
<detectJavaApiLink>false</detectJavaApiLink>
<additionalJOptions>
<additionalJOption>${compilerArg1}</additionalJOption>
<additionalJOption>${compilerArg2}</additionalJOption>
<additionalJOption>${javadocJOption1}</additionalJOption>
<additionalJOption>${javadocJOption2}</additionalJOption>
</additionalJOptions>
<sourceFileExcludes>
<sourceFileExclude>${excludePattern1}</sourceFileExclude>
Expand Down Expand Up @@ -338,11 +363,11 @@
</executions>
<configuration>
<doclint>none</doclint>
<source>11</source>
<source>${javac.compile.source}</source>
<detectJavaApiLink>false</detectJavaApiLink>
<additionalJOptions>
<additionalJOption>${compilerArg1}</additionalJOption>
<additionalJOption>${compilerArg2}</additionalJOption>
<additionalJOption>${javadocJOption1}</additionalJOption>
<additionalJOption>${javadocJOption2}</additionalJOption>
</additionalJOptions>
<sourceFileExcludes>
<sourceFileExclude>${excludePattern1}</sourceFileExclude>
Expand Down Expand Up @@ -444,6 +469,11 @@
<questdb.artifactid>questdb</questdb.artifactid>
<compilerArg1>--add-exports</compilerArg1>
<compilerArg2>java.base/jdk.internal.math=io.questdb.client</compilerArg2>
<!-- javadoc needs the same module export to resolve jdk.internal.math.FDBigInteger
referenced by the src/main/java11 FdBig bridge. Kept separate from the javac
compilerArgs because javadoc and javac accept different option sets. -->
<javadocJOption1>--add-exports</javadocJOption1>
<javadocJOption2>java.base/jdk.internal.math=io.questdb.client</javadocJOption2>
<excludePattern1>nothing-to-exclude-dummy-value-include-all-java11plus</excludePattern1>
<excludeTestPattern1>nothing-to-exclude-dummy-value-include-all-java11plus</excludeTestPattern1>
<javac.compile.source>${javac.target}</javac.compile.source>
Expand All @@ -466,6 +496,47 @@
</dependency>
</dependencies>
</profile>
<profile>
<id>java8</id>
<properties>
<jdk.version>8</jdk.version>
<java.enforce.version>[1.8,11)</java.enforce.version>
<questdb.artifactid>questdb</questdb.artifactid>
<!-- JDK8 javac rejects the add-exports flag; use a harmless no-op flag instead -->
<compilerArg1>-Xlint:none</compilerArg1>
<compilerArg2>-Xlint:none</compilerArg2>
<!-- JDK8 javadoc rejects javac's -Xlint and needs no module export (the java8 FdBig
bridge uses the open sun.misc.FDBigInteger). Pass a harmless no-op instead. -->
<javadocJOption1>-quiet</javadocJOption1>
<javadocJOption2>-quiet</javadocJOption2>
<!-- module-info.java cannot be compiled on JDK8; exclude it from both the main
(<excludes> = excludeTestPattern1) and test (<testExcludes> = excludePattern1)
source sets. -->
<excludePattern1>**/module-info.java</excludePattern1>
<excludeTestPattern1>**/module-info.java</excludeTestPattern1>
<compat.src.dir>src/main/java8</compat.src.dir>
<javac.compile.source>1.8</javac.compile.source>
<javac.compile.target>1.8</javac.compile.target>
<!-- logback 1.5.x requires Java 11; the 1.3.x line is the Java 8 compatible branch -->
<logback.version>1.3.15</logback.version>
</properties>
<activation>
<jdk>1.8</jdk>
</activation>
<dependencies>
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>17.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.17</version>
</dependency>
</dependencies>
</profile>
</profiles>

<dependencies>
Expand All @@ -485,7 +556,7 @@
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.25</version>
<version>${logback.version}</version>
<scope>test</scope>
</dependency>
<dependency>
Expand Down
7 changes: 7 additions & 0 deletions core/src/main/java/io/questdb/client/QuestDBBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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) {
Expand Down
86 changes: 84 additions & 2 deletions core/src/main/java/io/questdb/client/Sender.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 <orphanDrainBase>-<i> 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).
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -1548,7 +1565,7 @@ public Sender build() {
if (drainOrphans && sfDir != null) {
io.questdb.client.std.ObjList<String> 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 {} "
Expand Down Expand Up @@ -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 <sf_dir>/<base>-<i>}
* when recovering unacked data a previous run left behind.
*/
public String getConfiguredSfDir() {
return sfDir;
}

/**
* Excludes the connection pool's <em>live</em> slot set from
* {@link #drainOrphans(boolean)} scanning: a sibling slot under
* {@code sf_dir} named {@code <base>-<index>} with
* {@code 0 <= index < slotCount} is never treated as a drainable orphan.
* <p>
* Internal introspection hook for the connection pool. The pool gives
* each pooled SF sender a distinct slot id {@code <base>-<index>} 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.
* <p>
* Unlike a blanket {@code <base>-} 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 <base>-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.
* <p>
* 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
Expand Down
Loading
Loading