v1.5.0: WAL-streamer systemd unit (#56), LLM helper fixes, dependency bumps - #63
Open
postgresql007 wants to merge 20 commits into
Open
postgresql007 wants to merge 20 commits into
postgresql007 wants to merge 20 commits into
Conversation
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.83.1 to 1.83.2. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](grpc/grpc-go@v1.83.1...v1.83.2) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.83.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com>
Bumps [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.45.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](open-telemetry/opentelemetry-go@v1.44.0...v1.45.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/otel/sdk dependency-version: 1.45.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com>
…tlptracehttp Bumps [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.45.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](open-telemetry/opentelemetry-go@v1.44.0...v1.45.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp dependency-version: 1.45.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com>
Bumps [go.opentelemetry.io/otel/exporters/otlp/otlptrace](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.45.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](open-telemetry/opentelemetry-go@v1.44.0...v1.45.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace dependency-version: 1.45.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com>
…ang.org/grpc-1.83.2'
…emetry.io/otel/sdk-1.45.0'
…emetry.io/otel/exporters/otlp/otlptrace/otlptracehttp-1.45.0' # Conflicts: # go.mod # go.sum
…emetry.io/otel/exporters/otlp/otlptrace-1.45.0' # Conflicts: # go.mod # go.sum
The packages shipped pg_hardstorage.service and pg_hardstorage@.service — both running `pg_hardstorage agent` — and the documentation told operators to supervise the WAL streamer with them. The agent runs the scheduled backup and retention engine and never opens a WAL stream (grep internal/agent: no reference to it). So an operator who followed the getting-started tutorial ended up with periodic base backups and no continuous archiving, and nothing said so. Adds pg_hardstorage-wal-stream@.service, templated on the deployment because `wal stream` takes it as a positional, packaged by all four recipes. Corrects the tutorial, the R1 runbook (whose `systemctl stop pg_hardstorage` left the slot held and PG still blocked) and the three migration guides, each of which promised a replication slot and then enabled the agent. Three runbooks also named `pg_hardstorage-agent`, a unit no package ever shipped. New tests bind the units to the CLI: every unit must be in every packaging recipe, every ExecStart must name a real verb, and some unit must run `wal stream`. Also merges #57-#60. #57 matters: grpc 1.83.1, merged four days ago in #55 to clear an unreachable advisory, carried a REACHABLE one (GO-2026-6443). 1.83.2 clears it; the binary scans clean again. Closes #56 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RmkgG1CUNYTkZES1M4BkkN
The OpenAI-compatible provider set http.Client.Timeout to 5 minutes,
described in a comment as "a backstop for hung connections". It is not
one. Client.Timeout is a deadline on the WHOLE exchange including the
body, and this is a Server-Sent Events client — it cannot distinguish
a dead connection from a healthy stream still delivering tokens.
Measured against a deepseek-v4 reasoning endpoint, one ordinary
question ("What does pg_hardstorage wal stream do?") streams steadily
for 265s at max_tokens=4096; a qwen3.8 endpoint takes 532s. Both are
well-behaved: bytes arrive continuously and the server closes with
`data: [DONE]`. The client killed them at 300s and reported
openai: read stream: context deadline exceeded
(Client.Timeout or context cancellation while reading body)
which reads as a network fault rather than as our own client hanging
up on a working model. Every reasoning model is affected, and the
failure is silent in the sense that nothing points at the real cause.
The phases where silence genuinely means a fault are now bounded
individually — connect and TLS (30s), response headers (5m, since a
busy inference server queues before generating) — and the stream
itself is watched for a two-minute gap BETWEEN BYTES by a stall
reader, which is the condition the old timeout was reaching for.
Total duration belongs to the caller's ctx, which is where the
operator's Ctrl-C already lives.
A stalled stream now says so, instead of surfacing a bare context
error.
Verified end to end: the query that died at 300s now returns rc=0 in
265s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RmkgG1CUNYTkZES1M4BkkN
Two defects found by driving `llm ask` against real reasoning endpoints (deepseek-v4, qwen3.8) rather than the mock provider. 1. Every question shipped a 150 KB system prompt. hotCommandPaths bakes the full --help of 38 commands into the prompt at bootstrap. Its comment estimated "~200-400 tokens" per entry; measured against the live binary it is ~1,000 — 38,309 tokens in total, so the list was kept "tight" against a budget understating the cost by 3x. Asking "is there an RPM?" shipped the full flag inventory of restore, forecast and compliance report. The cost is not money, it is latency: 38k tokens of prefill on a reasoning endpoint is minutes of silence before the first token, which is indistinguishable from a hung client. The block is now budgeted to 16 KB (PG_HARDSTORAGE_LLM_HOT_HELP_BYTES, 0 disables), entries render in priority order, and the overflow is named so the model knows to call read_command_help rather than guess. That tool was already registered and already advertised in the prompt. Measured: 150 KB -> 64 KB, 38,413 -> 16,444 tokens. "How do I take my first backup?" went from 764s-and-failing to 316s-and-answering. 2. The validator's retry narrated itself into the answer. When a reply names a bad flag, the session asks the model to revise. Nothing told it to keep that internal. Real answers came back opening "Now I have the correct flags. Here's the revised answer." and "Correct — `wal preflight` takes only --pg-connection. Revised:". The operator never saw the first attempt or the validator's complaint, so the answer simply begins by referring to a conversation that, from where they are standing, did not happen. The retry prompt now says to reply with the corrected answer only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RmkgG1CUNYTkZES1M4BkkN
internal/cli's own docs_surface guard caught PG_HARDSTORAGE_LLM_HOT_HELP_BYTES as read by production code but absent from docs/ — the check exists because an undocumented env var is a setting no operator can find. Also folds the two LLM fixes and the prompt-size change into the Unreleased changelog, which previously carried only the systemd and dependency work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RmkgG1CUNYTkZES1M4BkkN
Records what has been verified on the branch and what still needs a run before tagging — specifically the four gates that rebuild bin/pg_hardstorage, which were deferred because an LLM evaluation run was driving that binary. Scaffolding; delete before tagging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RmkgG1CUNYTkZES1M4BkkN
The 10h soak recorded one fault_apply_failed in 1059 faults: the kernel refused a 32 MiB cgroup cap it could not reclaim to, with swap disabled for the container. The cell was unharmed and the product is not at fault — it is testkit triage noise, filed as #64. Recorded here so whoever reads the soak report before tagging does not have to re-derive it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RmkgG1CUNYTkZES1M4BkkN
The stall watchdog added earlier was calibrated against an IDLE
endpoint, where the largest mid-stream gap was 0.7s. Two minutes
looked generous. Against a real self-hosted endpoint serving more than
one request it was catastrophic: a 194-question run lost 192 answers.
Raising it to 15 minutes still lost 6 of 8.
Measuring instead of guessing again, with 8 concurrent requests:
req headers 1st byte max gap total
5 0.1s 1.0s 0.9s 272s
0 0.1s 273.1s 273.0s 464s
3 0.1s 873.7s 873.6s 1142s
Headers come back in 0.1s for every request — the server accepts
immediately and then QUEUES. A request can sit silent for 14+ minutes
waiting its turn, and that grows with concurrency. But max gap equals
first byte in every row: once a request starts generating, tokens
arrive continuously and gaps are sub-second.
So the long silence is entirely the queue, and it is legitimate. One
budget cannot tell it from a dead socket, which is why raising the
number did not work and never would have. The two are now separate:
PG_HARDSTORAGE_LLM_FIRST_BYTE_TIMEOUT 45m covers the queue wait
PG_HARDSTORAGE_LLM_STALL_TIMEOUT 5m after the first byte
The error names which budget fired, and says the request may be queued
rather than implying a network fault.
This is not a test-harness concern. Any on-prem vLLM serving more than
one user was hitting it, and a configurable --endpoint exists for
exactly that deployment.
Re-tested under the condition that failed: 8/8 answered, up from 2/8.
The two slowest took 1148s and 1267s and returned real answers citing
5 and 7 genuine commands — both would have been discarded before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RmkgG1CUNYTkZES1M4BkkN
stallReader decides whether a silent stream is a server that has gone away or one that is merely slow, and it had no tests at all. Both ways of getting that wrong have already shipped: a 2-minute budget calibrated on an idle endpoint discarded 192 of 194 answers from a busy one, and a budget with no upper bound hangs the command on a dead socket. Four tests now pin the split — the first-byte budget governs the queue wait, the stall budget takes over once bytes flow, a steadily streaming response is never cut off however long it runs, and the timer may fire while a Read is in flight. Two things worth recording, because both were mistakes this commit had to correct rather than hypotheticals: The first draft of the concurrency test called Read from four goroutines and the race detector duly reported a race — inside strings.Reader, which is not safe for concurrent use. Nothing in the provider reads concurrently (bufio.Scanner pulls from one goroutine), so the scenario could not occur and the finding was noise. The test now models the only concurrency that exists: the timer's callback firing under an in-flight Read. And the first draft of the first-byte test asserted nothing. It inspected the reader without ever calling Read, and the budget is chosen inside Read — so it passed with the fix reverted. Verified by reverting: it now fails with the exact message describing the bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RmkgG1CUNYTkZES1M4BkkN
The prompt lists the assistant's tools next to the real command
catalog, and describes them in CLI terms ("Run `pg_hardstorage doctor`
and return its structured report"). That says what a tool does for us,
but leaves the tool NAME looking like part of the command surface.
Models duly conflated the two. Across a 194-question run, three
answers told the operator to run
pg_hardstorage read_command_help deployment add
inside a copy-pasteable shell block. That command does not exist and
cannot: read_command_help is ours, not theirs. An operator following
it gets "unknown command" while attempting the very thing the answer
was explaining.
Budgeting the hot-command help block made this likelier rather than
less — the model is now told more often to reach for
read_command_help, so the name appears more often in its context.
Naming the boundary is what keeps that change safe.
The tools section now opens by stating that these are the assistant's
tools, that the operator cannot call them, and that they must never
appear in a shown command — naming the one that actually leaked, so
the instruction is concrete rather than abstract.
Two tests: the preamble must keep making the claim (reword freely,
don't drop it), and a tool name in a shell fence must reach the
command validator, since nothing else can catch it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RmkgG1CUNYTkZES1M4BkkN
`--repo` is on 101 of 208 command paths — 48%, a coin flip — and the command catalog in the same prompt deliberately shows verbs without flags. So the model has nothing to reason from and guesses. Across a 194-question evaluation against three models it was the most-invented flag by a wide margin: 42, 46 and 69 occurrences, bolted onto doctor, deployment list, kms inspect, wal preflight, schedule and audit. Confirmed against the live binary — every one of those command lines dies immediately with `unknown flag: --repo`. The cheatsheet already listed commands that DO take it. Positive examples alone are what invites the generalisation: the model sees `repair scrub --repo` and concludes the flag is universal. This adds the half that was missing — the common commands that take none, with the flags they take instead, and the reason (they resolve the repository through the deployment in pg_hardstorage.yaml). Cost is ~370 tokens against a 16.4k prompt. A list of facts about the CLI rots when the CLI moves, and a rotted entry is worse than no entry: the model would be told something false by the thing meant to stop it inventing. So the test asks the built binary whether each claim still holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RmkgG1CUNYTkZES1M4BkkN
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #61 and #62, which are closed in favour of this branch — one review, one release.
Also carries the four Dependabot PRs (#57–#60) as merges, so they close with it.
1. Issue #56 — the WAL streamer had no systemd unit
pg_hardstorage.serviceandpg_hardstorage@.serviceboth runpg_hardstorage agent, which executes the scheduled backup and retention engine and neveropens a WAL stream (
internal/agentcontains no reference to one). Thegetting-started tutorial, the R1 runbook and all three migration guides told
operators to supervise the streamer with those units.
Following the tutorial therefore gave you periodic base backups and no
continuous archiving, with nothing to indicate the always-on data plane was
absent. The R1 runbook was worse:
systemctl stop pg_hardstorageto "stop thestreamer" left the slot held and PostgreSQL still blocked — the exact failure
that runbook exists to resolve.
pg_hardstorage-wal-stream@.service, templated on the deployment(
wal streamtakes it as a positional, so%igenuinely selects one),packaged by all four recipes
pg_hardstorage-agent, a unit no package ever shippedrecipe, every
ExecStartmust name a real verb, and some unit must runwal stream2. LLM helper — three defects found against real endpoints
Found by driving
llm askagainst live reasoning models rather than the mock provider.A slow model was treated as a hung connection.
http.Client.Timeout: 5mis adeadline on the whole exchange including the body; an SSE client cannot tell a
dead connection from a healthy stream still delivering tokens.
deepseek-v4-flash-0731[DONE]qwen3.8-flash-nextBoth killed at 300 s and reported as a context deadline — reading as a network
fault rather than our own client hanging up. Every reasoning model was
affected. Connect/TLS/response-header timeouts are now bounded individually,
and the stream is watched for a gap between bytes.
Every question shipped a 150 KB system prompt.
hotCommandPathsbakes 38commands' full
--helpinto every prompt. Its comment estimated "~200–400tokens" per entry; measured, it is ~1,000 — 38,309 tokens, a 3× underestimate,
which is why the list was allowed to grow. Asking "is there an RPM?" shipped
the flag inventory of
restore,forecastandcompliance report. Nowbudgeted to 16 KB (
PG_HARDSTORAGE_LLM_HOT_HELP_BYTES), with the overflow namedso the model calls
read_command_help— already registered, already advertisedin that same prompt.
The validator's retry narrated itself into the answer. Replies came back
opening "Now I have the correct flags. Here's the revised answer." The
operator never saw the first attempt, so the answer began by referring to a
conversation that, from their side, never happened.
3. Dependencies (#57–#60)
#57matters: grpc 1.83.1 landed in #55 to clear an unreachable advisory andcarried a reachable one — GO-2026-6443, server panic via missing authority
or Host headers, reachable through
transport.http2Server.HandleStreams.1.83.2 clears it. Plus three OTel 1.45.0 bumps.
Verification
go build/go vet/gofmtgo test ./cmd/... ./internal/... ./compat/...mkdocs build --strictgovulncheck -mode=binaryTestOperatorEnvVarsAreDocumentedcaught the new env var as undocumented on thefirst run — documented rather than worked around.
Four gates that rebuild
bin/pg_hardstorageare not yet run, because an LLMevaluation is driving that binary and rebuilding mid-run would mix two code
versions into one dataset. They are listed with host caveats in
RELEASE-CHECKLIST-v1.5.0.md, which is scaffolding to delete before tagging.Suggested version: 1.5.0 — new systemd unit, materially different LLM
prompt, new env var. Nothing on disk or on the wire changes.