Skip to content

fix(proxy): gate /readyz on a decodable Fetch round-trip - #171

Open
kamir wants to merge 1 commit into
KafScale:mainfrom
kamir:fix/proxy-readyz-fetch-probe
Open

kamir wants to merge 1 commit into
KafScale:mainfrom
kamir:fix/proxy-readyz-fetch-probe

Conversation

@kamir

@kamir kamir commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

What

/readyz on the proxy answers two questions instead of one:

  1. Is a backend known? (the existing shallow check, unchanged, now haveBackend)
  2. Does that backend actually serve Fetch? (new, a decodable Fetch round-trip)

KAFSCALE_PROXY_READYZ_FETCH_PROBE=false falls back to the shallow check.

Why

The proxy is a full Fetch codec: it decodes, merges and re-encodes broker Fetch
responses. A proxy-to-broker Fetch serialization or connection-state mismatch
therefore breaks consume while Metadata and ListOffsets keep answering
normally.

In that state readiness based on question 1 alone reports ready, Kubernetes
keeps the pod in the Service, and every consumer silently receives zero
records. The failure is invisible to kubectl get pods and to any health check
that only asks whether a process is up.

The probe asks for one partition of a deliberately unknown topic ID, so it
needs no real topic and reads no data; a correct broker answers
UNKNOWN_TOPIC_ID. It uses a fresh connection on purpose, which also detects a
stale or half-open pooled connection that cached state cannot.

The part worth reviewing: framing

forwardToBackend prepends the frame length itself, and encodeFetchRequest
already strips the size prefix that kmsg's AppendRequest emits. The probe
payload must therefore be the bare request header plus body.

Stripping a second time removes the API key and version, four bytes. The
request is then not a malformed Fetch, it is a different API: the broker reads
the correlation ID as the API key. A broker that cannot parse a request header
closes the connection, so the probe reports EOF and the failure reads like a
broken broker rather than a broken probe.

buildFetchProbePayload is split out so the encoding is unit-testable without a
backend.

Startup ordering

With the probe enabled the proxy stays NotReady until a broker serves Fetch. An
installer that waits for proxy readiness before creating the cluster resource
that produces the broker will deadlock. Create the broker first, or do not gate
the install on proxy readiness. Documented in docs/operations.md.

Tests

Test States
TestBuildFetchProbePayloadIsSingleFramed the payload parses back as Fetch v13 with the probe's topic ID, one partition, ReplicaID -1
TestBuildFetchProbePayloadRejectsDoubleStrip a payload that has lost its four header bytes must NOT parse as Fetch, so the test above cannot silently stop distinguishing the bug
TestEnvBoolDefault parsing, including that an unparseable value falls back rather than flipping a gate

Both framing tests were checked against a deliberately broken encoder:

with the double strip:
  --- FAIL: TestBuildFetchProbePayloadIsSingleFramed
      probe payload is not parseable as a request:
      read client id: insufficient bytes: need 26227 have 98
without it:
  ok  github.com/KafScale/platform/cmd/proxy

need 26227 have 98 is what the broker sees before it closes the connection.

Verification

  • gofmt -l . clean, go vet ./cmd/... ./pkg/... clean
  • go test ./cmd/proxy ok
  • go test ./... 22 packages ok, 0 failures

Verified end to end on a single-node k3s edge deployment: with the probe
correctly framed the proxy reaches Ready and helm --wait completes; produce
and consume of 500 messages through the proxy round-trip with no gaps,
duplicates or checksum errors.

Scope

Three files, one concern. No behaviour change when
KAFSCALE_PROXY_READYZ_FETCH_PROBE=false.

The proxy is a full Fetch codec: it decodes, merges and re-encodes broker
Fetch responses. A proxy-to-broker Fetch serialization or connection-state
mismatch therefore breaks consume while Metadata and ListOffsets keep
answering normally. Readiness based on "a backend exists" reports ready in
that state, Kubernetes keeps the pod in the Service, and every consumer
silently receives zero records.

/readyz now answers two questions: is a backend known, and does that backend
actually serve Fetch. The deep gate sends a Fetch for one partition of a
deliberately unknown topic ID on a fresh connection, so it needs no real
topic, reads no data, and also detects a stale or half-open pooled
connection. A correct broker answers UNKNOWN_TOPIC_ID.

KAFSCALE_PROXY_READYZ_FETCH_PROBE=false falls back to the shallow check. It
is an escape hatch for a false negative in the field, not a steady state.

Framing is the part worth guarding. forwardToBackend prepends the frame
length itself, and encodeFetchRequest already strips the size prefix that
kmsg's AppendRequest emits, so the probe payload must be the bare request
header plus body. Stripping a second time removes the API key and version:
the request is then not a malformed Fetch but a different API, and a broker
that cannot parse a request header closes the connection. The probe reports
EOF and the failure reads like a broken broker rather than a broken probe.

buildFetchProbePayload is split out so that encoding is unit-testable without
a backend. Two tests state the invariant from both sides: the payload parses
back as Fetch v13 with the probe's topic ID, and a payload that has lost its
four header bytes must not parse as Fetch, so the first test cannot silently
stop distinguishing the bug.

Startup ordering is a consequence worth documenting: with the probe enabled
the proxy stays NotReady until a broker serves Fetch, so an installer that
waits for proxy readiness before creating the resource that produces the
broker will deadlock.

- cmd/proxy/main.go: split checkReady into haveBackend plus the deep gate,
  add fetchProbe, buildFetchProbePayload and envBoolDefault
- cmd/proxy/main_test.go: TestBuildFetchProbePayloadIsSingleFramed,
  TestBuildFetchProbePayloadRejectsDoubleStrip, TestEnvBoolDefault
- docs/operations.md: "Proxy readiness" section and the env var index entry

@PaxMachinaOne PaxMachinaOne left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 PaxMachina automated review (worker-code)

The Fetch probe’s timeout does not bound backend I/O.

  • Blocking: cmd/proxy/main.go:481 — probeCtx is not applied as a connection deadline, while forwardToBackend performs context-unaware blocking reads/writes. A backend that accepts TCP but never returns a complete frame can therefore hang /readyz indefinitely, accumulating connections and handler goroutines as probes repeat. Set the connection deadline from probeCtx or make the forwarding I/O cancellation-aware.

This branch has not been deployed

No deployments
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.

3 participants