Skip to content

Commit 7ed1aa1

Browse files
glasstigerclaude
andcommitted
Correct misleading OIDC docs and mislabeled test
Address review findings where documentation and one test described behavior the code does not implement. Comments/test-name only; no runtime behavior changes. - QwpWebSocketSender: three comments claimed a token-provider failure is retried within the reconnect budget and then terminates the sender. The actual behavior is the opposite - the foreground/SYNC connect fails fast with the provider's exception, while the running background drainer retries indefinitely (never budget-bounded, never terminal) per store-and-forward Invariant B. Rewrite all three to match. - OidcDeviceAuth.getToken javadoc said the store per-identity lock wait is "a few seconds at most, then proceeds without the lock." That bound is the cross-process file lock; the in-process lock guarding two same-JVM instances of one identity is not time-bounded and can wait out the peer's whole refresh. Clarify the distinction. - AbstractLineHttpSender: reword the drain comment - the per-flush budget bounds each recv() read, not the whole body cumulatively (fine here because the ILP server is trusted). - FileTokenStoreTest: rename testConcurrentStealContentionTwoWayPreservesMutualExclusion to testSameProcessContendersSerializeAndBothStealStaleLock. Its overlaps==0/maxInside==1 assertions are guaranteed by the in-process PROCESS_LOCKS lock, not the file-lock capture-verify the old name claimed, so the cross-process exclusion is masked in a single JVM. Re-comment to document that the cross-process property is inspection-verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2acba17 commit 7ed1aa1

4 files changed

