Skip to content

chore(stack): land the merged stack that never reached main - #22

Merged
ekalinin merged 11 commits into
mainfrom
docs/spec-code-alignment
Aug 20, 2026
Merged

chore(stack): land the merged stack that never reached main#22
ekalinin merged 11 commits into
mainfrom
docs/spec-code-alignment

Conversation

@ekalinin

Copy link
Copy Markdown
Owner

Ten merged pull requests, #12 through #21, that never reached main.

Every PR in that stack was based on the branch below it rather than on main, and GitHub only retargets a base when the base branch is deleted. The branches were kept, so each merge landed in its own base: #12 into fix/state-consistency, #13 into fix/core-invariants, and so on up to #21 into docs/spec-code-alignment. The work rolled up the stack instead of down into main, which received only #11.

This brings the whole accumulated chain over in one step. The diff is 81 files, and the only commit main holds that this branch does not is the merge node of #11, whose content (672d6e2) is already here, so the merge is conflict-free.

What is in it

PR Commit Subject
#12 3cb1cae fix(manager): close the gaps in the mandatory invariants
#13 3f449ca fix(transport): align responses with the contract and bound the surface
#14 cb77494 feat(authn): require credentials and bind queries to their subject
#15 0c7432b feat(storage): implement parquet, register clickhouse, checksum results
#16 4c4e8a6 feat(manager): deliver events across instances and report live progress
#17 359f8a9 chore(deploy): run unprivileged, keep secrets out of the config, support tls
#18 55d41b2 chore(test): cover real backends, patch dependencies, document security
#19 f7f9299 feat(server): cap the request rate per caller
#20 64cdda2 docs(spec): reconcile interface sketches and layout with the code
#21 2a04130 chore(test): make the container suite runnable under colima

Each commit carries its own description in the pull request it came from; nothing is new here beyond the merge itself.

Operational notes for whoever deploys this

  • /metrics now requires the admin scope wherever it is mounted, because its labels enumerate every configured db_id and the traffic volume per database. Any external scraper needs a token; the shipped Prometheus config has one.
  • Turning auth on binds new records to a subject and leaves older ones reachable only by admin. Switch it on while the instance is idle, or expect queries submitted before the switch to answer 404 to their owners until result_ttl expires.
  • auth is reported under report.ignored by a reload rather than applied, so revoking a token takes a restart.
  • deploy/k8s/secret.yaml is now secret.example.yaml. Copy it, fill it in and apply the copy; kubectl apply -f deploy/k8s/ no longer picks up placeholder tokens that would fail validation.
  • The idempotency key is namespaced by subject. Keys in flight across the upgrade resolve under a different name, so a repeated submission during the rollout runs again instead of matching.

Verification

make ci in full (fmt-check, vet, test, race, lint, buf lint, govulncheck) - green on the tip.

make test-containers with -race against real Redis, PostgreSQL, MySQL and MinIO - 8/8.

A compose stack was exercised by hand: scope enforcement on the public and admin listeners, Prometheus scraping the gated /metrics with a token, DML rejection, S3 materialization, and idempotency keys staying inside their subject.

After merging

The intermediate branches (fix/state-consistency through docs/spec-code-alignment) hold merge nodes that carry no content of their own. They can be deleted once this lands.

ekalinin and others added 11 commits August 17, 2026 22:21
Result finalization (I4). writer.Close is where S3 and ClickHouse wait for
the upload and return its error; ignoring it marked queries SUCCEEDED with
nothing behind them. Its error now fails the query, drops the partial result
and reports STORAGE_FINALIZE_FAILED.

State persistence (I2, I4). A failed terminal write was logged once and the
query then left the registry, so metadata could stay RUNNING for ever. Writes
are retried with backoff, the registry entry is released only afterwards so
the lease stays alive meanwhile, and a query that still cannot be recorded is
left to the owner reaper.

Owner fencing. The reaper wrote unconditionally and could replace a SUCCEEDED
record, ResultRef included, with FAILED/OWNER_LOST. It now skips records this
instance owns, ignores queries younger than two heartbeat intervals, and
writes through UpdateQueryIfState. Every state change is checked against
CanTransitionTo, which had no callers at all. Queries left behind by a
previous process under the same instance ID are recovered once at startup.

