Skip to content

Commit 00d3dcd

Browse files
committed
feat(facade): add write-only mode so the handle starts without a server
The QuestDB facade always built a reader (QueryClientPool), which prewarms synchronously and fail-fast (default query_pool_min=1, QwpQueryClient has no async connect). So a down server / read primary sank the whole facade build, taking the write side with it. Add QuestDBBuilder.writeOnly(): build an ingest-only handle that never constructs the query pool, so the read side cannot fail startup. A query config is no longer required in this mode (any query config set is ignored), and query()/newQuery() throw a clear "write-only" IllegalStateException. - QuestDBImpl gains a write-only public constructor + a writeOnly flag on the full constructor; the 12-arg white-box test-seam constructor stays unchanged (delegates with writeOnly=false). queryPool/queryThreadLocal are null in write-only mode. - PoolHousekeeper tolerates a null query pool. - QuestDBBuilder.buildWriteOnly() validates + resolves only the sender/shared pool knobs from the ingest config. Pair with initial_connect_retry=async (or sender_pool_min=0) on the ingest config so the write side does not fail-fast either -> the facade starts with no server present. Tests: QuestDBWriteOnlyTest proves the facade builds with no server, that query()/newQuery() are disabled, that no query config is required, and that an async warm sender can buffer a write while serverless.
1 parent 2d1d997 commit 00d3dcd

4 files changed

Lines changed: 190 additions & 8 deletions

