fix(backend): make the duckdb boot probe advisory, bounded, and honest - #193
Conversation
|
@schutera — senior-review + full local test sweep of this PR. Verdict: mergeable, but one real availability regression and a stale doc the PR touches should be fixed first. The core fix is sound and small; the Test results — every layer I can run locally is green
Findings (ranked, independently verified against the files — not just the diff)P1 — should fix before merge1. So 2. 3. Stale doc the PR touches. 4. Zero tests for the new logic. The analogous P2 — nits / defer5. 6. The chapter-11 incident this warning-pattern guards against was actually triggered by Genuinely fine — don't change
Manual test plan (what local tests can't cover — needs the Docker stack)A — the fix: no stale "not reachable" after a normal boot docker compose up --build -d
curl.exe http://localhost:3002/api/health
curl.exe -s -H "X-Admin-Key: hf_dev_key_2026" "http://localhost:3002/api/admin/logs?service=backend" | Select-String "DuckDB service"Expect B — the new unset-URL warning fires docker compose run --rm -e DUCKDB_SERVICE_URL= --service-ports backend node dist/server.jsExpect C — confirm the P1 delay (optional) docker compose stop duckdb-service; docker compose restart backend
(Invoke-WebRequest http://localhost:3002/api/health -UseBasicParsing).StatusCode # ~4.5s stall, then 200
docker compose start duckdb-serviceBottom lineHappy-path is correct (hence green CI), but #1 (listen first, probe in background) and #3 (fix the doc) shouldn't merge as-is. #2 and #4 are strongly recommended alongside. Full local sweep above is green. 🤖 Review generated with Claude Code (senior-reviewer + local test sweep). |
…B_SERVICE_URL The API and duckdb-service start together (pm2/compose), so the one-shot health probe in bootstrap() races the service binding its port. On every restart it logs '⚠ DuckDB service not reachable', which the in-memory log ring (#171) then keeps at the top of the admin server-logs panel for the whole process lifetime — looking like an outage when the service is fine. (Prod: API + duckdb-service started 1s apart; duckdb bound :8000 just after the probe -> ECONNREFUSED, logged once, non-fatal.) bootstrap(): retry duckdbHealth() up to 10x500ms before warning; still serves regardless (advisory). duckdbClient: keep the 8002 default (documented docker host-port mapping 8002:8000) but expose duckdbUrlFromDefault so server.ts warns when DUCKDB_SERVICE_URL is unset, mirroring the existing PORT-unset warning. Trims/treats blank as unset. Type-checked clean (tsc --noEmit) for both changed files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9690438 to
9818abf
Compare
Addresses the PR #193 senior review (P1 x3 + P2). 1. app.listen now runs BEFORE the boot health probe. The retry loop sat in front of the bind, so with duckdb down every route -- /api/health included -- connection-refused for the whole ~4.5s loop. The probe is advisory ("we serve regardless"), so blocking on it traded a cosmetic log problem for a real availability regression. probeDuckdbHealth() is now fire-and-forget after listen(). 2. duckdbHealth() takes an AbortSignal.timeout, matching the 15s ceiling every other DUCKDB_URL hop in app.ts already uses. Against a host that accepts TCP but never answers, the bare fetch blocked forever, so the loop never advanced and the backend never came up at all. The boot probe passes a shorter 2s so 10 attempts cap at ~25s, not 150s. 3. Resolution logic extracted to the pure resolveDuckdbUrl(envValue), mirroring port.ts's resolvePort, with 7 tests. The blank-env fix this PR carries -- DUCKDB_SERVICE_URL= no longer becoming the literal fetch base -- was previously unpinned. 4. duckdbHealth() now rejects a 200 carrying ok:false instead of logging it as "reachable" -- the same false-green this PR removes. Defensive; duckdb-service routes/health.py hardcodes ok=True today. 5. docs/05-building-block-view/backend.md claimed "exponential backoff" and "does not block". Both were wrong after the first commit: constant 500ms, and it did block. Now accurate, and non-blocking is true. Verified: backend 223/223 vitest, tsc clean. Against a refused port the API answers /api/health in 5ms and warns 4.5s later; against a blackhole listener it answers in 5ms and the probe exhausts at ~24.6s with TimeoutError instead of hanging forever. Co-Authored-By: WOZCODE <contact@withwoz.com>
Round-2 review of #193. Every finding verified against the code first. P1 fixes: - duckdbClient's timeout comment asserted an invariant that does not exist ("every other DUCKDB_URL hop in app.ts uses 15s, so no fetch in the proxy chain is unbounded"). Only 3 of 17 fetches in app.ts are bounded, and database.ts's fetchJsonOk -- the read-model hot path, four hops -- is not. Comment now states the truth and points at the tracked issue (#223) instead of implying coverage. - probeDuckdbHealth moved out of server.ts into duckdbBootProbe.ts. server.ts calls bootstrap() at module scope, so nothing in it can be imported by a test; port.ts exists for exactly this reason. The first round applied that convention to the trivial helper and skipped it for the risky one, leaving the boot path with zero coverage. - Retry budget is now a 15s wall-clock deadline, not 10 attempts. The real failure mode is a refused port where each attempt costs ~1ms, so the attempt cap was really 9x500ms = 4.5s -- under half the 10s start_period duckdb-service's own healthcheck budgets. - resolveDuckdbUrl now validates. It promised warn-on-misconfiguration but only checked emptiness, so DUCKDB_SERVICE_URL=duckdb-service:8000 (scheme omitted) passed clean and died with an opaque Invalid URL at every hop. Note it PARSES -- protocol 'duckdb-service:' -- so the http(s) check, not a bare new URL(), is what catches it. - docker-compose.yml's backend now gates on duckdb-service being healthy. docker-compose.prod.yml's backend and this file's own image-service already did; backend was the sole omission. The in-process retry stays for the PM2 host, which has no orchestrator. - backend.md said the API and duckdb "start together (pm2/compose)". False for prod compose, which gates on service_healthy. It is the PM2 path that has no ordering; the doc now says so instead of steering readers away from the declarative fix. - production-runbook.md's ecosystem.config.js template set only NODE_ENV and PORT. That file is gitignored, so the runbook IS its source of truth, and omitting a service URL there is a documented outage (ch11, "Admin failed to load images"). Both URLs added, with the --update-env caveat. - Chapter 11 lesson added: an advisory probe must not gate app.listen, a bare fetch() has no timeout, and a retry budget in attempts is not a budget when failures are instant. P2 fixes: dead 15s default removed (timeoutMs now required); ref:false on the retry timer so it can never hold shutdown; corrected a comment claiming listen() binds before the probe starts (it does not -- the ordering works via the fetch yielding to I/O, not via the bind); log ring described as evicted at 2000 entries rather than "for the whole process lifetime"; and the read-through section's pre-existing drift fixed (four fan-out endpoints, not three; the cache it said did not exist is ASSEMBLE_CACHE_TTL_MS). Tests: 26 new (8 boot-probe with an injected clock, 7 duckdbHealth with stubbed fetch incl. the abort and ok:false paths, 11 URL resolution). backend 242/242, homepage 191/191, duckdb-service 232/232, image-service 98/98, tsc clean, all four repo gates green. CLAUDE.md's per-service test counts were stale across the board (backend said 17); refreshed from measured runs. ESP native not run locally -- platformio is not installed on this box; CI covers it and this diff does not touch ESP32-CAM. Co-Authored-By: WOZCODE <contact@withwoz.com>
…ng figures Round-3 review of #193. The previous round put a wrong number into chapter 11 and shipped a test suite that could not see the failure shape it was written for; both are corrected here. Correctness: - The startup warning said "DUCKDB_SERVICE_URL unset" for values that are SET but unusable, because resolveDuckdbUrl collapsed unset/blank/malformed into one boolean. An operator who typos duckdb-service:8000 was told to go look at a variable they could plainly see set. That is the same misleading boot warning this PR exists to delete, reintroduced one file over. Now a discriminated 'ok' | 'unset' | 'malformed' reason, and the warning quotes the rejected value. Verified all three paths against a running backend. - After a failed probe the backend now looks once more at 60s and logs either a recovery line or an explicit terminal "still unreachable". Without it the boot WARN stood uncorrected in the admin panel until 2000 newer entries evicted it -- the previous round documented that as permanent rather than fixing it, i.e. shipped a mitigation described as a fix. Verified against a duckdb that comes up 20s late: warning at t=15s, recovery line at t=60s. - The deadline is now a true ceiling; the final attempt's timeout is clamped to the remaining budget. Previously an attempt could start at deadline-e and run a further 2s past a stated 15s budget. - resolveDuckdbUrl strips trailing slashes. http://duckdb-service:8000/ built //health, //modules on every hop -- survived only because Werkzeug and nginx merge slashes, at the cost of a redirect per request. Wrong facts corrected: - "each attempt fails in ~1 ms" was wrong and was load-bearing: it justified the whole deadline redesign. Measured: ~6ms refused on loopback, ~70ms refused across the docker bridge, and the FULL 2s timeout against a hung service. Chapter 11 now carries a table of all three plus the measured 15s-deadline outcomes (30 attempts/14806ms refused, 6 attempts hung). - Also corrected: the deadline rewrite SHORTENS the hung-service window (15s vs ~25s), which the previous text implied it lengthened. That is intended and now says so. - "Both compose files" -> all four gate the backend on service_healthy (verified by parsing each file, not by grep). Tests: the fake clock advanced only on sleep, so every attempt was free in fake time and the timeout shape -- the one AbortSignal.timeout was added for -- was untestable; deleting the timeout would have passed. The clock is now advanced by the health check, which honours the timeout it was granted, and both measured shapes are pinned. That change immediately caught a real deadline overshoot in the retryDelayMs:0 case. 14 boot-probe tests (was 8). Also: #223 now actually referenced from the code comment and ch11 (the last commit claimed this and didn't do it); duckdb healthcheck interval 15s -> 2s since backend now blocks on it (~6-15s tax on every compose up; tests/ui already used 2s); DuckdbHealthResult owned by duckdbClient; failure log reports elapsed ms, not just attempts; api-reference notes that ok:false is treated as unreachable. The Windows vitest worker flake that has now cost two review cycles is documented in troubleshooting.md -- it was in no doc anywhere. Verified: backend 249/249 (3 consecutive clean runs), tsc clean, all four repo gates green, docker compose config valid, all four compose files parse with the backend gate. ESP native not run -- platformio is not installed on this box and this diff does not touch ESP32-CAM. Co-Authored-By: WOZCODE <contact@withwoz.com>
…ings Round-4 review of #193. Two findings were reproduced before fixing. 1. The deadline clamp granted the final attempt whatever scrap of budget remained -- measured as low as 1ms, which guarantees an abort. That synthetic TimeoutError then became lastError and so the operator-facing message, overwriting the real ECONNREFUSED. Reproduced with deadlineMs:5061: granted timeouts [2000 x7, 1519, 1013, 507, 1] and a final reported error of TimeoutError. Odds scale with attempt cost -- ~1% on loopback but ~12% across the docker bridge -- so it is routine, not a corner case. A boot line blaming a timeout for a refused port is exactly the misdiagnosis this branch exists to delete. Now floors at DUCKDB_BOOT_PROBE_MIN_ATTEMPT_MS (50ms) and breaks instead. 2. The test named "treats the deadline as a true ceiling" detected nothing. Its Math.max(...seen) <= 2000 assertion is a tautology given timeoutMs:2000, and at deadlineMs:5000 an unclamped loop lands exactly on the deadline and passes. Retuned to 4000 (unclamped 4500 fails, clamped 4000 passes) and now asserts the final grant was strictly clamped. Its test-side twin of CLAUDE.md rule 5: envelope right, behaviour wrong. 3. reportDuckdbHealth moved from server.ts into duckdbBootProbe.ts with an injected logger, and its four operator-facing strings are now pinned by 7 tests. This is the gap the previous commit wrote a chapter-11 bullet about and then reproduced one function over -- and it is where the next bug was hiding: the recovery lines said "60s after boot" but fired at t~=75s, because the probe burns up to 15s of that window first and the follow-up then slept the full 60s. The previous commit message asserted a verification the code could not produce. Now the probe's elapsed time is subtracted; measured on a real late-starting duckdb, boot 21:07:30.976 -> recovery 21:08:30.970, i.e. 60.0s. A test pins probeElapsed + sleep == the advertised interval. 4. The malformed-URL warning echoed process.env.DUCKDB_SERVICE_URL verbatim into the log ring, which ADR-023 persists to disk and the admin panel renders -- against the SECURITY note in the same file's neighbour. A malformed ftp://user:pass@host takes that path. Now redacted; verified the password does not appear in the output. Doc corrections: the "PM2 starts them within a second of each other" claim appeared in three places but rests on a duckdb-service PM2 unit no doc in this repo defines -- reworded to what is knowable (an off-compose host has no orchestrator; ordering is whatever the operator arranged). The compose healthcheck comment no longer names start_interval it does not use, and no longer claims a flat ~10s to unhealthy (it is ~35s against a hung service -- the same "an attempt does not have one cost" trap). docker-compose.md now records the gate's real trade-off: with the backend gated on service_healthy, a duckdb that never goes healthy means no backend, so the admin panel you would use to diagnose it is itself unreachable. troubleshooting.md no longer hardcodes one machine's path or a test count that goes stale next PR. Verified: backend 256/256, tsc clean, four repo gates green, compose config valid. Bench-verified: refused port (warn at 14.7s, 30 attempts), late duckdb (recovery at exactly 60.0s), malformed URL (redacted), unset vs malformed warnings say different things. ESP native not run -- platformio is not installed here and this diff does not touch ESP32-CAM. Co-Authored-By: WOZCODE <contact@withwoz.com>
Round-4 review of #193. The most serious finding was self-inflicted by the previous commit. 1. A blanket `prettier --write docs/` corrupted chapters this branch has no business touching. In esp-reliability.md it turned `RTC_NOINIT` into `RTC*NOINIT` and `_successful_` into `\_successful*`: the identifier is destroyed and ungreppable -- and CLAUDE.md's bench-gotcha section tells you to grep for exactly that symbol -- while intraword `*` opens emphasis in CommonMark, so the paragraph renders as one italic run. Prettier's markdown printer normalises emphasis delimiters and cannot distinguish an underscore in a bare snake_case identifier from an emphasis delimiter; this repo's docs are full of them. Four files reverted to main byte-for- byte, `docs/**/*.md` added to .prettierignore (verified: an explicit --write on the damaged file is now a no-op), and the mechanism recorded as its own chapter-11 lesson. The .lintstagedrc.json `*.md` glob means any commit touching any doc could have done this. 2. redactUrlCredentials missed the shape most likely to reach it. Verified before fixing: `user:pass@duckdb-service:8000` (scheme omitted -- the case resolveDuckdbUrl's own docstring calls the likeliest env-file typo) was logged VERBATIM because the regex anchored on `//`, and `ftp://user:p@sswOrd@host` leaked the password tail because it split on the first `@`. Rewritten to split on the last `@` of the authority with a scheme-less branch, and shipped with the 6 tests it should have had -- a redactor nobody tested is worse than not echoing the value, because it reads as protection. 3. troubleshooting.md's new commands used `<repo>` placeholders. PowerShell 5.1 parses `<` as a redirection operator, so both blocks were parse errors -- and CLAUDE.md names this rule verbatim. Now `$repo = "..."`. The round-3 fix for a hardcoded path had made the snippet worse than the original, which at least ran. 4. probeOptions is now Omit<BootProbeOptions,'health'> -- Partial let a caller probe one endpoint and re-check another, and permitted health:undefined, which reports a TypeError to the operator as the cause of the outage. probeOptions.timeoutMs is now forwarded to the re-check instead of silently keeping the default. 5. The recovery line's reported interval is derived from the wait actually performed, not the nominal constant. With the constant, dropping recoveryDelayMs below the probe's elapsed time silently restores the "line misstates its own timing" bug that round 3 fixed. 6. Test fixtures said /data/hive.duckdb; the real DB_PATH is /data/app.duckdb (duckdb-service/db/connection.py), which is also what api-reference.md documents. CLAUDE.md rule 3: a guessed fixture is a smell. Verified: backend 262/262, tsc clean, four repo gates green, compose config valid, and the four reverted docs confirmed byte-identical to main. ESP native not run -- platformio is not installed here and this diff does not touch ESP32-CAM. Co-Authored-By: WOZCODE <contact@withwoz.com>
Round-5 review of #193. The headline finding is one this branch introduced: the previous commit added a credential redactor and wired it only to the MALFORMED branch. A well-formed `DUCKDB_SERVICE_URL=http://user:pass@duckdb-service:8000` is `reason: 'ok'`, so it never touched the redactor and the password went verbatim into the ring that ADR-023 persists to disk and the admin panel renders. Basic-auth is an ordinary thing to configure, and the line fires on exactly the failure an operator pastes into an issue. Reproduced, and worse than reported: the password appeared TWICE, because undici embeds the full URL in its own error text ("Request cannot be constructed from a URL that includes credentials: http://user:pass@host/health"), so redacting only the URL field would still have leaked it one field over. Fixed at the source rather than per call site -- the per-site "did I remember to redact this one" decision is what failed: - duckdbClient exports DUCKDB_URL_SAFE, and server.ts no longer imports DUCKDB_URL at all, so a log statement cannot reach the raw value. - The error string is redacted too, not just the URL. - redactUrlCredentials -> redactCredentials, now operating on arbitrary text and covering the shapes that still leaked: leading whitespace (server.ts passed the UNTRIMMED env var while the ^-anchored scheme regex needed a trimmed one), protocol-relative `//user:pass@host`, and the single-slash typo `http:/user:pass@host` that WHATWG normalises into a valid URL. 13 tests, including an all-shapes sweep asserting the password never survives. Verified on a running backend: 0 occurrences. Also from this round: - MIN_ATTEMPT_MS 50 -> 250. A 50ms floor still left the window remaining in [50,70) where a ~70ms docker-bridge attempt aborts early and its synthetic TimeoutError masks the real ECONNREFUSED -- the floor only helps if it exceeds an attempt's actual cost. Costs nothing: the retry-delay guard already stops the loop below ~500ms remaining. - The chapter-11 cost table attributed "~25s" to the original `10 x 500ms` loop, but that loop had no timeout, so against a hung host it did not cost 25s -- it never finished, which is the very first failure the entry describes. Column relabelled to what it actually is. - Four docs still claimed the backend has "no caching layer" / is "stateless", including backend.md's own opening paragraph, which the previous commit contradicted 40 lines further down in the same file. Fixed in 04-solution-strategy, 06-runtime-view, ch11 and backend.md. 06-runtime-view also still listed three fan-out endpoints; it is four, and it is the chapter CLAUDE.md designates for read-flow changes. - Reverted the unrelated prettier churn in accessLog.test.ts and logStream.test.ts left over from the blanket run, so the diff is only what this PR is about. Verified: backend 269/269 across 30 files (3 consecutive clean runs; one run hit the documented Windows tinypool flake, which is why that entry exists), tsc clean, repo gates green, compose config valid. Co-Authored-By: WOZCODE <contact@withwoz.com>
…eal cause Round-6 review of #193. Both findings reproduced on Node 24 before fixing. 1. The boot warning -- this branch's entire deliverable -- printed "TypeError: fetch failed" for a refused port. Node's fetch puts the real reason on err.cause, and String(err) drops it, so a refused port was indistinguishable from a DNS typo or an expired certificate. Every doc and docstring on this branch claimed the operator sees "ECONNREFUSED"; nobody had ever seen that string. The tests could not catch it because their fixtures were `new Error('ECONNREFUSED')` -- a guessed shape, not the one undici produces. Added describeError(), which walks cause and AggregateError.errors with a seen-set. Verified on a real backend against a refused port: "TypeError: fetch failed <- Error: connect ECONNREFUSED 127.0.0.1:59999". 2. The previous commit's headline -- "redact at the source, not per log site" -- was not what shipped. It redacted the two files it was already editing while app.ts and database.ts still interpolate the raw credentialed DUCKDB_URL into ~10 error strings that land in the same disk-persisted, admin-rendered ring. An invariant asserted in a docstring and enforced in two of four files is worse than none, because the next reader trusts the docstring. The actual source is resolveDuckdbUrl, and the right fix makes the whole class disappear: undici REFUSES a credentialed URL before any network I/O ("Request cannot be constructed from a URL that includes credentials"), so such a config fails 100% of duckdb hops -- every dashboard read, not just the probe -- while the resolver happily reported it as 'ok'. It is now 'malformed': the misconfiguration is loud at boot instead of silently fatal, and DUCKDB_URL cannot carry a secret by construction, so every log site in the backend is safe without per-site redaction. DUCKDB_URL_SAFE deleted -- the guarantee replaces it. redactCredentials stays for the one place a credential can still appear: the startup warning echoing the raw rejected env value back to the operator. Its docstring no longer claims basic-auth is "ordinary to configure" -- it is instantly fatal here, and now says so. Tests: the redaction WIRING is now pinned, which is what actually broke both times -- the regex was always fine. Two reportDuckdbHealth cases feed in the literal undici messages (credential-rejection text, and fetch-failed-with- cause) and assert the password is absent and the cause present. Plus resolver rejection cases and 5 describeError cases including a cyclic chain. Also: MIN_ATTEMPT_MS 50 -> 250 changes the refused-port shape from 30 to 29 attempts; all four places quoting that number updated, with the reason. redactCredentials no longer treats a Windows path (\node_modules\@scope) as userinfo. Not done, stated plainly: the incidental prettier reformatting of accessLog.test.ts and logStream.test.ts could NOT be reverted. Those files are non-conformant on main, and lint-staged runs prettier --write on any staged file, so reverting them and committing re-applies the formatting. A previous commit message claimed this was reverted; it was not. Verified: backend 279/279 across 30 files, tsc clean, four repo gates green, compose config valid. Bench: refused port names ECONNREFUSED; credentialed URL rejected at boot with 0 password occurrences in the log. Co-Authored-By: WOZCODE <contact@withwoz.com>
…sson Round-7 review of #193. The shipped code came back clean; these close the remaining testability and documentation gaps. - The `[startup]` misconfiguration warning was the one place a credential could still reach the ring -- it echoes the raw env value back so the operator can see their typo -- and it lived inline in server.ts, which calls bootstrap() at module scope and so cannot be imported by a test. Both redaction bugs on this branch were un-pinned wiring, not a bad regex, so leaving this one on trust was the same bet a third time. Extracted to describeDuckdbUrlMisconfig() and pinned with 5 tests, including one that feeds in a password and asserts it is absent. - Chapter 11 gains the redaction lesson, which until now existed only in commit messages: a redactor is a WIRING property, unit tests on the transform cannot see wiring, and an invariant asserted in a docstring but enforced in two files out of four is worse than none. It also records the fix that actually worked -- asking whether the bad input was even legal (it was not; Node's fetch refuses it) beat three rounds of asking how to hide it. - resolveDuckdbUrl now returns the normalised origin+path instead of the raw trimmed string. A query or fragment (`http://host:8000?x=1`) was reported 'ok' and then produced `...?x=1/health` at every call site -- the exact "sails through and dies at every hop instead of once, loudly, at boot" shape the function's docstring claims to prevent. - describeError is now used in the two remaining places that still did String(err): the bootstrap catch, and the 60s re-check's failure line, which previously reported no cause at all -- so an operator could not tell that duckdb was down for a DIFFERENT reason at t=60s than at boot. Verified: backend 287/287 across 30 files, tsc clean, repo gates green, compose config valid. Bench-verified all four startup paths (credentialed, scheme-less, unset, valid): correct message each time, zero password occurrences, and no warning at all on a valid URL. Co-Authored-By: WOZCODE <contact@withwoz.com>
…json Applies the two P2s the previous review round identified but did not push. 1. pip output went to /dev/null, so the WARN it emits on failure carried no reason. The exact failure this step exists to tolerate is "no matching distribution" for a wheel that doesn't exist on the host interpreter -- which is precisely the detail that was being discarded. pip stdout+stderr now append to the deploy log next to the WARN, and the runbook says where to look with a copy-paste grep. 2. The npm ci gate listed the three workspace package.json files but not the root one, which is where the workspace LIST lives -- adding a workspace without touching the lockfile would have skipped the install. Theoretical, but its absence beside the other three read as an oversight rather than a decision. Both pip calls are now one helper instead of two near-identical blocks, and the `changed_match X && ...` form was replaced with an `if` -- the script runs under `set -euo pipefail`, and while a failing left side of && is exempt from set -e (verified), the `if` form does not depend on knowing that. Verified: `bash -n` clean; a 13-case matrix over the real regexes (in the PR discussion) confirms both npm and pip gates fire exactly when intended, including that requirements-dev.txt does NOT trigger a pip install and that one service's requirements cannot fire the other's gate. Merged current main (which carries #193) -- clean, no conflicts. Co-Authored-By: WOZCODE <contact@withwoz.com>
) * fix(deploy): install workspace npm + service pip deps in deploy.sh deploy.sh rebuilt/reloaded but never installed new deps, so a dep-adding release failed its build and auto-rolled-back. npm: it gated npm ci on backend/package-lock.json, but this is a workspaces monorepo with one ROOT lockfile, so new backend/homepage deps were missed (broke on rotating-file-stream, #178). Now a single root 'npm ci' gated on the root lockfile / any workspace package.json, before the builds; dropped the wrong per-prefix ci. pip: never ran. Now 'python3 -m pip install -r <svc>/requirements.txt' for duckdb-service/image-service when their requirements changed, into the system python3 pm2 uses; non-fatal, the post-reload health check is the real gate (graceful degradation on a missing optional dep). Also rewrites the stale production-runbook 'Updates & Redeployment' section to match reality (main branch, root npm ci, pip into system python3, all 4 pm2 apps, health checks, Python 3.10 / onnxruntime 1.23.2 note). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(deploy): align runbook Python-deps section with floated pins (ADR-029) The PR's runbook rewrite asserted onnxruntime is "pinned to 1.23.2" under a "Python 3.10 ceiling". After folding in main (#195 / ADR-029), the real requirements float numpy>=2.0.0 / onnxruntime>=1.23.2 / pydantic>=2.12.5 for a 3.10-3.14 matrix — a floor, not a pin. Rewrote the step-2 comment and the "Python 3.10 floor" paragraph to match, citing ADR-029, and noted that a pip upgrade is not reverted on rollback. Added a ch11 lessons-learned entry for the workspace-lockfile npm-ci miss this PR corrects (per CLAUDE.md's mandatory doc gate). Addresses the senior-reviewer P0/P2 findings on the PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(deploy): log why pip failed, and gate npm ci on the root package.json Applies the two P2s the previous review round identified but did not push. 1. pip output went to /dev/null, so the WARN it emits on failure carried no reason. The exact failure this step exists to tolerate is "no matching distribution" for a wheel that doesn't exist on the host interpreter -- which is precisely the detail that was being discarded. pip stdout+stderr now append to the deploy log next to the WARN, and the runbook says where to look with a copy-paste grep. 2. The npm ci gate listed the three workspace package.json files but not the root one, which is where the workspace LIST lives -- adding a workspace without touching the lockfile would have skipped the install. Theoretical, but its absence beside the other three read as an oversight rather than a decision. Both pip calls are now one helper instead of two near-identical blocks, and the `changed_match X && ...` form was replaced with an `if` -- the script runs under `set -euo pipefail`, and while a failing left side of && is exempt from set -e (verified), the `if` form does not depend on knowing that. Verified: `bash -n` clean; a 13-case matrix over the real regexes (in the PR discussion) confirms both npm and pip gates fire exactly when intended, including that requirements-dev.txt does NOT trigger a pip install and that one service's requirements cannot fire the other's gate. Merged current main (which carries #193) -- clean, no conflicts. Co-Authored-By: WOZCODE <contact@withwoz.com> * fix(deploy): make rollback dependency-aware and stop reporting degraded as OK Round-2 review of #196. Three findings, all verified against the code before fixing; the first is a latent production outage this PR would have introduced. 1. P0 -- `npm ci` DELETES node_modules before installing, and rollback() never put it back. It restores backend/dist, homepage/dist and the git tree; none of those is node_modules. Because a failed install happens BEFORE any reload, RELOADED is still empty, the untouched pm2 cluster keeps answering from modules already resident in RAM, the rollback's own health check PASSES, and it notifies "old version is running and healthy" -- while the host now has a wiped dependency tree that dies on its next pm2 restart, which max_memory_restart schedules unprompted. A transient npm error would have silently armed an outage and announced it as a clean recovery. rollback() now reinstalls from the restored lockfile and escalates to a NEEDS-A-HUMAN notification if that also fails. 2. P1 -- "the health check is the real gate" was false for exactly the dependency the non-fatal pip design was built around. image-service imports cv2/numpy/onnxruntime under a try/except (_RUNTIME_AVAILABLE) and /health is a pure liveness probe that never touches them, so a missing OPTIONAL wheel leaves health green, hole detection silently dead, and Discord reporting "Deploy OK". The graceful degradation cited as the justification is what defeats the gate. A failed pip install is now recorded and the deploy reports "Deploy DEGRADED" instead of success. 3. P1 -- the two services pinned DIFFERENT requests versions (2.32.3 vs 2.32.5) while sharing one system site-packages with no venv, so each install succeeded and the last one silently violated the other's pin. This PR installs both on every dep change, which would have made it flap on every deploy. Reconciled to 2.32.5 with a keep-in-sync note in both files. Also: HUSKY=0 on the deploy's npm ci -- the root package.json declares "prepare": "husky", so a root install on the host would set core.hooksPath and make deploy.sh's own git commit in publish_firmware fire developer pre-commit hooks; that call is unguarded, so a hook failure would kill the script mid firmware-publish and brick every later tick on the dirty-tree check. npm ci output now goes to the log too (it is the FATAL one -- "root npm ci failed" with no reason was the message an operator could least act on). The pip step logs which interpreter it installs into, turning the unverifiable "same python3 pm2 uses" assumption into a recorded fact. Docs: the runbook claimed the manual steps "mirror" the automated deploy. They do not -- the manual homepage build passes VITE_API_URL and deploy.sh does not, so the automated path depends on a gitignored homepage/.env.production on the host or ships a bundle pointing at localhost (the homepage health check only verifies the HTML loads, so it cannot detect this). Documented as a simplified hand-deploy with the three real differences, including that hidden host contract. Also corrected "four pm2 apps" -> three, and the now-false header note that nothing live is touched before reload. Chapter 11's entry gains the three generalisable traps: a rollback that doesn't restore what a step mutated has a green check measuring the wrong thing; graceful degradation and health-check gating cancel each other out; and "no venv" is a coupling, not a configuration. Verified: bash -n clean; a 5-case harness (extracting the real rollback() and running it against stubs) confirms the reinstall fires only when npm ci ran, and that a failing reinstall produces the INCOMPLETE alert rather than the old false "healthy" one; the 13-case gate matrix still passes; duckdb-service 232/232 after the requests bump; doc-citation and python-version gates green. Not runnable here: the real deploy (needs pm2/systemd//var/www/highfive). Co-Authored-By: WOZCODE <contact@withwoz.com> * fix(deploy): verify what rollback restored; add the missing shellcheck gate Round-3 review of #196. 1. rollback() probed only HEALTH_BACKEND and then announced "old version is running and healthy" -- but it is reachable from duckdb, image AND homepage health failures, where the backend was never touched and passes trivially. That is the same report-green-without-checking mistake this PR's own title is about, three lines below the code it added. It now builds the probe list from what was actually reloaded (plus the homepage when dist.old was restored) and names the failing endpoints. 2. The new escalation path exited WITHOUT reloading, while telling the operator "the cluster is still serving from memory". On the path most likely to reach it -- build OK, reload done, health failed, then the reinstall fails -- that is false twice over: the cluster is serving the failed build, and the restored artifacts were never loaded. It now reloads first and describes the actual disk/process split. 3. A lockfile-only tick (an `npm audit fix`, a transitive bump) matched neither ^backend/ nor ^homepage/, so it wiped and reinstalled every production dependency under the live cluster with NO health check at all and then reported "(no service rebuild)". It now records `npm-deps` as a real action and health-checks the backend, which is the Node consumer of that tree. 4. The rollback reinstall is now conditional on node_modules actually being gone. npm validates package.json/lockfile agreement BEFORE clearing the tree, so an abort at that stage leaves a good tree -- reinstalling anyway would have manufactured the outage the guard exists to prevent. node_modules/.package-lock.json (written on a completed install) is the signal. 5. Added a shellcheck CI job. scripts/deploy.sh is the highest-blast-radius file in the repo -- root, unattended, every 2 minutes, can reload services, reset the tree, and publish an irreversible fleet OTA -- and nothing checked it at all; `bash -n` in a PR description was the entire verification story, which is how round 1's false "health check is the real gate" assumption shipped. All six scripts/*.sh are clean at -S warning after fixing an unused loop variable here and unchecked `cd` in the check-* scripts. Doc/comment corrections: the requirements sync note claimed deploy.sh "installs both on every dep change" (it gates each service on its own file); the runbook cited ADR-028 (ML-inference-server-side) for graceful degradation when it is ADR-027; the runbook's step 4 hands the reader a pm2 reload of three apps while its own ecosystem template defines one, which is now called out inline; and the DEGRADED notification no longer offers a hole-detection example when the failure was duckdb-service. Verified: shellcheck clean over all scripts; bash -n clean; an 11-case harness that extracts the real rollback()/rollback_health_targets() and runs them against stubs -- covering wiped-vs-intact node_modules, reinstall success and failure, the reload-before-escalate ordering, and per-service probe selection; the 13-case gate matrix still passes. Not runnable here: a real deploy. Co-Authored-By: WOZCODE <contact@withwoz.com> * fix(deploy): reload the app whose dependencies changed; drop the dead guards Round-4 review of #196. Three findings, all control-flow facts rather than opinions, and all cases where a comment asserted a guarantee the code could not provide. 1. RELOADED is empty at the npm ci call site -- it is initialised at :273 and first appended at :322, after the install. So on the npm-ci-failure path, which is the ONLY path that reaches the rollback reinstall, the reload added last round iterated over nothing and the health probe fell through to its backend fallback, probing a process that was never restarted and printing "verified healthy". The escalation message likewise claimed "services reloaded" 100% of the times it fired. highfive-api is now added to RELOADED before the install runs, so both the rollback reload and the probe are real. 2. The same emptiness made the dependency-only health check unfalsifiable: it probed a Node process still holding the pre-install module graph, so it passed regardless of what was on disk. Worse, it was a real functional gap -- a lockfile-only tick (dependabot, npm audit fix) installed a new tree, reloaded nothing, and reported Deploy OK while every service still ran the old modules. Same fix; the probe now means something. A dependency change also rebuilds the homepage bundle, which otherwise kept a bumped dependency out of the shipped site until some unrelated homepage file happened to change. 3. The node_modules/.package-lock.json heuristic skipped the reinstall in the MORE common shape. NPM_CI_RAN is set before the install, not after it succeeds, so "tree intact" cannot distinguish "npm aborted pre-wipe" from "npm succeeded and a later step failed" -- and in the latter, node_modules is synced to the NEW lockfile while the tree is reset to the old one, so the old code runs against the new dependency set and it is announced as a clean restore. Three documents asserted the reinstall was unconditional. It now is; a redundant npm ci during an already-failing deploy is cheap insurance. Also from this round: - A failure marker stops the 2-minute timer retrying a known-broken SHA forever. That was merely noisy before; with a dependency wipe inside the loop it re-tears-down production's node_modules every two minutes. - reload_services no longer swallows pm2 failures. "process or namespace not found" is a real state here (the runbook's ecosystem template registers only highfive-api) and silently succeeding on it is how a deploy reports green for a service it never restarted. - The homepage build now fails the deploy if the bundle contains localhost:3002 -- the VITE_API_URL landmine documented last round was left armed, and the homepage health check cannot see it because it only verifies the HTML loads. - add_reload() dedupes, since a backend change and a dependency change both select highfive-api. - shellcheck gate tightened to -S info (SC2086 lives there) and extended to ESP32-CAM/build.sh, which produces the artifacts the fleet OTA ships. Both verified clean at that level. - docs/10-quality-requirements/ci-gates.md updated -- it still said "ten parallel jobs" while this PR made it eleven, which CLAUDE.md's own mandatory-update table required. - The requirements sync comments are scoped to the PM2 path; under Docker Compose the two services have separate site-packages and would not collide. - "Rebuilt: npm-deps" -> "Built/installed:", since nothing was rebuilt. Verified: shellcheck -S info clean over scripts/*.sh + ESP32-CAM/build.sh; bash -n clean; the 11-case rollback harness passes against the corrected contract (including the two cases whose expectations this commit deliberately inverted); the 13-case gate matrix passes; doc-citation gate clean. Co-Authored-By: WOZCODE <contact@withwoz.com> --------- Co-authored-by: Mark Schutera <mark.schutera@mailbox.org> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: cofade <wienhold@gmx.net> Co-authored-by: WOZCODE <contact@withwoz.com>
Senior review of the production-branch adoption. Two P0s, both verified with single git commands against the repo rather than read off the prose. 1. THE DOCUMENTED CUTOVER WOULD HAVE REVERTED PRODUCTION. The procedure said "git checkout production && git reset --hard origin/production" with no promotion step. But origin/production is a0e7374, four commits behind main: git show origin/production:scripts/deploy.sh | grep '^BRANCH=' -> "main" ...and 0 hits for FAILED_MARKER|NPM_CI_RAN|PIP_FAILED|add_reload| rollback_health_targets|HUSKY=0 (26 on this branch) So running it today rolls the live host back past #193, #196 and #222 -- discarding the deploy hardening and the security track-A work -- and leaves the checkout on `production` running a driver that tracks `main`. That takes the old bare `log "skip"; exit 0` path: deploys stop silently, forever, and the wrong-branch alert that would have said so was reverted away with everything else. Silent, permanent, self-concealing. The procedure is now explicitly ordered: promote, VERIFY (`git show origin/production:scripts/deploy.sh | grep '^BRANCH='` must print production), then check out. The same ordering and the same verify command are in the Discord alert body, because that is what the operator actually reads at 2am. 2. ADR-030's recorded root cause was FALSE, and it was the sole justification for force-resetting a release branch: git rev-list --max-parents=0 main -> d9ac93d (one root) git rev-list --max-parents=0 bf8b314 -> d9ac93d (the SAME root) git merge-base main bf8b314 -> da1b21d git rev-list --count main..bf8b314 -> 25 (not 136) There was no orphan root, no rebuilt history, no unrelated ancestry -- a merge was available the entire time and was rejected for tidiness. The cited #124 is a senior-reviewer config commit. Corrected in ADR-030 and in chapter 11, kept as a visible correction rather than a quiet edit, because ch11 had already generalised the false cause into a "how to avoid this next time" rule that future maintainers would have applied to a scenario that never happened. Also from this round: - "A real promotion gate" was overstated: neither branch has protection, CI runs on main only, and any fast-forwarding commit is accepted. Now says gate-by-convention, and states that production MUST stay unprotected -- protecting it would reject publish_firmware's push and ship OTAs whose SEQUENCE bump is not in git. - The ADR's one acknowledged invariant violation (publish_firmware commits to production) had "tracked as a follow-up" with nothing behind it. Filed #225. - The OTA notification -- attached to the single irreversible action in the system -- cited ADR-028 (ML inference server-side) instead of ADR-030. This is the ADR-renumber-on-collision trap the repo already documented. - The archive tag is NOT on the remote (`git ls-remote --tags origin` finds nothing), so the 25 commits survive only via a stale branch clean_gone would delete. Both docs now say so instead of claiming a recovery point exists. - CONTRIBUTING.md, which CLAUDE.md names as the authority for the branch model, said only "branch off main" and never mentioned production. It now documents the promote-don't-PR rule and the never-force-push constraint. - The branch-mismatch marker is hoisted to a BRANCH_MARKER constant next to FAILED_MARKER (it was a local var re-typed as a literal in the rm), and the marker is now written BEFORE notify -- notify can fail under set -e, which would have produced the every-two-minutes alert the marker exists to stop. Not done, stated plainly: image-service/tests/test_upload.py carries ruff format reflow. main's version is not ruff-clean, and the pre-commit hook reformats any staged .py, so it cannot be reverted without bypassing the hook. Verified: bash -n and shellcheck -S info clean; #196's 11-case rollback harness and 13-case gate matrix still pass; all five repo gates green. Co-Authored-By: WOZCODE <contact@withwoz.com>
* chore: adopt production as the gated release source (#152) Reconcile the documented services deploy source with reality and unify it with firmware OTA on a single gated `production` branch. Investigation for #152 found three stacked problems: the docs named `production` while the live auto-deploy pulled `main`; firmware OTA and the services track were documented as separate; and `main`/`production` shared no common git ancestor (main's history was rebuilt), so `production` could never fast-forward and silently rotted. Decision (per maintainer): `production` becomes the single gated release branch for both web services and firmware OTA. `main` is the integration line; a release is a fast-forward of `production` onto a chosen `main` commit. `prod-*` tags are cut on `production`. - scripts/deploy.sh: BRANCH main -> production; branch-agnostic notify text - production-deployment.md: drop drift warning; add release/promotion + one-time host cutover section - production-runbook.md: document the promote-then-pull model - firmware-release.md: rewrite the branch & tag model (both tracks on production); replace the "known drift" callout with a history note; update the release-checklist commit/tag step - chapter 11: mark the drift lesson RESOLVED; record the unrelated-history root cause and the fast-forwardable-deploy-branch rule - new ADR-028; update README/esp-flashing/CLAUDE.md pointers The branch reconciliation (archive tag + force-reset of origin/production) and the one-time prod-host checkout are operator steps documented in ADR-028 and production-deployment.md, to run after this lands on main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017drgAN84qrn61eZ1yZTdgS * docs: add "never release from main" critical rule to CLAUDE.md The production-as-gated-release-branch model (#152 / ADR-030) was only spelled out in the firmware-OTA section. Add a concise hard rule to the top-level "Critical rules (do NOT violate)" list so every session knows prod releases ship from `production`, never from `main`. Links to the full mechanics rather than duplicating them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: correct ADR-030's root cause and invert the cutover order Senior review of the production-branch adoption. Two P0s, both verified with single git commands against the repo rather than read off the prose. 1. THE DOCUMENTED CUTOVER WOULD HAVE REVERTED PRODUCTION. The procedure said "git checkout production && git reset --hard origin/production" with no promotion step. But origin/production is a0e7374, four commits behind main: git show origin/production:scripts/deploy.sh | grep '^BRANCH=' -> "main" ...and 0 hits for FAILED_MARKER|NPM_CI_RAN|PIP_FAILED|add_reload| rollback_health_targets|HUSKY=0 (26 on this branch) So running it today rolls the live host back past #193, #196 and #222 -- discarding the deploy hardening and the security track-A work -- and leaves the checkout on `production` running a driver that tracks `main`. That takes the old bare `log "skip"; exit 0` path: deploys stop silently, forever, and the wrong-branch alert that would have said so was reverted away with everything else. Silent, permanent, self-concealing. The procedure is now explicitly ordered: promote, VERIFY (`git show origin/production:scripts/deploy.sh | grep '^BRANCH='` must print production), then check out. The same ordering and the same verify command are in the Discord alert body, because that is what the operator actually reads at 2am. 2. ADR-030's recorded root cause was FALSE, and it was the sole justification for force-resetting a release branch: git rev-list --max-parents=0 main -> d9ac93d (one root) git rev-list --max-parents=0 bf8b314 -> d9ac93d (the SAME root) git merge-base main bf8b314 -> da1b21d git rev-list --count main..bf8b314 -> 25 (not 136) There was no orphan root, no rebuilt history, no unrelated ancestry -- a merge was available the entire time and was rejected for tidiness. The cited #124 is a senior-reviewer config commit. Corrected in ADR-030 and in chapter 11, kept as a visible correction rather than a quiet edit, because ch11 had already generalised the false cause into a "how to avoid this next time" rule that future maintainers would have applied to a scenario that never happened. Also from this round: - "A real promotion gate" was overstated: neither branch has protection, CI runs on main only, and any fast-forwarding commit is accepted. Now says gate-by-convention, and states that production MUST stay unprotected -- protecting it would reject publish_firmware's push and ship OTAs whose SEQUENCE bump is not in git. - The ADR's one acknowledged invariant violation (publish_firmware commits to production) had "tracked as a follow-up" with nothing behind it. Filed #225. - The OTA notification -- attached to the single irreversible action in the system -- cited ADR-028 (ML inference server-side) instead of ADR-030. This is the ADR-renumber-on-collision trap the repo already documented. - The archive tag is NOT on the remote (`git ls-remote --tags origin` finds nothing), so the 25 commits survive only via a stale branch clean_gone would delete. Both docs now say so instead of claiming a recovery point exists. - CONTRIBUTING.md, which CLAUDE.md names as the authority for the branch model, said only "branch off main" and never mentioned production. It now documents the promote-don't-PR rule and the never-force-push constraint. - The branch-mismatch marker is hoisted to a BRANCH_MARKER constant next to FAILED_MARKER (it was a local var re-typed as a literal in the rm), and the marker is now written BEFORE notify -- notify can fail under set -e, which would have produced the every-two-minutes alert the marker exists to stop. Not done, stated plainly: image-service/tests/test_upload.py carries ruff format reflow. main's version is not ruff-clean, and the pre-commit hook reformats any staged .py, so it cannot be reverted without bypassing the hook. Verified: bash -n and shellcheck -S info clean; #196's 11-case rollback harness and 13-case gate matrix still pass; all five repo gates green. Co-Authored-By: WOZCODE <contact@withwoz.com> * docs: retract the history claim in the runbook too, and price the cutover honestly Round-2 review. The pattern in the finding is the same one this PR is about. 1. The false "shared no common ancestor" claim was fixed in ADR-030 and chapter 11 and LEFT STANDING in docs/07-deployment-view/firmware-release.md -- so the branch shipped three documents describing one event, two of which called the third a fabrication. The survivor was the runbook: the file someone opens WHILE cutting a release, while the retractions sat in an ADR and a tech-debt log nobody reads mid-release. Fixed, with the retraction visible there too. Swept the tree afterwards; the only remaining matches are inside the correction blocks that quote the claim in order to retract it. 2. "verified stale" did not survive its own standard. The ADR had just spent twenty lines explaining why unverified assertions here are dangerous, and then rested the whole justification for discarding 25 commits on an enumeration that omitted four test files and never mentioned the ESP work. Re-derived from the repo, and one finding is worth the trouble: the archived TIP (bf8b314, "use esp_task_wdt_reconfigure and defer loopTask subscribe past AP setup") uses an API that appears NOWHERE on main -- `git grep esp_task_wdt_reconfigure origin/main -- ESP32-CAM/` is empty, and main still uses the IDF-4 esp_task_wdt_init/add pair. Main fixes the same AP-mode reboot loop a different way (>=60s TASK_WDT_TIMEOUT_S plus runAccessPoint feeding the watchdog, recorded as fixed in troubleshooting.md), so nothing live is lost -- but "already exists on main" was the wrong description, and the ADR now says what was actually checked. 3. The OTA notification told the operator to run `git checkout main` with no statement of WHERE. It arrives while they are looking at the host, and the commands run there -- where `git checkout main` immediately trips the branch-mismatch guard this same PR adds and pauses every deploy. Now says "FROM A MAINTAINER CLONE, NOT THIS HOST" and explains the consequence. 4. The reordered cutover fixed the direction but not the rebuild. `git reset --hard` restores the source tree only; backend/dist, homepage/dist and node_modules stay at whatever the host last built, and no later tick repairs that because deploy.sh exits at `[ "$PREV_SHA" = "$REMOTE_SHA" ] && exit 0`. Step 3's own health checks pass in that skewed state. Added an explicit rebuild, and said when it can be skipped. 5. docs/02-constraints/README.md -- which CLAUDE.md's critical-rules section names as the full list -- had no production-branch rule at all. This PR found and fixed exactly that gap in CONTRIBUTING.md and left the file CLAUDE.md points at. Added, including the never-force-push and must-stay-unprotected constraints. P2s: the ADR said CI "runs on main only" (it triggers on main push+PR and never on production -- same conclusion, wrong sentence); the Decision section described the OTA publish as unconditional when the whole block is gated behind FIRMWARE_AUTO_OTA=1; and chapter 11's "how to avoid" paragraph still led with the history-rewrite scenario the correction above it calls fictional -- it now leads with the real rule (no promotion mechanism, no staleness signal). Verified: bash -n and shellcheck -S info clean, the 11-case rollback harness passes, all repo gates green. Co-Authored-By: WOZCODE <contact@withwoz.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: WOZCODE <contact@withwoz.com>
Symptom
The admin server-logs panel shows, seemingly as a live outage:
…even though
duckdb-serviceis up and/healthreturns200.Root cause — a startup race, not an outage
bootstrap()probedduckdbHealth()once, immediately at boot. On a host with no orchestrator the probe can fire before duckdb finishes binding its port →ECONNREFUSED. It was caught and non-fatal, but the #171 log ring retains boot lines, so a single stale warning sat at the top of the admin panel for the whole process lifetime.Verified on prod:
highfive-apiandduckdb-servicestarted 1 s apart;/healthwas200throughout.What ships
1.
app.listenhappens first. The first attempt at this put a retry loop in front of the bind, so with duckdb down every route —/api/healthincluded — connection-refused for ~4.5 s. A cosmetic log problem had been traded for a real availability regression. The probe is advisory and is now fire-and-forget after the bind.2. Every probe fetch is bounded.
duckdbHealth()used a barefetch(), which in Node has no default timeout. Against a host that accepts TCP but never answers, the first attempt never settled — the loop never advanced and the backend never came up at all. Reproduced with a blackhole listener.3. The retry budget is a wall-clock deadline, not an attempt count. An attempt does not have one cost: measured at ~6 ms (refused, loopback), ~70 ms (refused, docker bridge) and the full 2 s timeout (hung). So
10 × 500 msmeant 4.5 s in one shape and 25 s in another. Now a 15 s deadline, with each attempt clamped to the remaining budget and never started with less than 250 ms — a shorter clamp produced a syntheticTimeoutErrorthat overwrote the realECONNREFUSEDin the operator's log line.4. The warning now says what actually happened.
String(err)on a fetch failure yields the uselessTypeError: fetch failed; the real reason lives onerr.cause.describeError()walks the cause chain, so a refused port is now distinguishable from a DNS typo:5. A stale warning gets corrected. If the probe gives up, the backend re-checks once — beginning exactly 60 s after boot (the probe's own elapsed time is subtracted) — and logs a recovery line or an explicit terminal "still unreachable". Without it the scary line stood uncorrected until 2000 newer entries evicted it, which is mitigation dressed as a fix.
6.
DUCKDB_SERVICE_URLis validated, and the warning names the real mistake. Blank, non-http(s), scheme-less, and query/fragment-bearing values are rejected instead of dying with an opaque error at every hop. Crucially, a URL carrying credentials is now rejected too: Node's fetch refuses those before any I/O, so such a config broke 100% of duckdb hops while the resolver reported it as fine. Rejecting it also makesDUCKDB_URLcredential-free by construction, which is what keeps passwords out of the disk-persisted, admin-rendered log ring — three earlier attempts to redact per-log-site all leaked.7. Compose gates declaratively.
docker-compose.yml'sbackendnow hasdepends_on: duckdb-service: {condition: service_healthy}— the other three compose stacks already did; dev's backend was the sole omission. The healthcheck drops tointerval: 2ssince two services now block on it. The in-process retry remains for the off-compose PM2 path.Tests
+70 backend tests (217 → 287). The retry loop, the abort behaviour, the URL resolution, the credential handling and — deliberately — the operator-facing strings are all pinned. The boot-probe suite uses an injected clock advanced by the health call itself, so a regression deleting the per-attempt timeout fails the suite; an earlier version advanced time only on
sleepand could not see that shape at all.Docs
docs/05-building-block-view/backend.md,docs/06-runtime-view/README.md,docs/07-deployment-view/docker-compose.md,docs/07-deployment-view/production-runbook.md(the PM2 ecosystem template was missing both service URLs — a documented outage shape),docs/api-reference.md,docs/troubleshooting.md, and three chapter-11 lessons: the boot-probe one, a credential-redaction one, and one aboutprettier --write docs/silently destroyingRTC_NOINITin an unrelated chapter — which is also whydocs/**/*.mdis now in.prettierignore.Stale "no caching layer" / "stateless" claims in four docs were corrected against
database.ts's actual 5 s snapshot cache, and CLAUDE.md's per-service test counts were refreshed from measured runs (backend said 17; it is 287).Verification
tsc --noEmitclean, all repo gate scripts green,docker compose configvalid./api/healthin 5 ms while warning 14.8 s later with the realECONNREFUSED; blackhole listener → probe terminates at ~24.6 s instead of hanging forever; late-starting duckdb → recovery line at exactly 60.0 s; credentialed / scheme-less / unset / valid URLs → correct distinct message each time, zero password occurrences in the log.ESP32-CAM/. CI covers it.Known trade-off
Gating the dev backend on
service_healthymeans a duckdb that never becomes healthy leaves you with no backend — so/api/healthand the admin log panel, the surfaces you would diagnose with, are unreachable in that case. Usedocker compose logs duckdb-service. This is written down indocs/07-deployment-view/docker-compose.md.🤖 Reviewed across seven rounds with the repo's
senior-reviewergate.