Skip to content

Commit f0b8b21

Browse files
authored
feat(qwp): connect timeout, ingest callbacks, and lazy_connect (tolerant startup) on the QuestDB facade (#60)
1 parent 660a06b commit f0b8b21

152 files changed

Lines changed: 16759 additions & 4554 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/review-pr/SKILL.md

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,18 @@ Capture the PR identifier in `$PR` (the part of `$ARGUMENTS` left after strippin
5151
PR='<PR number or URL from $ARGUMENTS, with any --level=N / -lN / bare-digit level token removed>'
5252
gh pr view "$PR" --json number,title,body,labels,state
5353
gh pr diff "$PR"
54+
gh pr diff "$PR" --numstat # binary files show as `-<TAB>-<TAB><path>`
5455
gh pr view "$PR" --comments
5556
```
5657

58+
**Committed-binary gate (runs at every level).** Scan the `--numstat` output for
59+
any added/modified file git reports as binary (`-`/`-` in the added/deleted
60+
columns). This repo builds its native/C libraries from source in CI and does not
61+
commit build outputs, so any such file is a **Critical** finding regardless of
62+
review level — report it even at level 0. See the "Committed build artifacts"
63+
checklist for the rationale and the acceptable-exception (genuine test-input
64+
fixtures only).
65+
5766
## Step 2: PR title and description
5867

5968
Check against CLAUDE.md conventions:
@@ -144,7 +153,7 @@ Every agent receives:
144153

145154
Launch the following agents in parallel.
146155

147-
**Agent 1 — Correctness & bugs:** NULL handling, edge cases, logic errors, off-by-one, operator precedence, error paths. Cross-reference every changed symbol against its callsite inventory and verify the new behavior is correct at each callsite.
156+
**Agent 1 — Correctness & bugs:** NULL handling, edge cases, logic errors, off-by-one, operator precedence, error paths. Cross-reference every changed symbol against its callsite inventory and verify the new behavior is correct at each callsite. When the diff touches the store-and-forward sender, the async drainer / send loop, primary reconnect/failover, or pool startup (`lazy_connect` / `initial_connect_retry` / `SenderPool` / `QueryClientPool`), also verify the "Store-and-forward & pool startup invariants" checklist — a running drainer that propagates a transport error to the caller, imposes a reconnect time budget, or hard-fails on a transient outage is a Critical (data-loss) finding.
148157

149158
**Agent 2 — Concurrency:** Race conditions, shared mutable state, missing volatile, lock ordering, thread-safety of data structures. Use the implicit contract list (lock order, thread-affinity) and check every callsite from 2.5b for violations of the new contract.
150159

@@ -154,7 +163,7 @@ Launch the following agents in parallel.
154163

155164
**Agent 5 — Test coverage:** Coverage gaps, error path tests, NULL tests, boundary conditions, regression tests exist, `assertMemoryLeak()` usage. Cross-reference 2.5d: every cross-context exposure should have a test that exercises the changed symbol from that context. Missing tests for cross-context callsites is a high-priority finding. Test *efficacy* (whether those tests actually exercise the change and could fail) and test-*code* quality are handled by Agents 11-13 — here focus only on whether coverage exists for every new or changed path.
156165

157-
**Agent 6 — Code quality & standards:** Code smell, member ordering, naming conventions, modern Java features, dead code, third-party dependencies.
166+
**Agent 6 — Code quality & standards:** Code smell, member ordering, naming conventions, modern Java features, dead code, third-party dependencies. Also scan the diff for any committed compiled binary / build artifact (run `git diff --numstat`/`--stat` and flag files git reports as binary) — the native/C libraries are built from source in CI, so a committed binary is a **Critical** finding (see the "Committed build artifacts" checklist).
158167

159168
**Agent 7 — PR metadata & conventions:** Title format, description quality, commit messages, labels, SQL style in tests.
160169

@@ -278,6 +287,26 @@ Review the diff for:
278287
- Code smell: overly complex methods, deep nesting, unclear intent, dead code
279288
- No third-party Java dependencies on data paths
280289

290+
### Committed build artifacts
291+
- **A newly committed compiled binary is always Critical.** This repo builds its
292+
native/C libraries from source in CI (`rebuild_native_libs.yml`,
293+
`build_native.yaml`, guarded by `check-glibc-floor.sh`) and does not commit
294+
build outputs. A binary added or modified in the diff cannot be reviewed,
295+
audited, or reproduced from source, can smuggle in unaudited or malicious
296+
code, and bloats the repo history irreversibly — so it blocks the merge.
297+
- Detect it structurally, not by extension alone: run `git diff --stat` /
298+
`git diff --numstat` on the PR and flag every added/modified file git reports
299+
as binary (`numstat` shows `-`/`-` for added/deleted lines; `--stat` shows a
300+
`Bin … -> … bytes` marker). Typical offenders: `.so`, `.dylib`, `.dll`, `.a`,
301+
`.o`, `.lib`, `.exe`, `.class`, `.jar`, `.war`, `.wasm`, `.node`, `.bin`.
302+
- The finding stands even when the binary "looks" legitimate (e.g. a rebuilt
303+
`libquestdb.*`): the correct source of these artifacts is the CI native-build
304+
pipeline plus release packaging, never a PR diff. The only acceptable binaries
305+
are genuine test-input fixtures/resources (data a test reads), not build
306+
outputs — and even those must be justified.
307+
- Suggested fix: drop the binary from the PR, confirm a `.gitignore` entry
308+
covers it, and let CI native-build + release packaging produce it.
309+
281310
### QuestDB coding standards
282311
- Class members grouped by kind (static vs instance) and visibility, sorted alphabetically
283312
- Boolean names use `is...` / `has...` prefix
@@ -288,6 +317,82 @@ Review the diff for:
288317
- try-with-resources used where applicable
289318
- Native memory freed correctly
290319

320+
### Store-and-forward & pool startup invariants (QWP facade)
321+
Apply this whenever the diff touches the SF sender, the async drainer / send
322+
loop, primary reconnect/failover, `SenderPool` / `QueryClientPool` startup,
323+
`lazy_connect`, or `initial_connect_retry`. A violation here is a **Critical**
324+
finding: the whole point of store-and-forward is that a running producer never
325+
loses data and never hard-fails on a transient outage.
326+
327+
**Drainer (steady state — once the pool is running).**
328+
- Once the pool is running, an async drainer thread ships buffered SF data to
329+
the server. It MUST NOT propagate server / transport errors back to the
330+
client (`Sender` producer calls, `flush()`, the pooled handle). The ONLY
331+
error a running drainer may surface to the caller is **SF out of space** (the
332+
on-disk / backing buffer is full and can accept no more rows). Flag any other
333+
failure class (connect-refused, DNS, unreachable/black-hole, TLS/cert, auth,
334+
role-reject, upgrade/protocol timeout, reset) that can escape the drainer
335+
onto a producer or borrow call.
336+
- Primary reconnect MUST be fully contained inside the drainer thread and MUST
337+
have **no time limit** — no `reconnect_max_duration_millis`-style budget, no
338+
deadline, no "give up and latch terminal after N ms". A budget that latches
339+
the sender terminal on a long outage is a Critical violation: it drops a
340+
producer that store-and-forward promised to keep alive. Flag any bounded
341+
reconnect loop, `deadlineNanos` / `while (now < deadline)`, or terminal
342+
`SenderError` reachable from the running drainer's reconnect path.
343+
- The drainer must retry with **exponential backoff** and handle every connect
344+
failure class gracefully, without a hard fail — it keeps buffering and keeps
345+
retrying until the wire is back. The per-attempt backoff may be capped (a max
346+
delay between attempts), but the RETRY LOOP ITSELF must be unbounded. Flag a
347+
capped total retry duration or an attempt-count cap on the steady-state
348+
drainer.
349+
- **Sanctioned terminals (orphan-slot drainer only).** The orphan drainer
350+
(`BackgroundDrainer`) MAY quarantine its slot (`.failed` sentinel,
351+
human-in-the-loop) on conditions that are terminal by design: auth failure,
352+
a non-421 upgrade reject, and a genuine cluster-wide durable-ack capability
353+
gap that exhausted its documented settle budget (16 consecutive
354+
capability-gap sweeps, or a wall-clock budget anchored at the FIRST
355+
capability-gap error of the episode — whichever is hit first). These are
356+
NOT violations of the no-budget rule above. The settle budget applies ONLY
357+
to consecutive capability-gap attempts: transient classes (role reject,
358+
transport error) must never increment it or burn its wall clock — a
359+
transient state consuming the terminal budget (shared attempt counter,
360+
entry-anchored deadline) IS a Critical violation of this checklist.
361+
- **Mid-stream server NACKs (no drop policy).** The NACK policy must mirror
362+
the connect-time tiering. A rejection category that a transient cluster
363+
state can produce (`WRITE_ERROR`, `INTERNAL_ERROR`, `UNKNOWN` — and any
364+
future status byte) is RETRIABLE: recycle the wire and replay from
365+
`ackedFsn+1`. It must NEVER drop the batch and NEVER latch a terminal /
366+
quarantine a slot on first sight. Only rejections deterministic under
367+
byte-identical replay (`SCHEMA_MISMATCH`, `PARSE_ERROR`, `SECURITY_ERROR`
368+
on a writable node) may go TERMINAL. A client that advances the ack
369+
watermark past a NACKed frame is silently losing data — Critical. A frame
370+
repeatedly rejected with no ack progress must escalate through the
371+
poison-frame detector (bounded consecutive strikes at the same head FSN),
372+
not through a WS close-code list — close codes carry no policy semantics.
373+
`UNKNOWN` must fail OPEN (retry), never closed (terminal): a status byte
374+
from a newer server must degrade to retry, not to a dead sender.
375+
376+
**Pool startup — two modes; the mode decides who sees connectivity errors.**
377+
- `lazy_connect=true`: `build()` MUST succeed with **no server present**. The
378+
producing `Sender` must work immediately (writes buffer via SF), and once the
379+
server comes up the read side must also connect and read (reads are deferred,
380+
not disabled). Verify `build()` does not fail-fast, the sender does not throw
381+
on the first write while the server is down, and a later `borrowQuery()`
382+
succeeds once the server is up.
383+
- `lazy_connect=false` (default): `build()` / the initial connect MUST expose
384+
connectivity problems to the caller — DNS errors, connect-refused /
385+
unreachable, TLS/cert, authentication/authorization, and connect/upgrade
386+
timeouts must all surface as a thrown exception at startup, not be swallowed.
387+
Verify each of those failure classes reaches the user during initialization.
388+
- **In BOTH modes the boundary is the same:** connectivity errors are only
389+
ever the caller's problem DURING initialization. Once the client has
390+
connected and is past initialization, the running drainer reverts to the
391+
steady-state contract above — it must NEVER expose transport problems, NEVER
392+
impose a reconnect time budget, and NEVER hard-fail on a transient outage.
393+
Anything that undermines the store-and-forward guarantee past init is
394+
Critical.
395+
291396
### SQL conventions (if tests or SQL involved)
292397
- Keywords in UPPERCASE
293398
- `expr::TYPE` cast syntax preferred over CAST()
@@ -340,7 +445,10 @@ Review the diff for:
340445
Present ONLY verified findings (false positives are excluded). Structure as:
341446

342447
### Critical
343-
Issues that must be fixed before merge. Each must include:
448+
Issues that must be fixed before merge. **A newly committed compiled binary or
449+
other build artifact (see the "Committed build artifacts" checklist) is always
450+
Critical, no matter how legitimate it looks — native/C libraries are built from
451+
source in CI, so a binary in the diff is never acceptable.** Each must include:
344452
- Exact file path and line numbers (including out-of-diff files)
345453
- Whether the finding is **in-diff** or **out-of-diff**
346454
- Code path trace showing why the bug is real
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env bash
2+
# Assert the glibc runtime floor of a Linux native library.
3+
#
4+
# Usage: check-glibc-floor.sh <path-to-libquestdb.so> <max-glibc-version>
5+
# e.g. check-glibc-floor.sh core/.../linux-x86-64/libquestdb.so 2.14
6+
# check-glibc-floor.sh core/.../linux-aarch64/libquestdb.so 2.17
7+
#
8+
# The dynamic linker resolves .gnu.version_r at load time, so the HIGHEST
9+
# GLIBC_x.y version node the library imports is its hard load floor: a host
10+
# whose glibc is older than that node fails System.loadLibrary/dlopen with
11+
# `version 'GLIBC_x.y' not found`. This script extracts every versioned import
12+
# and fails if the highest one exceeds the allowed floor.
13+
#
14+
# Why the floors are what they are:
15+
# * linux-x86-64 -> 2.14. The oldest node we intentionally keep is
16+
# memcpy@GLIBC_2.14; clock_gettime is pinned back to GLIBC_2.2.5 by
17+
# src/main/c/share/glibc_compat.h, and stat/fstat resolve to the inline
18+
# __xstat/__fxstat@GLIBC_2.2.5 wrappers when built in a low-glibc container.
19+
# A build on a modern host (glibc >= 2.33) instead emits stat@GLIBC_2.33 /
20+
# fstat@GLIBC_2.33 and trips this guard -- that is exactly the regression it
21+
# exists to catch.
22+
# * linux-aarch64 -> 2.17. glibc gained aarch64 support in 2.17, so 2.17 is
23+
# the lowest floor physically achievable on that architecture.
24+
#
25+
# Portable to bash 3.2 (no mapfile / no negative array indices) so it can be run
26+
# locally on macOS as well as in the glibc build containers.
27+
set -euo pipefail
28+
29+
lib="${1:?usage: check-glibc-floor.sh <lib.so> <max-glibc-version>}"
30+
floor="${2:?usage: check-glibc-floor.sh <lib.so> <max-glibc-version>}"
31+
32+
if [ ! -f "$lib" ]; then
33+
echo "::error::check-glibc-floor: library not found: $lib"
34+
exit 1
35+
fi
36+
37+
# All distinct versioned GLIBC nodes (e.g. 2.14, 2.2.5), sorted ascending.
38+
# objdump prints them as (GLIBC_x.y) or GLIBC_x.y depending on the toolchain;
39+
# the -o regex captures the token regardless of surrounding parentheses.
40+
# GLIBC_PRIVATE has no digit after the underscore, so it is naturally excluded.
41+
versions="$(
42+
objdump -T "$lib" \
43+
| grep -oE 'GLIBC_[0-9]+(\.[0-9]+)+' \
44+
| sed 's/^GLIBC_//' \
45+
| sort -Vu
46+
)"
47+
48+
if [ -z "$versions" ]; then
49+
echo "::error::check-glibc-floor: no versioned GLIBC symbols found in $lib (unexpected)."
50+
exit 1
51+
fi
52+
53+
highest="$(printf '%s\n' "$versions" | tail -n1)"
54+
55+
echo "GLIBC version nodes required by $lib:"
56+
printf '%s\n' "$versions" | sed 's/^/ GLIBC_/'
57+
echo "Highest required: GLIBC_${highest} (allowed floor: GLIBC_${floor})"
58+
59+
# leq A B -> succeeds when version A <= version B: sorting {A, B} with -V puts B
60+
# last, or they are equal.
61+
leq() {
62+
[ "$1" = "$2" ] && return 0
63+
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$2" ]
64+
}
65+
66+
if leq "$highest" "$floor"; then
67+
echo "OK: $lib floor is GLIBC_${highest} (<= GLIBC_${floor})."
68+
exit 0
69+
fi
70+
71+
echo "::error::GLIBC floor regression in $lib: requires GLIBC_${highest}, above the GLIBC_${floor} floor."
72+
echo "::error::This library will fail to load on hosts with glibc < ${highest}."
73+
echo "Offending nodes above the floor and the symbols that pull them in:"
74+
printf '%s\n' "$versions" | while IFS= read -r v; do
75+
if ! leq "$v" "$floor"; then
76+
echo " GLIBC_${v}:"
77+
objdump -T "$lib" | grep -E "GLIBC_${v//./\\.}([^0-9]|\$)" | awk '{print " " $NF}' | sort -u
78+
fi
79+
done
80+
exit 1

.github/scripts/stage-native-artifacts.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ downloaded="core/target/downloaded-native-artifacts"
99
staged="core/target/native-libs/io/questdb/client/bin"
1010

1111
# platform -> library filename
12+
# darwin-x86-64 is intentionally not shipped: no CI runs the test suite on x64
13+
# macOS, so the release does not build or bundle a binary it cannot test.
1214
declare -A libs=(
1315
[darwin-aarch64]=libquestdb.dylib
14-
[darwin-x86-64]=libquestdb.dylib
1516
[linux-aarch64]=libquestdb.so
1617
[linux-x86-64]=libquestdb.so
1718
[windows-x86-64]=libquestdb.dll

0 commit comments

Comments
 (0)