File tree

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ public final class QuestDBBuilder {
7272
private int queryPoolMin = UNSET;
7373
private int senderPoolMax = UNSET;
7474
private int senderPoolMin = UNSET;
75+
// When true, build() creates only the ingest (Sender) side: no query pool
76+
// is constructed, so the facade starts even when the server / read primary
77+
// is down, and query() is disabled. A query config is not required.
78+
private boolean writeOnly;
7579

7680
QuestDBBuilder() {
7781
}
@@ -89,6 +93,21 @@ public QuestDBBuilder acquireTimeoutMillis(long millis) {
8993
return this;
9094
}
9195

96+
/**
97+
* Builds an ingest-only (write-only) handle: no query/read pool is created,
98+
* so the facade starts even when the server / read primary is unavailable
99+
* (the read side is normally fail-fast and would sink the whole facade).
100+
* {@link QuestDB#query()} / {@link QuestDB#newQuery()} are disabled and a
101+
* query configuration is not required (any query config set is ignored).
102+
* <p>
103+
* Pair with {@code initial_connect_retry=async} (or {@code sender_pool_min=0})
104+
* on the ingest config so the write side does not fail-fast either.
105+
*/
106+
public QuestDBBuilder writeOnly() {
107+
this.writeOnly = true;
108+
return this;
109+
}
110+
92111
/**
93112
* Sets the async connection-event listener applied to every pooled ingest
94113
* {@link Sender}. The listener observes connect / disconnect / failover
@@ -141,6 +160,9 @@ public QuestDB build() {
141160
if (ingestConfig == null) {
142161
throw new IllegalStateException("ingest configuration is required; call fromConfig() or ingestConfig()");
143162
}
163+
if (writeOnly) {
164+
return buildWriteOnly();
165+
}
144166
if (queryConfig == null) {
145167
throw new IllegalStateException("query configuration is required; call fromConfig() or queryConfig()");
146168
}
@@ -184,6 +206,34 @@ public QuestDB build() {
184206
);
185207
}
186208

209+
// Ingest-only build path: validates and resolves the sender + shared pool
210+
// knobs from the ingest config alone and constructs a QuestDBImpl with no
211+
// query pool. Never touches the read side, so a down server cannot fail it.
212+
private QuestDB buildWriteOnly() {
213+
ConfigString ingestCs = ConfigString.parse(ingestConfig);
214+
ConfigView ingestView = new ConfigView(ingestCs);
215+
Sender.LineSenderBuilder.validateWsConfigString(ingestConfig);
216+
// Only the ingest view applies; pass it for both sides so the existing
217+
// resolvers (which cross-check ingest vs query) read a single source.
218+
resolvePoolInt(senderPoolMin, "sender_pool_min", ingestView, ingestView, DEFAULT_POOL_MIN, this::senderPoolMin);
219+
resolvePoolInt(senderPoolMax, "sender_pool_max", ingestView, ingestView, DEFAULT_POOL_MAX, this::senderPoolMax);
220+
resolvePoolLong(acquireTimeoutMillis, "acquire_timeout_ms", ingestView, ingestView, DEFAULT_ACQUIRE_TIMEOUT_MILLIS, this::acquireTimeoutMillis);
221+
resolvePoolLong(idleTimeoutMillis, "idle_timeout_ms", ingestView, ingestView, DEFAULT_IDLE_TIMEOUT_MILLIS, this::idleTimeoutMillis);
222+
resolvePoolLong(maxLifetimeMillis, "max_lifetime_ms", ingestView, ingestView, DEFAULT_MAX_LIFETIME_MILLIS, this::maxLifetimeMillis);
223+
resolvePoolLong(housekeeperIntervalMillis, "housekeeper_interval_ms", ingestView, ingestView, DEFAULT_HOUSEKEEPER_INTERVAL_MILLIS, this::housekeeperIntervalMillis);
224+
return new QuestDBImpl(
225+
ingestConfig,
226+
senderPoolMin,
227+
senderPoolMax,
228+
acquireTimeoutMillis,
229+
idleTimeoutMillis,
230+
maxLifetimeMillis,
231+
housekeeperIntervalMillis,
232+
errorHandler,
233+
connectionListener
234+
);
235+
}
236+
187237
/**
188238
* Sets a single configuration string used for both ingest and egress. The
189239
* schema must be {@code ws} or {@code wss}.

core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,10 @@ private void runLoop() {
126126
// thread and stop all future reaping for the life of the handle.
127127
}
128128
try {
129-
queryPool.reapIdle();
129+
// queryPool is null for a write-only (ingest-only) handle.
130+
if (queryPool != null) {
131+
queryPool.reapIdle();
132+
}
130133
} catch (Throwable ignored) {
131134
// Same rationale as the senderPool guard above: best-effort,
132135
// must never propagate, and Throwable (not RuntimeException) so

core/src/main/java/io/questdb/client/impl/QuestDBImpl.java

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,26 @@ public QuestDBImpl(
6666
) {
6767
this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax,
6868
acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis,
69-
housekeeperIntervalMillis, null, null, errorHandler, connectionListener);
69+
housekeeperIntervalMillis, null, null, errorHandler, connectionListener, false);
70+
}
71+
72+
// Write-only (ingest-only) handle: no query pool is created, so build()
73+
// never touches the read side and the facade starts even when the
74+
// server / read primary is unavailable. query()/newQuery() are disabled.
75+
public QuestDBImpl(
76+
String ingestConfig,
77+
int senderMin,
78+
int senderMax,
79+
long acquireTimeoutMillis,
80+
long idleTimeoutMillis,
81+
long maxLifetimeMillis,
82+
long housekeeperIntervalMillis,
83+
SenderErrorHandler errorHandler,
84+
SenderConnectionListener connectionListener
85+
) {
86+
this(ingestConfig, null, senderMin, senderMax, 0, 0,
87+
acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis,
88+
housekeeperIntervalMillis, null, null, errorHandler, connectionListener, true);
7089
}
7190

7291
// Package-private constructor exposing the senderFactory and connectHook test
@@ -91,7 +110,7 @@ public QuestDBImpl(
91110
) {
92111
this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax,
93112
acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis,
94-
housekeeperIntervalMillis, senderFactory, connectHook, null, null);
113+
housekeeperIntervalMillis, senderFactory, connectHook, null, null, false);
95114
}
96115

97116
// Full constructor adding the ingest-side errorHandler/connectionListener,
@@ -112,7 +131,8 @@ public QuestDBImpl(
112131
IntFunction<Sender> senderFactory,
113132
Consumer<QwpQueryClient> connectHook,
114133
SenderErrorHandler errorHandler,
115-
SenderConnectionListener connectionListener
134+
SenderConnectionListener connectionListener,
135+
boolean writeOnly
116136
) {
117137
SenderPool builtSenderPool = null;
118138
QueryClientPool builtQueryPool = null;
@@ -126,9 +146,13 @@ public QuestDBImpl(
126146
// server; the housekeeper drives it via runStartupRecoveryStep().
127147
true,
128148
errorHandler, connectionListener);
129-
builtQueryPool = new QueryClientPool(
130-
queryConfig, queryMin, queryMax, acquireTimeoutMillis,
131-
idleTimeoutMillis, maxLifetimeMillis, connectHook);
149+
// Write-only: skip the read side entirely so a down server / read
150+
// primary cannot fail the facade build.
151+
if (!writeOnly) {
152+
builtQueryPool = new QueryClientPool(
153+
queryConfig, queryMin, queryMax, acquireTimeoutMillis,
154+
idleTimeoutMillis, maxLifetimeMillis, connectHook);
155+
}
132156
builtHousekeeper = new PoolHousekeeper(builtSenderPool, builtQueryPool, housekeeperIntervalMillis);
133157
builtHousekeeper.start();
134158
} catch (Throwable e) {
@@ -158,7 +182,7 @@ public QuestDBImpl(
158182
this.senderPool = builtSenderPool;
159183
this.queryPool = builtQueryPool;
160184
this.housekeeper = builtHousekeeper;
161-
this.queryThreadLocal = ThreadLocal.withInitial(() -> new QueryImpl(queryPool));
185+
this.queryThreadLocal = writeOnly ? null : ThreadLocal.withInitial(() -> new QueryImpl(queryPool));
162186
}
163187

164188
@Override
@@ -219,16 +243,24 @@ public Completion executeSql(CharSequence sql, QwpColumnBatchHandler handler) {
219243

220244
@Override
221245
public Query newQuery() {
246+
requireQueryEnabled();
222247
return new QueryImpl(queryPool);
223248
}
224249

225250
@Override
226251
public Query query() {
252+
requireQueryEnabled();
227253
QueryImpl q = queryThreadLocal.get();
228254
q.resetIfDone();
229255
return q;
230256
}
231257

258+
private void requireQueryEnabled() {
259+
if (queryPool == null) {
260+
throw new IllegalStateException("query/read is disabled on a write-only QuestDB client");
261+
}
262+
}
263+
232264
@Override
233265
public void releaseSender() {
234266
senderPool.releaseCurrentThread();
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*+*****************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.test;
26+
27+
import io.questdb.client.QuestDB;
28+
import io.questdb.client.Sender;
29+
import io.questdb.client.test.cutlass.qwp.client.TestPorts;
30+
import org.junit.Assert;
31+
import org.junit.Test;
32+
33+
/**
34+
* A write-only (ingest-only) {@link QuestDB} facade skips the read pool, which
35+
* is otherwise fail-fast at startup -- so the handle builds even when the
36+
* server / read primary is unavailable.
37+
*/
38+
public class QuestDBWriteOnlyTest {
39+
40+
@Test
41+
public void testWriteOnlyDoesNotRequireQueryConfig() {
42+
int port = TestPorts.findUnusedPort();
43+
// Only an ingest config is supplied -- no queryConfig()/fromConfig().
44+
try (QuestDB db = QuestDB.builder()
45+
.ingestConfig("ws::addr=localhost:" + port + ";sender_pool_min=0;")
46+
.writeOnly()
47+
.build()) {
48+
Assert.assertNotNull(db);
49+
}
50+
}
51+
52+
@Test
53+
public void testWriteOnlyIngestWorksWhileServerless() {
54+
int port = TestPorts.findUnusedPort();
55+
// Warm one sender in async mode against a dead port: build() returns an
56+
// unconnected-but-usable sender; writes buffer until the wire is up.
57+
try (QuestDB db = QuestDB.builder()
58+
.fromConfig("ws::addr=localhost:" + port + ";sender_pool_min=1;sender_pool_max=1"
59+
+ ";initial_connect_retry=async"
60+
+ ";reconnect_max_duration_millis=400;reconnect_initial_backoff_millis=10"
61+
+ ";reconnect_max_backoff_millis=50;close_flush_timeout_millis=0;")
62+
.writeOnly()
63+
.build()) {
64+
Sender sender = db.borrowSender();
65+
Assert.assertNotNull("a sender must be available with no server present", sender);
66+
// Buffering a row against an unconnected sender must not throw.
67+
sender.table("t").longColumn("v", 1L).atNow();
68+
}
69+
}
70+
71+
@Test
72+
public void testWriteOnlyStartsWithoutServerAndDisablesQuery() {
73+
int port = TestPorts.findUnusedPort();
74+
// No server at `port`. A normal facade builds the read pool eagerly
75+
// (query_pool_min defaults to 1) and would fail-fast here; write-only
76+
// skips it, and sender_pool_min=0 means no eager ingest connect either,
77+
// so build() performs no network I/O and succeeds.
78+
try (QuestDB db = QuestDB.builder()
79+
.fromConfig("ws::addr=localhost:" + port + ";sender_pool_min=0;")
80+
.writeOnly()
81+
.build()) {
82+
assertQueryDisabled(db::query);
83+
assertQueryDisabled(db::newQuery);
84+
}
85+
}
86+
87+
private static void assertQueryDisabled(Runnable read) {
88+
try {
89+
read.run();
90+
Assert.fail("query/read must be disabled on a write-only client");
91+
} catch (IllegalStateException expected) {
92+
Assert.assertTrue(
93+
"message should explain write-only: " + expected.getMessage(),
94+
expected.getMessage().toLowerCase().contains("write-only"));
95+
}
96+
}
97+
}

0 commit comments

Comments
 (0)