Drain admission (I5). The draining check sat outside the registration step,
so a query could register after the shutdown loop had already seen zero
in-flight. Both now happen under one lock, query goroutines are tracked in a
WaitGroup, and CountInFlight takes the larger of the local registry and the
MetaStore, so a restarted node does not claim to be quiesced.

Idempotency (I3). The key is released when submission fails afterwards, is
reclaimed when it points at a record that no longer exists, is re-armed
against FinishedAt so it expires with the result, is stored on the record,
and is resolved before Ping so a duplicate resolves while the database is
down.

Input validation. result_format reached os.Create as part of a file name;
"../../etc/cron.d/x" created, truncated and then removed that file. Options
are validated against a whitelist before any side effect, the fs backend
works through os.Root and confines stored locators, and the S3 backend
confines keys to its result prefix.

Cancellation and timeouts. context.Canceled now yields CANCELED and
context.DeadlineExceeded a QUERY_TIMEOUT failure instead of a blanket
DB_EXEC_FAILED, and the storage writer receives the execution context so a
stopped query aborts its upload rather than uploading in the background.

Also: DML and DDL are rejected by default through a tokenizing guard, with
allow_writes to opt out; concurrent executions can be capped; GC runs under a
cluster lock and keeps metadata when the result could not be deleted; pools
are opened outside the pool lock and really are recreated when the DSN,
engine or max_conns changes, with the replaced pool drained rather than
closed under running queries; reload reports the sections it had to ignore;
Watch validates the query, replays the current state and ends after a
terminal one; driver errors carrying the DSN stay in the log and callers get
typed domain errors instead.
REST answered with the domain model, so `timeout`, `result_ttl` and
`db_exec_duration` went out in nanoseconds while api/openapi/dbbridge.yaml
promised `timeout_ms`, `result_ttl_seconds` and `db_exec_duration_ms`. The
API accepted milliseconds and answered in nanoseconds, so no generated
client could read a response. Explicit wire types now carry the documented
names and units, and unset timestamps are omitted rather than sent as the
year one.

Errors. Everything except draining was a 500 (CodeInternal in Connect) whose
body carried err.Error(); for a connection failure that text is the DSN with
host, user and parameters. Both transports now map ValidationError to
400/InvalidArgument, NotFoundError to 404/NotFound, DrainingError and
UnavailableError to 503/Unavailable and ResourceExhaustedError to
429/ResourceExhausted, and answer everything else with "internal error" plus
a request ID that ties the response to the full log line. Connect also stops
turning unset timestamps into large negative milliseconds.

Timeouts. middleware.Timeout(60s) covered the whole router, which cut result
downloads and sync submissions off after a minute and killed every WebSocket
connection at the same age. It now applies only to the short-lived routes.
Both listeners get ReadHeaderTimeout, IdleTimeout and MaxHeaderBytes, and the
submission body goes through MaxBytesReader with a 413 on overflow; neither
ReadTimeout nor WriteTimeout is set globally, they would break the streams.

WebSocket. Origin checking was disabled outright, so any page in a browser
inside the perimeter could open a connection and read other people's query
events. Origins are now checked against server.ws_allowed_origins. Each watch
message used to spawn a goroutine and register a channel that lived until the
connection closed, with no cap and no check on the query ID; subscriptions
are now tracked per connection, deduplicated, capped at 32, released on
unwatch and on disconnect, and an unknown query ID is reported instead of
hanging.

Also: a Range starting past the end is a 416 with `bytes */<total>` rather
than a 206 with a malformed `bytes 0--1/*` header, and X-Forwarded-For is
only trusted for the configured number of proxy hops.
There was no authentication on any transport and no securityScheme in the
OpenAPI document. Anyone who could reach the port could run arbitrary SQL
under the service credentials of every configured database, stop other
people's queries, read their results and reload the process.

internal/authn holds static bearer tokens loaded from auth.tokens, with the
value normally taken from an environment variable so it never has to sit in a
ConfigMap. Comparison is constant time across all tokens with no early exit.
Scopes are read (status, stats, download, list, watch), write (submit, stop)
and admin (reload, can-stop); admin implies the others. The package sits
outside internal/transport because the core stamps a query with the subject
that submitted it and must not import a transport.

