|
| 1 | +# QWP client startup/failover — ergonomics issues |
| 2 | + |
| 3 | +Tracked sharp edges surfaced while reviewing |
| 4 | +[`qwp-client-startup-failover-behavior.md`](./qwp-client-startup-failover-behavior.md). |
| 5 | +Each entry is grounded in source. "Candidate" = likely defect worth changing; |
| 6 | +"Intended (revisit)" = deliberate contract that may still deserve reconsideration. |
| 7 | + |
| 8 | +Severity legend: **P1** user-visible footgun likely to cause an outage or hang · |
| 9 | +**P2** confusing/surprising but recoverable · **P3** polish. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## ERG-1 — `initial_connect_retry` is implicitly promoted to SYNC (P1, Candidate) |
| 14 | + |
| 15 | +**Symptom.** A user sets `reconnect_max_duration_millis` for resilience and, |
| 16 | +without setting `initial_connect_retry`, their application now **blocks** on |
| 17 | +startup for the entire budget when the server is down. |
| 18 | + |
| 19 | +**Source.** `Sender.java` (~line 1451): if `initialConnectMode == null` and any |
| 20 | +`reconnect_*` knob is set, the mode resolves to `SYNC`. |
| 21 | + |
| 22 | +**Why it's bad.** Mode is inferred from an unrelated knob. The "make me more |
| 23 | +resilient" action produces a "hang my boot" side effect. The code comment itself |
| 24 | +acknowledges the knob "reads as a generic retry budget but the underlying path |
| 25 | +only governs reconnects." |
| 26 | + |
| 27 | +**Proposed fix.** |
| 28 | +- Make initial-connect mode an explicit, independent choice; stop inferring it. |
| 29 | +- If inference must stay for back-compat, log a `WARN` when a `reconnect_*` knob |
| 30 | + flips startup to `SYNC`, naming the knob and the resulting blocking behavior. |
| 31 | + |
| 32 | +**Acceptance.** With only `reconnect_max_duration_millis` set and the server |
| 33 | +down, `build()` either returns promptly (OFF default) or logs an explicit |
| 34 | +warning before blocking. A test asserts the warning / non-blocking default. |
| 35 | + |
| 36 | +--- |
| 37 | + |
| 38 | +## ERG-2 — `reconnect_max_duration_millis`: misleading name + inconsistent `0` (P2, Candidate) |
| 39 | + |
| 40 | +**Symptom.** Two confusions: |
| 41 | +1. The name implies "reconnect only" but it also bounds the **initial** connect |
| 42 | + in SYNC/ASYNC modes. |
| 43 | +2. `reconnect_max_duration_millis=0` means **give up immediately**, whereas |
| 44 | + `idle_timeout_ms=0` and `max_lifetime_ms=0` in the same config surface mean |
| 45 | + **infinite**. There is no infinite-retry mode at all. |
| 46 | + |
| 47 | +**Source.** `CursorWebSocketSendLoop.java:827` — `deadlineNanos = start + dur*1e6`, |
| 48 | +loop `while (now < deadline)`; `0` ⇒ zero iterations. Contrast |
| 49 | +`QuestDBBuilder.idleTimeoutMillis/maxLifetimeMillis` (`0 ⇒ Long.MAX_VALUE`). |
| 50 | + |
| 51 | +**Why it's bad.** Same `0` token, opposite semantics depending on the knob; |
| 52 | +tolerating a long maintenance window forces magic numbers like `86400000`. |
| 53 | + |
| 54 | +**Proposed fix.** |
| 55 | +- Adopt one `0` convention. Recommended: `0 ⇒ infinite`, matching the pool |
| 56 | + knobs, which also gives a real infinite-retry mode. |
| 57 | +- Consider an alias `connect_retry_budget_ms` that reflects it covers initial + |
| 58 | + reconnect; keep the old key as a deprecated alias. |
| 59 | + |
| 60 | +**Acceptance.** Documented, consistent `0` semantics across the config surface; |
| 61 | +test covering `=0` behavior and (if added) infinite mode. |
| 62 | + |
| 63 | +--- |
| 64 | + |
| 65 | +## ERG-3 — `failover` does not cover startup; queries have no async connect (P2, Candidate) |
| 66 | + |
| 67 | +**Symptom.** Users expect `failover=on` to make startup resilient. It does not — |
| 68 | +it only governs reconnect+replay during `execute()` after a connection exists. |
| 69 | +Query initial connect is always synchronous and blocking, with no async/lazy |
| 70 | +mode (unlike ingest). |
| 71 | + |
| 72 | +**Source.** `QwpQueryClient.connect()` is synchronous; `failover_*` defaults at |
| 73 | +`QwpQueryClient.java:139-141`; spec "Query client behavior". |
| 74 | + |
| 75 | +**Why it's bad.** Expectation mismatch on a safety-critical knob; asymmetry |
| 76 | +between ingest (3 modes) and query (1 mode) forces two mental models. |
| 77 | + |
| 78 | +**Proposed fix.** |
| 79 | +- Document `failover`'s scope prominently (done in the rewrite). |
| 80 | +- Evaluate an async/lazy initial-connect mode for the query client to match |
| 81 | + ingest, or a unified `initial_connect` setting shared by both sides. |
| 82 | + |
| 83 | +**Acceptance.** Either query supports a documented non-blocking initial-connect |
| 84 | +mode, or the docs make the scope unambiguous and the limitation is explicitly |
| 85 | +accepted. |
| 86 | + |
| 87 | +--- |
| 88 | + |
| 89 | +## ERG-4 — No first-class write-only facade (P2, Candidate) |
| 90 | + |
| 91 | +**Symptom.** A write-only user of `QuestDB` must still supply a query config they |
| 92 | +never use **and** remember `query_pool_min=0` to avoid a build-time query |
| 93 | +connection. |
| 94 | + |
| 95 | +**Source.** `QuestDBBuilder.build()` hard-requires both `ingestConfig` and |
| 96 | +`queryConfig`; no write-only path. |
| 97 | + |
| 98 | +**Why it's bad.** Leaky and error-prone; the doc's own recommendation is "prefer |
| 99 | +direct `Sender`," which is an admission the facade is awkward here. |
| 100 | + |
| 101 | +**Proposed fix.** |
| 102 | +- Add `QuestDB.builder().ingestConfig(...).writeOnly()` (or a `writeOnly()` |
| 103 | + shortcut) that skips the query pool entirely. |
| 104 | +- Symmetric `readOnly()` is a natural follow-up. |
| 105 | + |
| 106 | +**Acceptance.** A write-only facade builds with no query config and creates no |
| 107 | +query pool; documented and tested. |
| 108 | + |
| 109 | +--- |
| 110 | + |
| 111 | +## ERG-5 — A single endpoint's `401`/`403` aborts the whole walk (P2, Intended, revisit) |
| 112 | + |
| 113 | +**Symptom.** One misconfigured endpoint returning `401`/`403` blocks startup |
| 114 | +even when other listed endpoints would accept the credentials. Applies to both |
| 115 | +ingest and query walks, including at startup. |
| 116 | + |
| 117 | +**Source.** Ingest/query endpoint matrices; `CursorWebSocketSendLoop` treats |
| 118 | +`QwpAuthFailedException` as terminal across all endpoints. |
| 119 | + |
| 120 | +**Why it's debatable.** "Fail fast on bad credentials" is reasonable, but it is |
| 121 | +asymmetric with how every *transport* failure is tolerated, and surprising |
| 122 | +during rolling credential rotation or a single bad node. |
| 123 | + |
| 124 | +**Proposed fix (revisit).** |
| 125 | +- Keep terminal-on-auth as the contract, but make it a deliberately documented |
| 126 | + contract (done in the rewrite). |
| 127 | +- Consider an opt-in (e.g. `auth_failure=continue`) that demotes auth failure to |
| 128 | + a per-endpoint skip for heterogeneous fleets. |
| 129 | + |
| 130 | +**Acceptance.** Behavior documented as intentional; decision recorded on whether |
| 131 | +an opt-in continue mode is warranted. |
| 132 | + |
| 133 | +--- |
| 134 | + |
| 135 | +## ERG-6 — Facade can't reach error handler / connection listener / serverInfoTimeout (P2, Candidate) |
| 136 | + |
| 137 | +**Symptom.** Through the `QuestDB` facade you cannot install a |
| 138 | +`SenderErrorHandler` or `SenderConnectionListener` (ingest), nor set |
| 139 | +`serverInfoTimeoutMs` (query). The latter has no config key at all. |
| 140 | + |
| 141 | +**Source.** `LineSenderBuilder.errorHandler()/connectionListener()` exist only on |
| 142 | +the direct sender builder; `serverInfoTimeoutMs` is a `QwpQueryClient` builder |
| 143 | +field with no `ConfigSchema` key (`ConfigSchema.java` EGRESS section). |
| 144 | + |
| 145 | +**Why it's bad.** The facade is the recommended high-level entry point, yet it |
| 146 | +cannot configure observability hooks or a documented query timeout. |
| 147 | + |
| 148 | +**Proposed fix.** |
| 149 | +- Expose ingest error handler / connection listener on `QuestDBBuilder` |
| 150 | + (per-pool or shared). |
| 151 | +- Add a `server_info_timeout_ms` config key so it is reachable from any conn |
| 152 | + string (and therefore the facade). |
| 153 | + |
| 154 | +**Acceptance.** Both hooks and the timeout are reachable from the facade; |
| 155 | +documented in the knob-availability matrix. |
| 156 | + |
| 157 | +--- |
| 158 | + |
| 159 | +## ERG-7 — Simplest API has the worst error visibility (P1, Candidate) |
| 160 | + |
| 161 | +**Symptom.** `Sender.fromConfig(cfg)` with `initial_connect_retry=async` swallows |
| 162 | +terminal startup failures — they surface only on a later producer call or at |
| 163 | +`close()`. The visible path requires switching to `Sender.builder(...)` and |
| 164 | +installing a handler. |
| 165 | + |
| 166 | +**Source.** Async terminal `SenderError` delivered to a configured |
| 167 | +`SenderErrorHandler`; "even without a handler they are surfaced by later producer |
| 168 | +calls or close-time safety net behavior." |
| 169 | + |
| 170 | +**Why it's bad.** The nicest ergonomics and the worst observability are |
| 171 | +inversely correlated for the single most important question: "did my writer ever |
| 172 | +connect?" |
| 173 | + |
| 174 | +**Proposed fix.** |
| 175 | +- Default to a sane error sink (e.g. `WARN`/`ERROR` log on terminal async |
| 176 | + failure) even without a registered handler. |
| 177 | +- Provide a lightweight status accessor (e.g. `wasEverConnected()` / |
| 178 | + `lastError()`) on the public `Sender` surface for poll-based checks. |
| 179 | + |
| 180 | +**Acceptance.** A terminal async failure is observable without installing a |
| 181 | +custom handler; documented and tested. |
| 182 | + |
| 183 | +--- |
| 184 | + |
| 185 | +## ERG-8 — No client-side TCP connect timeout (P2, Intended, revisit) |
| 186 | + |
| 187 | +**Symptom.** A black-holed host in the `addr` list blocks the endpoint walk |
| 188 | +until the OS connect timeout, undercutting the resilience value of listing |
| 189 | +multiple endpoints. |
| 190 | + |
| 191 | +**Source.** `auth_timeout_ms` bounds only the post-connect upgrade/auth phase; |
| 192 | +no separate application-level TCP connect timeout in the transport. |
| 193 | + |
| 194 | +**Why it's debatable.** It is a transport limitation, but it directly defeats the |
| 195 | +multi-endpoint failover use case at startup. |
| 196 | + |
| 197 | +**Proposed fix (revisit).** |
| 198 | +- Add a client-side connect timeout so the walk can abandon black-holed hosts |
| 199 | + and proceed to the next endpoint. |
| 200 | + |
| 201 | +**Acceptance.** A black-holed first endpoint no longer blocks past a configurable |
| 202 | +bound before the walk advances; documented and tested. |
| 203 | + |
| 204 | +--- |
| 205 | + |
| 206 | +## Suggested sequencing |
| 207 | + |
| 208 | +1. **ERG-1** and **ERG-7** (both P1) — they cause hangs and silent failures. |
| 209 | +2. **ERG-2**, **ERG-4**, **ERG-6** (P2 Candidate) — naming/consistency and |
| 210 | + facade completeness. |
| 211 | +3. **ERG-3**, **ERG-8** (P2, need design) — async query connect and connect |
| 212 | + timeout. |
| 213 | +4. **ERG-5** — confirm/record the auth-terminal contract; opt-in continue mode |
| 214 | + only if a concrete fleet use case justifies it. |
0 commit comments