Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e99c708
docs(qwp): specify refreshing bearer-token providers for Entra ID
bluestreak01 Oct 1, 2026
c9f2b96
feat(qwp): refreshing bearer-token providers and Microsoft Entra ID auth
bluestreak01 Oct 2, 2026
6fcc5df
Classify Azure credential errors by mode
bluestreak01 Oct 2, 2026
6492ebf
feat(qwp): select the Azure credential with azure_credential
bluestreak01 Oct 6, 2026
16cfb41
Merge main into feat/qwp-entra-token-provider
bluestreak01 Oct 7, 2026
d78d384
fix(qwp): don't close a pooled query client under a busy dispatch thread
bluestreak01 Oct 7, 2026
fefe382
fix(qwp): report a query connection lost when failover gives up
bluestreak01 Oct 7, 2026
6048875
fix(qwp): say memory-only rows are lost when the auth deadline fires
bluestreak01 Oct 7, 2026
96fd944
Merge client main for symbol-dictionary tests
glasstiger Oct 12, 2026
83847ae
Report external FSNs from the auth deadline
glasstiger Oct 13, 2026
1b92465
Widen the retry budget of a credential test
glasstiger Oct 13, 2026
2dd97e9
Fix a racy test counter and stale credential docs
glasstiger Oct 14, 2026
8a012d6
Sort health members and add a digit separator
glasstiger Oct 14, 2026
2c4a79b
Address token-provider review findings
glasstiger Oct 14, 2026
15fe202
Fix a racy scheduler check in provider tests
glasstiger Oct 14, 2026
a38afc3
Test the lease release on a failed sender build
glasstiger Oct 14, 2026
33200e9
Remove the authentication-outage deadline
glasstiger Oct 14, 2026
4355127
Report an outage when a connected sender fails
glasstiger Oct 14, 2026
8eaaef1
Address the level-3 review findings
glasstiger Oct 15, 2026
0bb9713
Fix token provider linger and test assertions
glasstiger Oct 15, 2026
7f2bae8
Prevent redundant token refreshes
glasstiger Oct 15, 2026
0b106bf
Keep token retry backoff above zero
glasstiger Oct 15, 2026
ae837a7
Merge remote-tracking branch 'origin/main' into feat/qwp-entra-token-…
glasstiger Oct 15, 2026
40fe8d8
Test close during credential refresh
glasstiger Oct 15, 2026
086f1a7
Honor Azure MSAL Retry-After hints
glasstiger Oct 15, 2026
2554335
Test superseded token refresh callbacks
glasstiger Oct 15, 2026
97cfe2b
Report health loss after catch-up failure
glasstiger Oct 15, 2026
65bc2ff
Fix Azure module discovery and diagnostics
glasstiger Oct 15, 2026
5b1da60
Report health loss during dictionary recycling
glasstiger Oct 15, 2026
ff6c0ee
Fix the health test recovery ordering
glasstiger Oct 15, 2026
dc9cf22
Resolve query token provider configuration once
glasstiger Oct 16, 2026
51dc740
Fix Azure rejection handling and replay test race
glasstiger Oct 16, 2026
4aa6c39
Classify missing credential files as permanent
glasstiger Oct 16, 2026
a5a9702
Drop unused requires from the client descriptor
glasstiger Oct 17, 2026
b0fbaa7
Fix member order and a stale design note
glasstiger Oct 17, 2026
add519a
Test the 401 retry's no-retry branches
glasstiger Oct 17, 2026
471f060
Reject non-ASCII Azure client IDs
glasstiger Oct 18, 2026
4c8ff95
Fail fast on malformed Azure certificates
glasstiger Oct 19, 2026
748ed98
Fix query shutdown and health classification
glasstiger Oct 19, 2026
cfee59a
Strengthen auth and shutdown regression tests
glasstiger Oct 19, 2026
66009e1
Prefix new boolean fields with is
glasstiger Oct 19, 2026
2529665
Fix skip-test builds and new member order
glasstiger Oct 19, 2026
76718cb
Fix query shutdown and Windows native builds
glasstiger Oct 19, 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
23 changes: 17 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,13 @@ jobs:
if-no-files-found: error