REST gates every /v1 route on its scope, the Connect handler gets an
interceptor that covers unary and streaming calls alike, and an unmapped RPC
defaults to admin so a new method is never accidentally public. The health
probes stay open; a configured auth section that resolves to no usable token
is a startup failure rather than a silent pass-through.

Knowing a query ID was also enough to read anyone's SQL, status, stats and
result. QueryRecord now carries the submitting subject, and status, stats,
download, stop and watch check it. Admin acts across subjects; a record
written before subject binding has no owner and is admin-only. A foreign
query answers 404, not 403, so the API does not confirm that an ID exists.

Finally, /metrics and /v1/admin/* move to their own listener when
server.admin_addr is set: the metric labels enumerate every configured db_id
and the admin routes reload the process, so neither belongs on the public
port.
result_format: parquet was serialized as JSONL, so clients received a
.parquet file with JSON lines inside and `format: "parquet"` in the metadata.
It now produces a real Parquet file: the head of the stream is sampled to
infer a type per column, everything past the bounded sample is streamed, SQL
NULL round-trips through optional fields, and a round-trip test opens the
result with a parquet reader. Two properties are documented in the encoder:
columns come out in alphabetical order because parquet-go sorts schema group
fields, and duplicate SQL column names get a numeric suffix.

The ClickHouse ResultStore was written but never registered in the
executable, so storage_backend: clickhouse failed with "unknown storage
backend" only after the SQL had already run. It is now built from
storage.clickhouse, registered, and closed on shutdown. A default_storage
that cannot be built is rejected at config load, not after the first query.
The FS store is only created when the configuration asks for it: doing it
unconditionally meant MkdirAll on every start, which fails under a read-only
root filesystem even when results go to S3.

ResultRef.Checksum was always empty. It is now a sha256 computed from the
same bytes on their way to storage, so it costs one pass and never needs the
result read back.
Watchers lived in a per-process map and Redis Pub/Sub only carried
STOP_QUERY, so a subscription opened through any instance other than the
owner never received an event, which breaks I2 for WebSocket and WatchQuery.
Events now travel over the control channel as QUERY_EVENT and are fanned out
to the local subscriptions of every instance. An instance ignores its own
announcements, and publishing runs in its own goroutine with a bounded queue
so it can never stall the query that produced the event.

Stats were written once, at completion, so a query that ran for an hour
reported nothing until it was already over. The encoder now calls back every
thousand rows, and the manager persists and announces the row and byte counts
from there, throttled to one write every couple of seconds with the first
batch reported immediately.

The instance lifecycle gains STOPPABLE, which spec section 9 defines but
which was never reachable: a draining instance advances to it once it holds
zero in-flight owned queries, /v1/admin/can-stop and CanIBeStopped report it,
and readiness keeps returning 503 through it so the node stays out of
rotation until termination.

Telemetry had two parallel metric stacks: the domain metrics were on
prometheus/client_golang while an OTel MeterProvider was raised beside them
and never given a single instrument, so nothing reached OTLP. The
instruments are OTel now and Prometheus is one of the readers behind them,
which keeps /metrics serving the same names, labels and histogram buckets
while OTLP finally carries the same data. Go runtime metrics stay on the
Prometheus collector, which is what exposes the full runtime/metrics ruleset.

Execution spans also lost their parent, because I1 detaches the execution
context from the request. The submitting span context is carried over as a
span link, so a trace can still be followed from transport to execution
without pretending the two share a lifetime.
…ort tls

The container had no USER and the pod no securityContext, so the process ran
as root: any file-handling defect became "write anywhere in the container".
It now runs as UID 10001 with a read-only root filesystem, all capabilities
dropped, no privilege escalation and the RuntimeDefault seccomp profile, with
emptyDir volumes for the result directory and /tmp because a read-only root
leaves nowhere else to write.

The config loader had no variable substitution, so `dbbridge-$(POD_NAME)` in
the ConfigMap stayed a literal and both replicas reported the same owner,
which makes leases, remote cancellation and owner-loss detection meaningless.
`${VAR}` is now expanded from the environment and an unset variable is a
startup error rather than an empty string. A bare `$VAR` is left alone, so
DSNs and passwords may contain a dollar sign.

That same mechanism moves the Redis password, the S3 credentials and the API
tokens out of the ConfigMap and into a Secret.

TLS is configurable for all three listeners; without a certificate gRPC still
runs as cleartext HTTP/2, but that now has to be acknowledged with
server.tls.allow_h2c or it logs a warning on every start.

The dev stack no longer publishes Redis without a password, and the MinIO
bucket is no longer made anonymously readable, which had published every
query result to anyone who could guess an object key. Metrics move to the
admin listener, so Prometheus scrapes port 8081 and the public Service does
not carry it.
Every test ran on the in-memory MetaStore, the local filesystem and fake
drivers, so the paths that only exist for the real backends had no coverage
at all: Lua scripts and WATCH transactions in Redis, lease key expiry, pgx and
MySQL type mapping, the S3 multipart uploader and the Close-waits-for-upload
contract behind I4. test/integration runs those against Redis, PostgreSQL,
MySQL and MinIO under testcontainers, behind the `integration` build tag and a
separate CI job so a Docker problem is never mistaken for a code failure. It
covers a full round trip per backend, multi-node idempotency, a subscription
and a stop crossing instances, owner-loss reaping, the GC lock, and a
heartbeat storm that must not clobber a terminal write.

govulncheck reported two reachable advisories: GO-2026-6061 in grpc and
GO-2026-5970 in x/text. Both are patched, and the check now runs in `make ci`
and in the CI workflow.

That check also raises the floor on the toolchain, because most of what it
finds is in the standard library rather than in a dependency: go.mod asks for
1.26.6, which is the first release without any of the advisories reachable
from this code. CI installs exactly what go.mod declares, so leaving it at
1.26.0 meant shipping a binary built against a stdlib with two dozen known
issues in net/http, crypto/tls, crypto/x509 and html/template.

spec.md gains the security section it never had - authentication,
authorization and subject binding, the statement policy, TLS and trusted
proxies, request and concurrency limits, listener isolation and error
sanitization - which was a gap in the specification rather than a divergence
from it. It also states what a reload cannot apply, and records that leases
live outside the query record and that query events travel the control
channel.
The concurrency semaphore bounds how many queries run at once, but nothing
bounded how fast they could be submitted, so one client could still churn
through pool connections, storage writes and idempotency keys as fast as the
process could accept or reject them.

server.rate_limit gives each caller a token bucket, keyed by authenticated
subject where there is one and by the resolved client address otherwise, on
both REST and Connect. Idle buckets are evicted so the map cannot grow
without bound. The health probes are exempt: the kubelet calls them on a
schedule and must not be starved by a noisy caller, nor consume its budget.
The interface sketches in sections 5.1 through 5.3 predate the
implementation and had drifted: the service methods are named after the RPCs
in section 7, a driver takes a DSN string and a pool size, a RowStream
reports column names because no supported driver offers portable type
metadata before the first row, and a ResultStore Writer mints the ResultRef
rather than receiving one, since only the backend knows the locator it is
about to write to.

Section 12 now matches the tree, including the two departures from the
original sketch and why they were made: idempotency has no package of its
own, and internal/state is flat.

Section 13 records what the code actually depends on, including why the
config layer is plain yaml.v3 rather than viper or koanf, and section 11
describes the single metrics pipeline and the span link that stands in for
the parent I1 makes impossible.
testcontainers does not read the docker CLI context, so on a colima host it
reported "rootless Docker not found" and every integration test failed before
starting a container. The target now spells out both variables it needs when
the current context is colima: DOCKER_HOST is the host-side socket it
connects to, and TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE is that same socket as
seen inside the VM, which is what its reaper container bind-mounts. Both stay
empty on Docker Desktop and in CI, where the default socket is already
correct for each.

Also stops logging a draining 503 as an ERROR. It is expected operation and
would fill the log on every rolling deploy; an unreachable database is
already logged where it is detected, with the driver detail the response
deliberately drops.
chore(test): make the container suite runnable under colima
@ekalinin
ekalinin merged commit 793340b into main Aug 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant