Files
cloud/reader_proxy.go
T
zeekayandHanzo Dev 74bb668a1f feat(ha): reader-proxy edge + writer fcntl lease for zero-downtime cloud rolls
The cloud writer embeds an exclusive-lock ZapDB KMS store. A new probe
(clients/kms.TestConcurrentOpen_LiveWriterStoreIsNotROShareable) proves that
opening that store READ-ONLY while the writer is live FAILS ("Log truncate
required to run DB") — Badger's RO open replays the live memtable WAL and
refuses to truncate it. So the prior groundwork's assumption that a reader can
open the KMS store RO off the writer's PVC is false for a LIVE writer (only the
sequential close-then-reopen case worked). The audit SQLite store IS
concurrently shareable (audit/shareability_probe_test.go); the KMS store is the
one that is not, and every mutation is audited on the writer anyway.

Reader tier is therefore a transparent, always-ready reverse proxy (opens no
stores) to the single writer:
 - reader_proxy.go: CLOUD_ROLE=reader boots serveReaderProxy BEFORE BuildDeps —
   forwards every request to CLOUD_WRITER_URL, streams SSE, preserves inbound
   Host. Dial-only retry (retryTransport) absorbs the writer's roll gap: it
   retries ONLY when the connection was never established (no ready endpoint /
   refused), so a non-idempotent POST is never double-executed; bounded by
   CLOUD_READER_RETRY_BUDGET (default 25s) then 502.
 - The reader Deployment rolls RollingUpdate(maxUnavailable:0), so the edge
   Service always has a ready endpoint — this removes the ~30s console blip that
   the writer's Recreate/replicas:1 causes today.

Writer zero-gap roll (opt-in, default OFF = byte-identical Recreate):
 - writer_lease.go (+_unix/_other): CLOUD_WRITER_LEASE takes an exclusive fcntl
   flock on {DataDir}/.writer.lock BEFORE opening the RWO stores and releases it
   LAST at shutdown (after every store closes). A surge writer blocks until the
   old one releases, so the exclusive ZapDB/audit stores are handed off, never
   double-opened. Fail-closed on timeout.

Removes the dead ReaderGuard (the reader no longer runs the full pipeline; it is
the proxy). Unset CLOUD_ROLE + unset CLOUD_WRITER_LEASE ⇒ writer, byte-identical
to today.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-10 14:12:46 -07:00

222 lines
8.6 KiB
Go