# Takes the JDK 8-built jar (the shipped artifact) from build-jdk8 and runs it
# on every newer JDK the client must work on, asserting (a) the automatic
# module name is io.questdb.client and (b) slow-path double formatting
# resolves the META-INF/versions/11 bridge. These are only observable in the
# PACKAGED jar on a modern JDK, and each regression here was invisible to the
# other jobs until the jar was EXECUTED on the right JDK/flags:
# on every newer JDK the client must work on, asserting (a) the versioned
# module descriptor names io.questdb.client and has no mandatory requires
# beyond java.base, jdk.unsupported and org.slf4j, and (b) slow-path double
# formatting resolves the META-INF/versions/11 bridge. These are only
# observable in the PACKAGED jar on a modern JDK, and each regression here
# was invisible to the other jobs until the jar was EXECUTED on the right
# JDK/flags:
# * 1.3.5-1.3.7: FdBig linked sun.misc.FDBigInteger (gone since Java 9) and
# the module name degraded to the filename-derived "questdb.client".
# * issue #96: JDK 26 made FDBigInteger package-private (passed 8/11/25).
Expand Down Expand Up @@ -134,8 +136,17 @@ jobs:
run: |
jar_file=(client-jar/questdb-client-*.jar)
jar_file="${jar_file[0]}"
jar --describe-module --file "$jar_file" | tee module.txt
jar --describe-module --file "$jar_file" --release 11 | tee module.txt
grep -q '^io\.questdb\.client@' module.txt
# A mandatory requires that the application's runtime image lacks fails
# module resolution at startup. A jlink image built from the client's
# jdeps output has java.base, jdk.unsupported and at most java.desktop,
# so any other platform module the client uses must be a static requires.
mandatory="$(awk '$1 == "requires" && !/ static( |$)/ {print $2}' module.txt | sort | tr '\n' ' ')"
if [ "$mandatory" != "java.base jdk.unsupported org.slf4j " ]; then
echo "::error::unexpected mandatory requires in the released module descriptor: ${mandatory}"
exit 1
fi
javac -cp "$jar_file" -d smoke-classes \
core/src/test/java/io/questdb/client/test/std/DoubleFormatSmoke.java
# The FdBig double formatter reaches jdk.internal.math.FDBigInteger by
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ core/target
utils/target
utils/dependency-reduced-pom.xml
examples/target
azure/target
compat/target
core/node
deploy*.bat
Expand Down
117 changes: 117 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,119 @@ The token is stored as **plaintext JSON protected by file permissions** — `060

`FileTokenStore` is safe to share between processes that sign in as the same identity: each update is written atomically (so a concurrent reader never sees a half-written credential), and when the identity provider rotates the refresh token on each refresh, the read-refresh-write is serialized across processes with a lock file so they do not race each other into an unnecessary re-prompt. The lock file's staleness is judged by its modification time, so this coordination assumes the processes share a clock — a single machine, or machines with synchronized clocks; under significant clock skew (for example a store directory on NFS shared across hosts) a live lock can be mis-judged stale or a dead one never expire. `clearCache()` removes the persisted entry under the same lock, but across processes it is best-effort: a peer that still holds a live in-memory token may legitimately re-persist afterwards (it always forces a fresh sign-in for the calling process).

### Rotating Bearer Tokens (Microsoft Entra ID and Other Identity Platforms)

Service identities - an Azure managed identity, a service principal, an AKS workload identity - authenticate with
bearer tokens that expire every hour or every day. Select a **token provider** and the client fetches tokens itself,
keeps them in a shared cache, and refreshes them in the background well before they expire, so long-lived senders and
query clients keep reconnecting with a valid token.

**Microsoft Entra ID from a connect string.** Add the optional `questdb-client-azure` artifact (same version as
`questdb-client`); it brings Azure Identity and registers `token_provider=azure`:

```xml
<dependency>
<groupId>org.questdb</groupId>
<artifactId>questdb-client-azure</artifactId>
<version>1.0.0</version>
</dependency>
```

```java
try (QuestDB db = QuestDB.connect(
"wss::addr=qdb1:9000,qdb2:9000;token_provider=azure;azure_resource=api://<questdb-app-id>;"
+ "azure_credential=managed_identity;")) {
// ... use db ...
}
```

- `azure_resource` (required) is the application ID URI (`api://<app-id>`) or the client ID of the QuestDB app
registration; the client requests the scope `<azure_resource>/.default`.
- `azure_credential` (optional) selects the credential. In production, name it: `managed_identity`,
`workload_identity` or `environment` (a service principal from `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and
`AZURE_CLIENT_SECRET` or a certificate). A managed identity selected this way retries an unreachable endpoint, and
fails fast when the identity is not assigned to the host.
- `default`, the default, is Azure Identity's `DefaultAzureCredential`, a discovery chain meant for development: an
environment service principal, a workload identity, a managed identity, or developer tools. It checks the
managed-identity endpoint once without retrying, so an outage of that endpoint fails a startup as if the host had
no credential; the client logs a warning saying so.
- `azure_client_id` (optional) selects a user-assigned managed identity or a workload identity. It cannot be combined
with `azure_credential=environment`.
- No secret ever goes into the connect string, so it stays safe to log and to put in `QDB_CLIENT_CONF`.
- `token_provider` requires `wss::`, and cannot be combined with `token`, `username`/`password` or an
application-supplied provider.
- Every sender, query client and pooled connection built from equivalent connect strings shares one provider per
process: one refresher thread, one call to Entra at a time.

On the server, validate tokens locally (`acl.oidc.groups.encoded.in.token=true`, `acl.oidc.groups.claim=roles`,
`acl.oidc.sub.claim=oid`), set the QuestDB app registration to issue v2 tokens, and map its app roles to groups with
`CREATE GROUP ... WITH EXTERNAL ALIAS '<role>'`.

**Any other identity platform.** Wrap a `TokenSource` in a `RefreshingTokenProvider` and pass the provider to
`QuestDB.connect(config, provider)`, `httpTokenProvider(...)` or `withBearerTokenProvider(...)`. The application owns
the provider: close it after the clients that use it.

```java
import io.questdb.client.cutlass.auth.ExpiringToken;
import io.questdb.client.cutlass.auth.RefreshingTokenProvider;
import io.questdb.client.cutlass.auth.TokenUnavailableException;

RefreshingTokenProvider tokens = RefreshingTokenProvider.builder(() -> {
MyToken t = myIdentityPlatform.requestToken(); // runs on the provider's own thread
return new ExpiringToken(t.value(), t.expiresAtEpochMillis());
}).build();
try (QuestDB db = QuestDB.connect("wss::addr=qdb1:9000;", tokens)) {
// ... use db ...
} finally {
tokens.close();
}
```

A source reports a failure by throwing `TokenUnavailableException.retryable(...)` (network trouble, throttling,
5xx) or `TokenUnavailableException.permanent(...)` (missing or wrong configuration); anything else counts as
retryable. Never put a token or a raw response body in the message. The provider keeps retrying with backoff and
keeps serving the current token while it is still valid.

The provider hands a token out only while it has more than 60 s left (`handoutFloorMillis`, the hand-out floor). A
source whose token arrives with 60 s or less left gets a failed fetch whose error names `handout_floor`. A token that
arrives with less than 90 s left is handed out, but it cannot be refreshed before it enters the floor, because
refreshes are at least 30 s apart (`minRefreshIntervalMillis`): callers wait for a fetch whenever such a token runs
out, and the provider logs a warning once. If your platform issues tokens that short, lower the floor with
`handoutFloorMillis(...)`.

How failures are handled:

- The provider refreshes a token proactively, usually at about half its lifetime, and starts earlier for shorter
hand-out windows where the minimum refresh interval permits. Uninterrupted availability requires the refresh to
finish before the current token enters the hand-out floor. Even a successful fetch can leave callers waiting:
with defaults, a 100 s token starts refreshing at +30 s and stops being handed out at +40 s, leaving only 10 s
for the refresh to complete.
- When the server rejects a token with `401`, the client asks the provider for a new one and retries the same server
once, immediately.
- At startup, `initial_connect_retry=off` (the default) fails if no token can be obtained; `on` keeps retrying a
retryable provider failure within `reconnect_max_duration_millis`; `async` retries in the background.
- Once connected, a store-and-forward sender rides out credential outages and `401`/`403` indefinitely, buffering
rows and reporting each failure to the error handler and in its connection health. It never fails on its own: an
application that prefers a hard failure watches the health or the error handler, and closes the sender itself.

### Connection Health

A sender that rides out an outage keeps accepting rows, so check its connection health to see that it is not
reaching the server. Reading health never blocks and never contains a credential, so it can back a health endpoint.

```java
ConnectionHealth.Aggregate health = db.health(); // every pooled sender and query client
if (health.count(ConnectionHealth.State.RECONNECTING) > 0) {
long since = health.getOldestOutageSinceEpochMillis();
ConnectionHealth.Failure failure = health.getLastFailure(); // e.g. AUTH_REJECTED 401, CREDENTIAL_UNAVAILABLE
// ...
}
```

`Sender.health()` and `QwpQueryClient.health()` return the same information for a single client: its state
(`CONNECTING`, `CONNECTED`, `RECONNECTING`, `FAILED`, `CLOSED`), the last successful connect, the start of the current
outage, the number of failed connect rounds since, and the last failure with its class.

### Explicit Timestamps

```java
Expand Down Expand Up @@ -529,6 +642,10 @@ schema::key1=value1;key2=value2;
| `tls_roots_password` | | Optional JKS/PKCS#12 password; omit when `tls_roots` is PEM |
| `connect_timeout` | _(OS)_ | TCP connect + TLS handshake timeout, in milliseconds |
| `auth_timeout_ms` | `15000` | Authentication/upgrade request timeout, in milliseconds |
| `token_provider` | | Refreshing bearer-token provider, `wss` only: `azure` (needs `questdb-client-azure`) |
| `azure_resource` | | `token_provider=azure`: application ID URI or client ID of the QuestDB app registration |
| `azure_client_id` | | `token_provider=azure`: client ID of a user-assigned managed identity or workload identity |
| `azure_credential` | `default` | `token_provider=azure`: `default`, `managed_identity`, `workload_identity` or `environment` |

### Pool keys (facade only)

Expand Down
Loading
Loading