Lines changed: 43 additions & 30 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -495,8 +495,12 @@ public String getAuthorizationHeaderValue() {
495495
* {@link Builder#httpTimeoutMillis(int)} and still failing fast the moment an interactive sign-in or
496496
* {@link #close()} begins meanwhile. It is not, otherwise, instantaneous - when the cached
497497
* token has expired it makes one synchronous refresh round-trip to the token endpoint (and, with a
498-
* coordinating {@link TokenStore}, may first wait briefly to acquire the store's per-identity lock - a few
499-
* seconds at most for {@link FileTokenStore}, then it proceeds without the lock - before that round-trip).
498+
* coordinating {@link TokenStore}, may first wait to acquire the store's per-identity lock before that
499+
* round-trip). For {@link FileTokenStore} the CROSS-process file lock is bounded to a few seconds and then
500+
* proceeds without it; but the IN-process lock that serializes two instances sharing one identity in the
501+
* same JVM (an ILP {@code Sender} and a {@code QwpQueryClient}, say) is not time-bounded, so such a
502+
* concurrent caller instead waits out the peer's whole refresh - itself bounded only by the OS connect
503+
* stall described next, not by a few seconds.
500504
* The send, response wait and body parse of that round-trip are each bounded by
501505
* {@link Builder#httpTimeoutMillis(int)} (30s by default); the connection phase that precedes them - DNS
502506
* resolution, the TCP connect and the TLS handshake - is bounded by the OS, not by httpTimeoutMillis, so an

core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -715,9 +715,12 @@ private void flush0(boolean closing) {
715715
response.await(remainingMillis);
716716
DirectUtf8Sequence statusCode = response.getStatusCode();
717717
if (isSuccessResponse(statusCode)) {
718-
// bound the body drain by the whole per-flush budget (base + throughput extension), NOT the
719-
// raw request_timeout: recv() otherwise inherits defaultTimeout, so a tuned-low request_timeout
720-
// paired with request_min_throughput would abort a large, still-progressing chunked body
718+
// pass the whole per-flush budget (base + throughput extension) as EACH recv() read's
719+
// timeout, NOT the raw request_timeout: recv() otherwise inherits defaultTimeout, so a
720+
// tuned-low request_timeout paired with request_min_throughput would abort a large,
721+
// still-progressing chunked body. This bounds each read, not the whole body cumulatively -
722+
// fine here because the ILP server is trusted (unlike OidcDeviceAuth.parseBody, which also
723+
// caps total bytes and wall-clock time against an untrusted identity provider).
721724
consumeChunkedResponse(response, actualTimeoutMillis); // if any
722725
if (keepAliveDisabled(response)) {
723726
// Server has HTTP keep-alive disabled, and it's closing this TCP connection.

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,10 @@ public class QwpWebSocketSender implements Sender {
145145
// fixed token or Basic credential; for an httpTokenProvider it pulls a freshly refreshed token,
146146
// so the initial connect and every reconnect re-handshake carry the current token. May be null
147147
// when no auth is configured. Evaluated once per (re)connect round in buildAndConnect, before the
148-
// endpoint walk (not once per endpoint); a throwing provider propagates to the connectWithRetry
149-
// reconnect wrapper, which retries it within the reconnect budget and surfaces the provider's message.
148+
// endpoint walk (not once per endpoint); a throwing provider is wrapped as
149+
// QwpCredentialUnavailableException: the foreground/SYNC initial connect fails fast with the provider's
150+
// own exception, while the running background drainer treats it as a transient outage and retries it
151+
// indefinitely (never bounded by the reconnect budget, never terminal) per store-and-forward Invariant B.
150152
private final Supplier<String> authorizationHeaderSupplier;
151153
private final int autoFlushBytes;
152154
private final long autoFlushIntervalNanos;
@@ -2811,20 +2813,23 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
28112813
// javadoc documents - NOT once per endpoint: a token-provider failure is cluster-wide (a failed silent
28122814
// refresh, or not signed in), not a per-endpoint transport fault, so re-querying it per endpoint would
28132815
// hammer the token endpoint with the same dead credential and mislabel the failure as "all endpoints
2814-
// unreachable". A throw here propagates to the connectWithRetry reconnect wrapper, which retries it
2815-
// within the reconnect budget like any other connect failure and surfaces the provider's own message,
2816-
// so a transient failed refresh recovers and only a persistent one terminates the sender (this
2817-
// transport's documented reconnect model). Mirrors QwpQueryClient, which likewise resolves the
2818-
// credential once before its endpoint walk.
2816+
// unreachable". A throw here is wrapped as QwpCredentialUnavailableException (below): the
2817+
// foreground/SYNC initial connect unwraps it and fails fast with the provider's own message, while the
2818+
// running background drainer treats it as a transient outage and retries it indefinitely with capped
2819+
// backoff (never bounded by the reconnect budget, never terminal), so even a persistent credential
2820+
// outage keeps the buffered rows in store-and-forward rather than terminating the sender (Invariant B).
2821+
// Mirrors QwpQueryClient, which likewise resolves the credential once before its endpoint walk.
28192822
final String authHeader;
28202823
try {
28212824
authHeader = authorizationHeaderSupplier == null ? null : authorizationHeaderSupplier.get();
28222825
} catch (RuntimeException e) {
2823-
// Tag the failure CLASS before it reaches a retry loop: a credential we cannot acquire is not a
2824-
// transport outage, so the background reconnect loop must not retry it forever under Invariant B
2825-
// -- it bounds this class by the reconnect budget and then terminates with the provider's message.
2826-
// A foreground connect unwraps this and rethrows the provider's own exception, so build() still
2827-
// surfaces the provider's error directly rather than an internal wrapper.
2826+
// Tag the failure CLASS so each context applies the right policy: a credential we cannot acquire is
2827+
// not a transport outage. A foreground/SYNC connect unwraps this and rethrows the provider's own
2828+
// exception, so build() surfaces the provider's error directly rather than an internal wrapper. The
2829+
// running background drainer, by contrast, treats it as a transient outage and retries it
2830+
// indefinitely under Invariant B -- never bounding it by the reconnect budget, never latching a
2831+
// terminal -- so a recoverable credential outage never drops a producer store-and-forward promised
2832+
// to keep alive.
28282833
throw new QwpCredentialUnavailableException(e);
28292834
}
28302835
while (true) {

core/src/test/java/io/questdb/client/test/cutlass/auth/FileTokenStoreTest.java

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,8 @@ public void testConcurrentStealContentionDegradesCleanly() throws Exception {
206206
// which two holders can briefly run concurrently. That residual needs three or more contenders and
207207
// degrades only to one extra token refresh (a re-prompt on a rotating-refresh-token IdP), never a
208208
// torn or forged credential, since the Layer-1 atomic-rename write is independent of the lock.
209-
// testConcurrentStealContentionTwoWayPreservesMutualExclusion pins the exclusion the atomic capture
210-
// does guarantee, deterministically, with two contenders.
209+
// testSameProcessContendersSerializeAndBothStealStaleLock covers the two-contender same-JVM case
210+
// (where PROCESS_LOCKS, not the file-lock capture, provides the exclusion it asserts).
211211
final int threads = 4;
212212
AtomicInteger ran = new AtomicInteger();
213213
TokenStore.CriticalSection section = () -> {
@@ -235,7 +235,7 @@ public void testConcurrentStealContentionDegradesCleanly() throws Exception {
235235
}
236236

237237
@Test
238-
public void testConcurrentStealContentionTwoWayPreservesMutualExclusion() throws Exception {
238+
public void testSameProcessContendersSerializeAndBothStealStaleLock() throws Exception {
239239
assertMemoryLeak(() -> {
240240
Path dir = storeDir();
241241
Files.createDirectories(dir);
@@ -245,15 +245,16 @@ public void testConcurrentStealContentionTwoWayPreservesMutualExclusion() throws
245245
Files.write(lock, "crashed-holder-stamp".getBytes(StandardCharsets.UTF_8));
246246
Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 600_000));
247247

248-
// TWO processes race to steal the one abandoned lock. With exactly two contenders the steal is
249-
// deterministically mutually exclusive: the atomic capture (rename) lets exactly one steal the
250-
// abandoned lock, and once a winner holds a freshly-stamped lock the loser reads that live stamp,
251-
// judges it not stale (the 60s window far exceeds each ~100ms hold) and waits rather than stealing
252-
// it; the empty-lock grace likewise stops the loser stealing the winner's lock in its brief
253-
// create->stamp gap. The three-actor residual stealIfStale documents - a peer recreating the lock
254-
// while a second captures it AND a third claims the freed path - structurally cannot arise with two
255-
// threads, so exclusion holds exactly here (the N-way best-effort path is
256-
// testConcurrentStealContentionDegradesCleanly).
248+
// Two threads of the SAME JVM contend for the one abandoned lock. SCOPE NOTE: the mutual exclusion
249+
// asserted below (overlaps==0, maxInside==1) is provided by the in-process PROCESS_LOCKS
250+
// ReentrantLock, which inLock() takes on key.hash() BEFORE any file-lock logic - so it would hold
251+
// even if the file-lock steal were broken. What this test genuinely proves is that two same-process
252+
// contenders each steal the stale lock and run their critical section (ran==2), serialized, without
253+
// leaving an orphaned capture temp (assertNoCaptureTempFiles). The CROSS-process capture-verify in
254+
// stealIfStale - that among separate OS PROCESSES exactly one steal wins - is masked by PROCESS_LOCKS
255+
// here and cannot be exercised in a single JVM; it is verified by inspection, and a two-holder
256+
// outcome is a documented best-effort residual anyway. The N-way degrade path is
257+
// testConcurrentStealContentionDegradesCleanly.
257258
final int threads = 2;
258259
AtomicInteger inside = new AtomicInteger();
259260
AtomicInteger maxInside = new AtomicInteger();
@@ -286,7 +287,7 @@ public void testConcurrentStealContentionTwoWayPreservesMutualExclusion() throws
286287
}
287288

288289
Assert.assertEquals("every contender must run its critical section", threads, ran.get());
289-
Assert.assertEquals("the steal must never admit two holders at once", 0, overlaps.get());
290+
Assert.assertEquals("same-process contenders must never overlap (PROCESS_LOCKS serializes them)", 0, overlaps.get());
290291
Assert.assertEquals("at most one holder at a time", 1, maxInside.get());
291292
assertNoCaptureTempFiles(dir, key);
292293
});

0 commit comments

Comments
 (0)