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>
168 lines
6.0 KiB
Go
168 lines
6.0 KiB
Go
package cloud
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
luxlog "github.com/luxfi/log"
|
|
)
|
|
|
|
// TestRetryTransport_AbsorbsDialGapThenSucceeds proves the core zero-downtime
|
|
// property: while the writer is un-dialable (its endpoint is down mid-roll), the
|
|
// reader retries and, once the writer comes back, the request SUCCEEDS — no
|
|
// 502/refused surfaces at the edge. This is what turns the writer's roll gap into
|
|
// a little latency instead of a blip.
|
|
func TestRetryTransport_AbsorbsDialGapThenSucceeds(t *testing.T) {
|
|
var up atomic.Bool
|
|
// A stub upstream that only exists while up==true; when down, dials are refused.
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
addr := ln.Addr().String()
|
|
_ = ln.Close() // free the port; nothing listens until we bring it up
|
|
|
|
srvErr := make(chan error, 1)
|
|
bringUp := func() {
|
|
l, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
srvErr <- err
|
|
return
|
|
}
|
|
up.Store(true)
|
|
s := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok:" + string(b)))
|
|
})}
|
|
_ = s.Serve(l)
|
|
}
|
|
|
|
// Bring the upstream up after 600ms — simulating the writer handoff gap.
|
|
go func() {
|
|
time.Sleep(600 * time.Millisecond)
|
|
bringUp()
|
|
}()
|
|
|
|
rt := newRetryTransport(10*time.Second, luxlog.NewNoOpLogger())
|
|
req, _ := http.NewRequest(http.MethodPost, "http://"+addr+"/v1/chat/completions", strings.NewReader(`{"x":1}`))
|
|
|
|
start := time.Now()
|
|
resp, err := rt.RoundTrip(req)
|
|
if err != nil {
|
|
t.Fatalf("RoundTrip failed despite retry budget: %v (up=%v)", err, up.Load())
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if string(body) != `ok:{"x":1}` {
|
|
t.Fatalf("body = %q, want replayed POST body echoed", body)
|
|
}
|
|
if elapsed := time.Since(start); elapsed < 500*time.Millisecond {
|
|
t.Fatalf("succeeded in %s — expected to have waited through the ~600ms gap", elapsed)
|
|
}
|
|
select {
|
|
case e := <-srvErr:
|
|
t.Fatalf("bringUp failed: %v", e)
|
|
default:
|
|
}
|
|
}
|
|
|
|
// TestRetryTransport_GivesUpAfterBudget proves the retry is BOUNDED: if the
|
|
// writer never returns, the reader stops retrying at the budget and surfaces the
|
|
// dial error (which the proxy renders as 502), rather than hanging forever.
|
|
func TestRetryTransport_GivesUpAfterBudget(t *testing.T) {
|
|
rt := newRetryTransport(300*time.Millisecond, luxlog.NewNoOpLogger())
|
|
// 127.0.0.1:1 is reserved/unbound → connection refused (a dial error).
|
|
req, _ := http.NewRequest(http.MethodGet, "http://127.0.0.1:1/v1/models", nil)
|
|
start := time.Now()
|
|
_, err := rt.RoundTrip(req)
|
|
if err == nil {
|
|
t.Fatal("expected a dial error after the budget elapsed")
|
|
}
|
|
if elapsed := time.Since(start); elapsed < 250*time.Millisecond {
|
|
t.Fatalf("gave up in %s — should have retried until ~300ms budget", elapsed)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
|
t.Fatalf("took %s — retry budget was not honored", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestIsDialError_OnlyRetriesUndeliveredRequests pins the safety invariant: only
|
|
// a dial failure (request NEVER delivered) is retryable, so a non-idempotent POST
|
|
// is never double-executed after it reached the writer.
|
|
func TestIsDialError_OnlyRetriesUndeliveredRequests(t *testing.T) {
|
|
if !isDialError(&net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}) {
|
|
t.Fatal("a dial connection-refused must be retryable")
|
|
}
|
|
if !isDialError(syscall.ECONNREFUSED) {
|
|
t.Fatal("bare ECONNREFUSED must be retryable")
|
|
}
|
|
// A read failure AFTER the request was written is NOT a dial error — ambiguous,
|
|
// must not be retried.
|
|
if isDialError(&net.OpError{Op: "read", Err: io.ErrUnexpectedEOF}) {
|
|
t.Fatal("a post-delivery read error must NOT be retried (ambiguous)")
|
|
}
|
|
if isDialError(errors.New("some upstream 500")) {
|
|
t.Fatal("a generic error must NOT be retried")
|
|
}
|
|
}
|
|
|
|
// TestServeReaderProxy_RequiresWriterURL proves a reader fails loud without a
|
|
// target rather than silently serving nothing.
|
|
func TestServeReaderProxy_RequiresWriterURL(t *testing.T) {
|
|
cfg := &Config{WriterURL: ""}
|
|
if err := serveReaderProxy(cfg); err == nil {
|
|
t.Fatal("reader with no CLOUD_WRITER_URL must fail closed")
|
|
}
|
|
}
|
|
|
|
// TestReaderProxy_EndToEndTransparent proves the assembled proxy forwards method,
|
|
// path, body, and a header to the writer and streams the response back — the
|
|
// transparent-forwarder contract, exercised through httptest.
|
|
func TestReaderProxy_EndToEndTransparent(t *testing.T) {
|
|
var gotPath, gotOrg, gotMethod string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath, gotMethod, gotOrg = r.URL.Path, r.Method, r.Header.Get("X-Org-Id")
|
|
b, _ := io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write(append([]byte("echo:"), b...))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
cfg := &Config{WriterURL: upstream.URL, ListenAddr: "127.0.0.1:0", HealthListenAddr: "127.0.0.1:0", ReaderRetryBudget: 2 * time.Second}
|
|
// Build the proxy handler the same way serveReaderProxy does, and drive it via
|
|
// httptest so we assert forwarding without binding real ports.
|
|
proxy, err := newReaderProxy(cfg, luxlog.NewNoOpLogger())
|
|
if err != nil {
|
|
t.Fatalf("newReaderProxy: %v", err)
|
|
}
|
|
edge := httptest.NewServer(proxy)
|
|
defer edge.Close()
|
|
|
|
req, _ := http.NewRequest(http.MethodPut, edge.URL+"/v1/prompts/foo", strings.NewReader("BODY"))
|
|
req.Header.Set("X-Org-Id", "acme")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("edge request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if gotMethod != http.MethodPut || gotPath != "/v1/prompts/foo" || gotOrg != "acme" {
|
|
t.Fatalf("writer saw method=%q path=%q org=%q — not transparently forwarded", gotMethod, gotPath, gotOrg)
|
|
}
|
|
if resp.StatusCode != http.StatusCreated || string(body) != "echo:BODY" {
|
|
t.Fatalf("edge returned status=%d body=%q — response not passed through", resp.StatusCode, body)
|
|
}
|
|
}
|