package cloud
// Reader edge — a transparent, always-ready reverse proxy to the single writer.
//
// WHY A PROXY, NOT A LOCAL-STORE REPLICA. The writer embeds an exclusive-lock
// ZapDB KMS store (a Badger fork). clients/kms.TestConcurrentOpen_LiveWriterStore-
// IsNotROShareable proves that opening that store READ-ONLY while the writer is
// live FAILS ("Log truncate required to run DB") — Badger's RO open replays the
// live memtable WAL and refuses to truncate it. So a reader CANNOT open the KMS
// store off the writer's PVC, even read-only, even same-node. (The audit SQLite
// store IS concurrently shareable — audit/shareability_probe_test.go — but the
// KMS store is the one that is not, and every mutation is audited on the writer
// anyway.) Rather than braid a partial local-read replica that must carefully
// route KMS + every audited verb to the writer, the reader is the SIMPLEST
// correct thing: it opens NO stores and forwards EVERY request to the writer.
//
// WHAT IT BUYS. The reader Deployment rolls RollingUpdate (maxUnavailable:0), so
// the edge Service always has a ready endpoint. During a writer roll the reader
// holds the client connection and RETRIES (dial-only) across the writer's brief
// handoff gap, so a `rollout restart` of the writer never surfaces a 502/refused
// at the edge — it surfaces as a little extra latency. This is what removes the
// ~30s console blip that Recreate/replicas:1 causes today.
//
// SAFETY OF RETRY. Retry fires ONLY on a dial failure — the connection to the
// writer was never established (no ready endpoint / connection refused), so the
// request was never delivered and re-sending it cannot double-execute a
// non-idempotent POST. Once bytes are on the wire to a writer, a failure is NOT
// retried (it is ambiguous). The request body is buffered (bounded) so a retried
// POST can be replayed.
//
// TRANSPARENCY / TRUST BOUNDARY. The reader forwards the request unchanged
// (method, path, query, headers, body) and preserves the inbound Host for the
// writer's host-based routing. It adds nothing to identity: the writer's
// SanitizeIdentity re-validates the JWT and re-derives X-Org-Id/X-User-Id exactly
// as if the gateway reached it directly, so the writer's trust boundary is
// unchanged by the extra hop. httputil.ReverseProxy strips hop-by-hop headers and
// appends X-Forwarded-For, and transparently proxies WebSocket/SSE upgrades.
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/signal"
"syscall"
"time"
luxlog "github.com/luxfi/log"
)
// serveReaderProxy runs the reader edge: a reverse proxy to cfg.WriterURL on the
// public listener, plus the ops health listener, shutting down gracefully on
// SIGINT/SIGTERM. It opens no stores and never returns until shutdown or a bind
// error. Serve dispatches here when CLOUD_ROLE=reader, BEFORE BuildDeps, so a
// reader never opens the KMS/audit/per-tenant stores.
func serveReaderProxy(cfg *Config) error {
log := luxlog.New("cloud").New("subsystem", "reader")
rp, err := newReaderProxy(cfg, log)
if err != nil {
return err
}
mainSrv := &http.Server{Addr: cfg.ListenAddr, Handler: rp, ReadHeaderTimeout: 10 * time.Second}
healthSrv := &http.Server{Addr: cfg.HealthListenAddr, Handler: healthMux(), ReadHeaderTimeout: 5 * time.Second}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
listenErr := make(chan error, 2)
go func() {
log.Info("reader health listening", "addr", cfg.HealthListenAddr)
if err := healthSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
listenErr <- fmt.Errorf("reader health listen: %w", err)
}
}()
go func() {
log.Info("reader edge listening", "addr", cfg.ListenAddr, "writer", cfg.WriterURL, "retry_budget", cfg.ReaderRetryBudget.String())
if err := mainSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
listenErr <- fmt.Errorf("reader edge listen: %w", err)
}
}()
select {
case <-ctx.Done():
log.Info("reader shutdown requested")
case err := <-listenErr:
return fmt.Errorf("reader listen: %w", err)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = healthSrv.Shutdown(shutdownCtx)
return mainSrv.Shutdown(shutdownCtx)
}
// newReaderProxy builds the transparent reverse proxy to cfg.WriterURL: dial-only
// retry transport, prompt flushing for SSE, and a 502 error handler once the
// retry budget is exhausted. It preserves the inbound Host for the writer's
// host-based routing. Extracted from serveReaderProxy so it is unit-testable.
func newReaderProxy(cfg *Config, log luxlog.Logger) (*httputil.ReverseProxy, error) {
if cfg.WriterURL == "" {
return nil, fmt.Errorf("reader role: CLOUD_WRITER_URL is required (the writer base URL to forward to, e.g. http://cloud-writer.hanzo.svc:8000)")
}
target, err := url.Parse(cfg.WriterURL)
if err != nil || target.Scheme == "" || target.Host == "" {
return nil, fmt.Errorf("reader role: invalid CLOUD_WRITER_URL %q: %v", cfg.WriterURL, err)
}
rp := httputil.NewSingleHostReverseProxy(target)
// Flush promptly so SSE / chunked streams (chat completions) pass through with
// no added buffering latency.
rp.FlushInterval = 100 * time.Millisecond
rp.Transport = newRetryTransport(cfg.ReaderRetryBudget, log)
rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
if log != nil {
log.Warn("reader proxy failed", "path", r.URL.Path, "method", r.Method, "err", e)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":{"code":"writer_unavailable","message":"upstream writer unavailable"}}`))
}
return rp, nil
}
// retryTransport re-sends a request ONLY when the writer could not be dialed —
// the connection was never established, so the request never reached the writer
// and replay cannot double-execute it. It buffers the request body (bounded) so a
// retried POST can be replayed. All other failures (a delivered request whose
// response failed) are returned as-is: retrying them would be ambiguous.
type retryTransport struct {
base http.RoundTripper
budget time.Duration
maxBufferedBody int64
log luxlog.Logger
}
func newRetryTransport(budget time.Duration, log luxlog.Logger) *retryTransport {
if budget <= 0 {
budget = 25 * time.Second
}
return &retryTransport{
base: http.DefaultTransport.(*http.Transport).Clone(),
budget: budget,
maxBufferedBody: 8 << 20, // 8 MiB — above this a request is a single attempt (never buffered)
log: log,
}
}
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Make the body replayable. If it is already rewindable (GetBody set by the
// proxy for small bodies) use that; otherwise buffer up to the cap. A body
// larger than the cap is sent once with no retry (never silently truncated).
getBody := req.GetBody
if getBody == nil && req.Body != nil && req.Body != http.NoBody {
buf, err := io.ReadAll(io.LimitReader(req.Body, t.maxBufferedBody+1))
_ = req.Body.Close()
if err != nil {
return nil, err
}
if int64(len(buf)) > t.maxBufferedBody {
// Too large to safely rebuffer: single attempt with what we read.
req.Body = io.NopCloser(bytes.NewReader(buf))
req.ContentLength = int64(len(buf))
return t.base.RoundTrip(req)
}
body := buf
getBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
req.ContentLength = int64(len(buf))
}
deadline := time.Now().Add(t.budget)
backoff := 100 * time.Millisecond
attempts := 0
for {
attempts++
if getBody != nil {
b, err := getBody()
if err != nil {
return nil, err
}
req.Body = b
}
resp, err := t.base.RoundTrip(req)
if err == nil {
return resp, nil
}
// Only a dial failure (request never delivered) is safely retryable.
if !isDialError(err) || time.Now().After(deadline) {
return nil, err
}
select {
case <-req.Context().Done():
return nil, req.Context().Err()
case <-time.After(backoff):
}
if backoff < 2*time.Second {
backoff *= 2
}
}
}
// isDialError reports whether err means the connection to the writer was never
// established — a no-endpoint / connection-refused / dial-timeout condition
// during a writer roll — so re-sending the request cannot double-execute it.
func isDialError(err error) bool {
if errors.Is(err, syscall.ECONNREFUSED) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
// "dial" is the phase before any byte is written to the writer.
return opErr.Op == "dial"
}
return false
}