zdd plan 1 task 5: per-connection AppSocket dial and per-generation idle pools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+793
−17
internal/edge/answered.go+21 −0internal/edge/edge.go+1 −17internal/edge/genproxy.go+353 −0internal/edge/genproxy_test.go+418 −0
diff --git a/internal/edge/answered.go b/internal/edge/answered.go| index 2df6e81d9..9699a7a13 100644 |
| --- a/internal/edge/answered.go |
| +++ b/internal/edge/answered.go |
| @@ -30,6 +30,11 @@ type outcome struct { |
| answered bool |
| cause string |
| start time.Time // when the edge began serving, so a proxy failure knows how long the client waited |
| + // genHost and gen name the generation whose connection pool carried the |
| + // request (genproxy.go), set only by an activator-managed route with |
| + // generations on. The cache tags what it stores with them (spec §7.2). |
| + genHost string |
| + gen uint64 |
| } |
| // causeClientGone is the cause noted when a proxy fails because the CLIENT |
| @@ -83,3 +88,19 @@ func noteCause(r *http.Request, cause string) { |
| o.cause = cause |
| } |
| } |
| + |
| +// noteServedGen records on r's outcome that generation n of host carried |
| +// the request; a no-op when r carries no outcome, like MarkAnswered. |
| +func noteServedGen(r *http.Request, host string, n uint64) { |
| + if o, ok := r.Context().Value(answeredKey{}).(*outcome); ok { |
| + o.genHost, o.gen = host, n |
| + } |
| +} |
| + |
| +// servedGen is what noteServedGen recorded: ok false when nothing was. |
| +func servedGen(r *http.Request) (host string, n uint64, ok bool) { |
| + if o, isOutcome := r.Context().Value(answeredKey{}).(*outcome); isOutcome && o.gen != 0 { |
| + return o.genHost, o.gen, true |
| + } |
| + return "", 0, false |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 4f95941a8..9f86a586a 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -59,7 +59,6 @@ import ( |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| - "github.com/carlosframework/platform/internal/unixdial" |
| ) |
| // Default cache sizing for EnableCache: 4096 entries is comfortably above a |
| @@ -2967,22 +2966,7 @@ func backhaulProxy(host, eip, box, auth string, tr http.RoundTripper, logThrottl |
| // in-flight request is never 502'd mid-deploy; a sustained-down backend hangs |
| // this long before 502 (rare: systemd Restart=always makes "down" transient). |
| func dialUnixWithRetry(ctx context.Context, d *net.Dialer, socketPath string) (net.Conn, error) { |
| - const window = 13 * time.Second |
| - deadline := time.Now().Add(window) |
| - for { |
| - conn, err := unixdial.Checked(ctx, d, socketPath) |
| - if err == nil { |
| - return conn, nil |
| - } |
| - if ctx.Err() != nil || !isRetryableDialErr(err) || time.Now().After(deadline) { |
| - return nil, err |
| - } |
| - select { |
| - case <-ctx.Done(): |
| - return nil, ctx.Err() |
| - case <-time.After(100 * time.Millisecond): |
| - } |
| - } |
| + return dialUnixRetrying(ctx, d, func() (string, bool) { return socketPath, true }) |
| } |
| func isRetryableDialErr(err error) bool { |
diff --git a/internal/edge/genproxy.go b/internal/edge/genproxy.go| new file mode 100644 |
| index 000000000..bc43dcd53 |
| --- /dev/null |
| +++ b/internal/edge/genproxy.go |
| @@ -0,0 +1,353 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/http/httputil" |
| + "sync" |
| + "syscall" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/unixdial" |
| +) |
| + |
| +// genproxy.go is the edge half of generations (zero-downtime spec §7.2): a |
| +// reverse proxy for an activator-managed route whose app socket moves from |
| +// one generation to the next. Every request asks the activator for the |
| +// current generation and uses that generation's pool, whose connections all |
| +// reach that generation's socket (dialGen), so the response is attributed to |
| +// the generation that served it. A connection to generation n is never |
| +// reused for n+1, and a generation's idle connections are closed when it |
| +// stops being current. |
| +// |
| +// Used only when the activator's GenerationsOn is true. With it false the |
| +// edge builds unixProxy(t.Socket) exactly as before. |
| + |
| +// errNoGeneration is a dial's answer while the route has no current |
| +// generation (between a stop and the next wake). It wraps ENOENT on purpose: |
| +// to dialUnixRetrying it is the same "socket briefly absent" gap a restart |
| +// always had, retried inside the same window, and each retry asks again. |
| +var errNoGeneration = fmt.Errorf("edge: instance has no current generation: %w", syscall.ENOENT) |
| + |
| +// dialUnixRetrying is dialUnixWithRetry with the socket resolved per attempt: |
| +// resolve is asked before the first dial and again before every retry, so a |
| +// dial that races a switch lands on the generation that is current by then. |
| +func dialUnixRetrying(ctx context.Context, d *net.Dialer, resolve func() (string, bool)) (net.Conn, error) { |
| + const window = 13 * time.Second |
| + deadline := time.Now().Add(window) |
| + for { |
| + var ( |
| + conn net.Conn |
| + err error |
| + ) |
| + if sock, ok := resolve(); ok { |
| + conn, err = unixdial.Checked(ctx, d, sock) |
| + } else { |
| + err = errNoGeneration |
| + } |
| + if err == nil { |
| + return conn, nil |
| + } |
| + if ctx.Err() != nil || !isRetryableDialErr(err) || time.Now().After(deadline) { |
| + return nil, err |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return nil, ctx.Err() |
| + case <-time.After(100 * time.Millisecond): |
| + } |
| + } |
| +} |
| + |
| +// genMovedError is a generation pool's dial giving up: its own socket is |
| +// gone and another generation is current. Nothing was sent, so the request |
| +// can go again on the current generation's pool. |
| +type genMovedError struct{ to GenInfo } |
| + |
| +func (e *genMovedError) Error() string { |
| + return fmt.Sprintf("edge: generation moved to %d while dialling the one before it", e.to.N) |
| +} |
| + |
| +// dialGen is a generation pool's dial: own's socket and nothing else, and |
| +// a connection proven to be own's (a shared foreign path is checked |
| +// against the current generation once connected), so every connection in |
| +// the pool is own's and a response through it is attributed to own exactly |
| +// (spec §7.2). The retiring generation still |
| +// accepts until its SIGTERM, which comes after the switch, so a request |
| +// that chose own's pool an instant before the switch is served there. A |
| +// retryable failure is retried inside the usual window while own is still |
| +// current or no generation is; once another one is current, own is gone for |
| +// good and the dial returns genMovedError. |
| +func dialGen(ctx context.Context, d *net.Dialer, src GenSource, host string, own GenInfo) (net.Conn, error) { |
| + const window = 13 * time.Second |
| + deadline := time.Now().Add(window) |
| + for { |
| + conn, err := unixdial.Checked(ctx, d, own.Socket) |
| + if err == nil { |
| + // Spec §7.2 (rev 17): if the generation stops being current |
| + // before the dial completes, the request is retried against the |
| + // new current generation. So every new connection is checked |
| + // once connected: own must still be current. This also covers a |
| + // foreign row, whose generations share one canonical path, so a |
| + // connection on it could have reached the replacement. A |
| + // connection already made (an idle one in own's pool) is not |
| + // re-checked: the request that chose own's pool sampled own. |
| + cur, ok := src.ServingGen(host) |
| + if ok && cur.N == own.N { |
| + return conn, nil |
| + } |
| + conn.Close() |
| + if ok { |
| + return nil, &genMovedError{to: cur} |
| + } |
| + err = errNoGeneration |
| + } |
| + if ctx.Err() != nil || !isRetryableDialErr(err) || time.Now().After(deadline) { |
| + return nil, err |
| + } |
| + if cur, ok := src.ServingGen(host); ok && (cur.N != own.N || cur.Socket != own.Socket) { |
| + return nil, &genMovedError{to: cur} |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return nil, ctx.Err() |
| + case <-time.After(100 * time.Millisecond): |
| + } |
| + } |
| +} |
| + |
| +// genPool is one generation's transport and the generation it reaches. |
| +type genPool struct { |
| + tr *http.Transport |
| + gen GenInfo |
| +} |
| + |
| +// GenPools is the per-socket-path set of transports, one idle pool per |
| +// generation socket. One set per consumer (the edge has one, scheduled |
| +// delivery has its own) and shared across route reloads, because the pool |
| +// belongs to the generation, not to the table build that first reached it. |
| +type GenPools struct { |
| + mu sync.Mutex |
| + m map[string]*genPool |
| +} |
| + |
| +// NewGenPools returns an empty set. |
| +func NewGenPools() *GenPools { return &GenPools{m: map[string]*genPool{}} } |
| + |
| +// pool is generation g's pool, made on first use. Its dial reaches g's own |
| +// socket only (dialGen). A foreign row reuses its canonical path for every |
| +// generation (plan 1's reading), so a pool found for the path under another |
| +// generation number is replaced, and its idle connections closed. |
| +func (p *GenPools) pool(host string, g GenInfo, src GenSource) *genPool { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if gp, ok := p.m[g.Socket]; ok { |
| + if gp.gen.N == g.N { |
| + return gp |
| + } |
| + gp.tr.CloseIdleConnections() |
| + } |
| + gp := &genPool{tr: genTransportFor(host, src, &g), gen: g} |
| + p.m[g.Socket] = gp |
| + return gp |
| +} |
| + |
| +// Retire closes socket's idle connections and forgets its pool. It has the |
| +// shape of GenSource.OnGenRetired's hook. A request still in flight on a |
| +// retired pool finishes; its connection is not reused by anyone after. |
| +func (p *GenPools) Retire(_, socket string) { |
| + p.mu.Lock() |
| + gp, ok := p.m[socket] |
| + delete(p.m, socket) |
| + p.mu.Unlock() |
| + if ok { |
| + gp.tr.CloseIdleConnections() |
| + } |
| +} |
| + |
| +// Len is the number of live pools. |
| +func (p *GenPools) Len() int { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return len(p.m) |
| +} |
| + |
| +// genTransportFor builds the transport behind one pool: unixProxy's idle |
| +// bounds, and a dial that reaches own's socket only (dialGen). RoundTrip |
| +// always passes own: a request that finds no current generation waits for |
| +// one (waitServingGen) and then uses that generation's pool. With own nil |
| +// it is a one-shot transport whose dial resolves the socket per attempt. |
| +func genTransportFor(host string, src GenSource, own *GenInfo) *http.Transport { |
| + dialer := &net.Dialer{} |
| + dial := func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return dialUnixRetrying(ctx, dialer, func() (string, bool) { return src.AppSocket(host) }) |
| + } |
| + if own != nil { |
| + g := *own |
| + dial = func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return dialGen(ctx, dialer, src, host, g) |
| + } |
| + } |
| + return &http.Transport{ |
| + DialContext: dial, |
| + MaxIdleConns: 32, |
| + IdleConnTimeout: 90 * time.Second, |
| + DisableKeepAlives: own == nil, |
| + } |
| +} |
| + |
| +// genRoundTripper picks the pool of host's current generation for each |
| +// request, stamps the response with the build of the generation whose |
| +// connection carried it, and records that generation on the request's |
| +// outcome for the cache (spec §7.2). A pool's connections are all its own |
| +// generation's (dialGen), so the pool's generation is that generation. |
| +type genRoundTripper struct { |
| + host string |
| + src GenSource |
| + pools *GenPools |
| +} |
| + |
| +// genRetries bounds how often one request is sent again after its |
| +// generation moved under it. |
| +const genRetries = 2 |
| + |
| +func (t *genRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { |
| + // The generation is chosen, and any switch retried, before a byte of the |
| + // body is consumed (spec §7.2, rev 17). The transport gets the body |
| + // behind heldBody: it reads it only once it is writing the request on a |
| + // connection dialGen has proven to be the chosen generation's, and a |
| + // Close it makes after a failed dial is held. So a dial that finds the |
| + // generation moved leaves the body untouched, and the request goes again |
| + // on the current generation's pool, a streaming POST with no GetBody |
| + // included. |
| + var hb *heldBody |
| + if req.Body != nil && req.Body != http.NoBody { |
| + hb = &heldBody{rc: req.Body} |
| + req.Body = hb |
| + } |
| + for attempt := 0; ; attempt++ { |
| + g, ok := t.src.ServingGen(t.host) |
| + if !ok { |
| + // No current generation (between a stop and the next wake): wait |
| + // for one inside the dial's retry window, then take the normal |
| + // path, so the response is attributed to the generation that |
| + // serves it and its connection passes dialGen's check (spec §7.2). |
| + g, ok = waitServingGen(req.Context(), t.src, t.host) |
| + if !ok { |
| + if hb != nil { |
| + hb.release() |
| + } |
| + return nil, errNoGeneration |
| + } |
| + } |
| + gp := t.pools.pool(t.host, g, t.src) |
| + resp, err := gp.tr.RoundTrip(req) |
| + var moved *genMovedError |
| + if errors.As(err, &moved) && attempt < genRetries && (hb == nil || !hb.wasRead()) { |
| + // The generation this request chose stopped being current before |
| + // its dial completed. Nothing was sent and nothing of the body |
| + // was read: send it again, on the current one. |
| + continue |
| + } |
| + if err != nil { |
| + if hb != nil { |
| + hb.release() |
| + } |
| + return nil, err |
| + } |
| + // X-Carlos-Version names the build of the generation that served the |
| + // request, not the registry's Version (spec §7.2). Set, not Add: the |
| + // platform's voice on this header is the only one. |
| + if gp.gen.Build != "" { |
| + resp.Header.Set("X-Carlos-Version", gp.gen.Build) |
| + } |
| + noteServedGen(req, t.host, gp.gen.N) |
| + return resp, nil |
| + } |
| +} |
| + |
| +// waitServingGen waits, within the dial's 13 s retry window and ctx, for |
| +// host to have a current generation, asking every 100 ms. |
| +func waitServingGen(ctx context.Context, src GenSource, host string) (GenInfo, bool) { |
| + deadline := time.Now().Add(13 * time.Second) |
| + for { |
| + if g, ok := src.ServingGen(host); ok { |
| + return g, true |
| + } |
| + if time.Now().After(deadline) { |
| + return GenInfo{}, false |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return GenInfo{}, false |
| + case <-time.After(100 * time.Millisecond): |
| + } |
| + } |
| +} |
| + |
| +// heldBody is a request body the transport cannot consume or close before |
| +// the request is on a connection. The transport reads a body only while it |
| +// writes the request on a dialled connection, and closes it on any error, |
| +// including a dial that never connected. Until the first Read, that Close |
| +// is held, so the same body can go again on another generation's pool. |
| +// After it, Close passes through as usual. |
| +type heldBody struct { |
| + rc io.ReadCloser |
| + mu sync.Mutex |
| + read bool |
| +} |
| + |
| +func (b *heldBody) Read(p []byte) (int, error) { |
| + b.mu.Lock() |
| + b.read = true |
| + b.mu.Unlock() |
| + return b.rc.Read(p) |
| +} |
| + |
| +func (b *heldBody) Close() error { |
| + b.mu.Lock() |
| + read := b.read |
| + b.mu.Unlock() |
| + if !read { |
| + return nil // a failed dial's close: the body may still be sent |
| + } |
| + return b.rc.Close() |
| +} |
| + |
| +func (b *heldBody) wasRead() bool { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.read |
| +} |
| + |
| +// release closes the body for good: the request has finished with an error. |
| +func (b *heldBody) release() { _ = b.rc.Close() } |
| + |
| +// appSocketProxy is unixProxy for an activator-managed route with |
| +// generations on: the same Rewrite, Host preservation, upgrade pass-through |
| +// and error handler, over genRoundTripper instead of one fixed socket. |
| +func appSocketProxy(host string, src GenSource, pools *GenPools) *httputil.ReverseProxy { |
| + return &httputil.ReverseProxy{ |
| + Rewrite: func(pr *httputil.ProxyRequest) { |
| + setForwarded(pr) |
| + pr.Out.URL.Scheme = "http" |
| + pr.Out.URL.Host = "carlos" // placeholder; the unix dialer ignores it |
| + pr.Out.Host = pr.In.Host // backend sees the real hostname |
| + scrubInboundPurgeHeader(pr) |
| + }, |
| + Transport: &genRoundTripper{host: host, src: src, pools: pools}, |
| + ErrorHandler: defaultProxyError(proxyCause), |
| + } |
| +} |
| + |
| +// AppSocketProxy is appSocketProxy for callers outside this package: the |
| +// scheduled-work deliverer in internal/host, which must reach the current |
| +// generation the same way the edge does (spec §7.1) without the edge's |
| +// cache, compression or usage layers. |
| +func AppSocketProxy(host string, src GenSource, pools *GenPools) http.Handler { |
| + return appSocketProxy(host, src, pools) |
| +} |
diff --git a/internal/edge/genproxy_test.go b/internal/edge/genproxy_test.go| new file mode 100644 |
| index 000000000..594b55d08 |
| --- /dev/null |
| +++ b/internal/edge/genproxy_test.go |
| @@ -0,0 +1,418 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "syscall" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// fakeGens is a GenSource a test drives by hand: switchTo is plan 3's |
| +// switch as the edge sees it, sleep is a stop. |
| +type fakeGens struct { |
| + mu sync.Mutex |
| + on bool |
| + cur map[string]GenInfo // host -> current generation; absent = no current generation |
| + last map[string]uint64 // host -> CacheGen |
| + asks int // AppSocket calls |
| + retired []func(host, socket string) |
| + // onServing, when set, runs after every ServingGen answer, with no lock |
| + // held: a test's way to land a switch between the pool choice and the |
| + // dial. |
| + onServing func() |
| +} |
| + |
| +func newFakeGens(on bool) *fakeGens { |
| + return &fakeGens{on: on, cur: map[string]GenInfo{}, last: map[string]uint64{}} |
| +} |
| + |
| +func (f *fakeGens) GenerationsOn() bool { return f.on } |
| + |
| +func (f *fakeGens) AppSocket(host string) (string, bool) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.asks++ |
| + g, ok := f.cur[host] |
| + return g.Socket, ok |
| +} |
| + |
| +func (f *fakeGens) ServingGen(host string) (GenInfo, bool) { |
| + f.mu.Lock() |
| + g, ok := f.cur[host] |
| + hook := f.onServing |
| + f.mu.Unlock() |
| + if hook != nil { |
| + hook() |
| + } |
| + return g, ok |
| +} |
| + |
| +func (f *fakeGens) CacheGen(host string) (uint64, bool) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + n, ok := f.last[host] |
| + return n, ok |
| +} |
| + |
| +func (f *fakeGens) OnGenRetired(fn func(host, socket string)) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.retired = append(f.retired, fn) |
| +} |
| + |
| +// switchTo makes g host's current generation, then tells the retire hooks |
| +// about the one it replaced. |
| +func (f *fakeGens) switchTo(host string, g GenInfo) { |
| + f.mu.Lock() |
| + old, had := f.cur[host] |
| + f.cur[host], f.last[host] = g, g.N |
| + hooks := append([]func(string, string){}, f.retired...) |
| + f.mu.Unlock() |
| + if had { |
| + for _, fn := range hooks { |
| + fn(host, old.Socket) |
| + } |
| + } |
| +} |
| + |
| +// sleep takes host's current generation away and keeps its number for the |
| +// cache, as the activator does at a stop. |
| +func (f *fakeGens) sleep(host string) { |
| + f.mu.Lock() |
| + old, had := f.cur[host] |
| + delete(f.cur, host) |
| + hooks := append([]func(string, string){}, f.retired...) |
| + f.mu.Unlock() |
| + if had { |
| + for _, fn := range hooks { |
| + fn(host, old.Socket) |
| + } |
| + } |
| +} |
| + |
| +func (f *fakeGens) askCount() int { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return f.asks |
| +} |
| + |
| +// genSockDir is a short directory for generation sockets. Run with |
| +// TMPDIR=/tmp/k: a socket path must stay under sockaddr_un's 108 bytes. |
| +func genSockDir(t *testing.T) string { |
| + t.Helper() |
| + dir, err := os.MkdirTemp("", "e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + return dir |
| +} |
| + |
| +// genApp is one generation's process: an HTTP server on a unix socket that |
| +// counts the connections it opened and closed. |
| +type genApp struct { |
| + sock string |
| + ln net.Listener |
| + opened, closed atomic.Int64 |
| + srv *http.Server |
| +} |
| + |
| +func startGenApp(t *testing.T, sock string, h http.Handler) *genApp { |
| + t.Helper() |
| + ln, err := net.Listen("unix", sock) |
| + if err != nil { |
| + if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") { |
| + t.Skipf("unix-socket listeners not permitted in this environment: %v", err) |
| + } |
| + t.Fatalf("listen %s: %v", sock, err) |
| + } |
| + a := &genApp{sock: sock, ln: ln} |
| + a.srv = &http.Server{Handler: h, ConnState: func(_ net.Conn, st http.ConnState) { |
| + switch st { |
| + case http.StateNew: |
| + a.opened.Add(1) |
| + case http.StateClosed, http.StateHijacked: |
| + a.closed.Add(1) |
| + } |
| + }} |
| + go a.srv.Serve(ln) |
| + t.Cleanup(func() { a.srv.Close() }) |
| + return a |
| +} |
| + |
| +// stop is the generation's process exiting: the server and its listener close now, |
| +// so the socket file is unlinked before stop returns (srv.Close alone misses a |
| +// listener Serve has not registered yet, and Serve's late close would unlink the |
| +// path by name, possibly a replacement's socket). |
| +func (a *genApp) stop() { a.srv.Close(); a.ln.Close() } |
| + |
| +// body answers b with cache-control cc ("" for none). |
| +func body(b, cc string) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| + if cc != "" { |
| + w.Header().Set("Cache-Control", cc) |
| + } |
| + io.WriteString(w, b) |
| + }) |
| +} |
| + |
| +func serve(t *testing.T, h http.Handler, url string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, httptest.NewRequest("GET", url, nil)) |
| + return rec |
| +} |
| + |
| +// Each retry asks the source again: a dial that races a switch reaches the |
| +// generation that is current by the time it retries. |
| +func TestDialUnixRetryingAsksAgainOnEveryRetry(t *testing.T) { |
| + dir := genSockDir(t) |
| + live := startGenApp(t, filepath.Join(dir, "2.sock"), body("B", "")) |
| + var asks atomic.Int64 |
| + resolve := func() (string, bool) { |
| + switch asks.Add(1) { |
| + case 1: |
| + return "", false // between generations: no current one yet |
| + case 2: |
| + return filepath.Join(dir, "1.sock"), true // a retired generation: its file is gone |
| + default: |
| + return live.sock, true |
| + } |
| + } |
| + conn, err := dialUnixRetrying(context.Background(), &net.Dialer{}, resolve) |
| + if err != nil { |
| + t.Fatalf("dial: %v", err) |
| + } |
| + conn.Close() |
| + if got := asks.Load(); got != 3 { |
| + t.Fatalf("resolve asked %d times, want 3 (once per attempt)", got) |
| + } |
| +} |
| + |
| +// A connection to generation n is never reused for n+1, and n's idle |
| +// connections close when it stops being current. The version header names |
| +// the build of the generation that served each request. |
| +func TestAppSocketProxyFollowsTheCurrentGeneration(t *testing.T) { |
| + dir := genSockDir(t) |
| + a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", "")) |
| + b := startGenApp(t, filepath.Join(dir, "b.sock"), body("B", "")) |
| + src := newFakeGens(true) |
| + pools := NewGenPools() |
| + src.OnGenRetired(pools.Retire) |
| + const host = "inst.example.com" |
| + src.switchTo(host, GenInfo{Socket: a.sock, N: 1, Build: "v1"}) |
| + p := appSocketProxy(host, src, pools) |
| + |
| + for i := 0; i < 2; i++ { |
| + rec := serve(t, p, "http://inst.example.com/") |
| + if rec.Body.String() != "A" || rec.Header().Get("X-Carlos-Version") != "v1" { |
| + t.Fatalf("request %d: %q version %q, want A from v1", i, rec.Body.String(), rec.Header().Get("X-Carlos-Version")) |
| + } |
| + } |
| + if got := a.opened.Load(); got != 1 { |
| + t.Fatalf("generation 1 saw %d connections, want 1 (keep-alive reused)", got) |
| + } |
| + |
| + src.switchTo(host, GenInfo{Socket: b.sock, N: 2, Build: "v2"}) |
| + rec := serve(t, p, "http://inst.example.com/") |
| + if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" { |
| + t.Fatalf("after the switch: %q version %q, want B from v2", rec.Body.String(), rec.Header().Get("X-Carlos-Version")) |
| + } |
| + if got := a.opened.Load(); got != 1 { |
| + t.Fatalf("generation 1 saw %d connections after the switch, want still 1", got) |
| + } |
| + deadline := time.Now().Add(2 * time.Second) |
| + for a.closed.Load() != 1 && time.Now().Before(deadline) { |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + if got := a.closed.Load(); got != 1 { |
| + t.Fatalf("generation 1's idle connection was not closed at retirement (closed=%d)", got) |
| + } |
| + if got := pools.Len(); got != 1 { |
| + t.Fatalf("%d pools live, want 1 (the retired one forgotten)", got) |
| + } |
| +} |
| + |
| +// Spec §7.2: the version header and the cache's generation name the |
| +// generation whose connection carried the request. Here the switch lands |
| +// between the pool choice and the dial. With a pool whose dial re-resolves |
| +// AppSocket, the request reached B while it was stamped and tagged as A. |
| +func TestAppSocketProxyAttributesTheConnectionsGeneration(t *testing.T) { |
| + const host = "inst.example.com" |
| + // Rev 17: a switch that lands before the dial completes sends the request |
| + // to the new current generation, even though the retiring one still |
| + // accepts. Its response is attributed to the generation that served it. |
| + t.Run("a switch before the dial completes", func(t *testing.T) { |
| + dir := genSockDir(t) |
| + a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", "")) |
| + b := startGenApp(t, filepath.Join(dir, "b.sock"), body("B", "")) |
| + src := newFakeGens(true) |
| + src.switchTo(host, GenInfo{Socket: a.sock, N: 1, Build: "v1"}) |
| + var once sync.Once |
| + src.onServing = func() { |
| + once.Do(func() { src.switchTo(host, GenInfo{Socket: b.sock, N: 2, Build: "v2"}) }) |
| + } |
| + p := appSocketProxy(host, src, NewGenPools()) |
| + r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil)) |
| + rec := httptest.NewRecorder() |
| + p.ServeHTTP(rec, r) |
| + _, n, _ := servedGen(r) |
| + if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" || n != 2 { |
| + t.Fatalf("served %q, version %q, generation %d: want B, v2, 2 (retried on the current generation)", |
| + rec.Body.String(), rec.Header().Get("X-Carlos-Version"), n) |
| + } |
| + if got := b.opened.Load(); got < 1 { |
| + t.Fatal("generation 2 was never reached") |
| + } |
| + _ = a |
| + }) |
| + // A proxied POST streams its body: no GetBody, unknown length. A switch |
| + // before the dial completes must not turn it into a 502. The generation |
| + // is settled before a byte of the body is read, so the body goes, whole, |
| + // to the current generation. Retrying only replayable requests failed it. |
| + t.Run("a streaming POST across a switch", func(t *testing.T) { |
| + dir := genSockDir(t) |
| + a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", "")) |
| + echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, _ := io.ReadAll(r.Body) |
| + io.WriteString(w, "B:"+string(b)) |
| + }) |
| + b := startGenApp(t, filepath.Join(dir, "b.sock"), echo) |
| + src := newFakeGens(true) |
| + src.switchTo(host, GenInfo{Socket: a.sock, N: 1, Build: "v1"}) |
| + var once sync.Once |
| + src.onServing = func() { |
| + once.Do(func() { |
| + a.stop() // generation 1 has gone: its socket file with it |
| + src.switchTo(host, GenInfo{Socket: b.sock, N: 2, Build: "v2"}) |
| + }) |
| + } |
| + pr, pw := io.Pipe() |
| + go func() { |
| + io.WriteString(pw, "hello, ") |
| + io.WriteString(pw, "world") |
| + pw.Close() |
| + }() |
| + req := httptest.NewRequest("POST", "http://inst.example.com/form", pr) |
| + req.ContentLength = -1 |
| + if req.GetBody != nil { |
| + t.Fatal("fixture: the request must have no GetBody") |
| + } |
| + r, _ := withOutcome(req) |
| + rec := httptest.NewRecorder() |
| + appSocketProxy(host, src, NewGenPools()).ServeHTTP(rec, r) |
| + if rec.Code != http.StatusOK || rec.Body.String() != "B:hello, world" || rec.Header().Get("X-Carlos-Version") != "v2" { |
| + t.Fatalf("status %d, body %q, version %q: want 200, the whole body at generation 2, v2", |
| + rec.Code, rec.Body.String(), rec.Header().Get("X-Carlos-Version")) |
| + } |
| + _ = b |
| + }) |
| + t.Run("the sampled generation is gone", func(t *testing.T) { |
| + dir := genSockDir(t) |
| + a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", "")) |
| + b := startGenApp(t, filepath.Join(dir, "b.sock"), body("B", "")) |
| + src := newFakeGens(true) |
| + src.switchTo(host, GenInfo{Socket: a.sock, N: 1, Build: "v1"}) |
| + var once sync.Once |
| + src.onServing = func() { |
| + once.Do(func() { |
| + a.stop() // A has exited: its socket file is gone |
| + src.switchTo(host, GenInfo{Socket: b.sock, N: 2, Build: "v2"}) |
| + }) |
| + } |
| + p := appSocketProxy(host, src, NewGenPools()) |
| + r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil)) |
| + rec := httptest.NewRecorder() |
| + p.ServeHTTP(rec, r) |
| + _, n, _ := servedGen(r) |
| + if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" || n != 2 { |
| + t.Fatalf("served %q, version %q, generation %d: want B, v2, 2 (sent again on the current pool)", |
| + rec.Body.String(), rec.Header().Get("X-Carlos-Version"), n) |
| + } |
| + }) |
| + // A foreign row's generations share one path. Generation 1's pool, chosen |
| + // an instant before the hold, dials that path after generation 2 has |
| + // bound it: the connection reaches 2, and a dial that trusts the path |
| + // stamps 2's response as 1 and keeps the connection in 1's pool. |
| + t.Run("a foreign row's path now belongs to the replacement", func(t *testing.T) { |
| + dir := genSockDir(t) |
| + path := filepath.Join(dir, "canonical.sock") |
| + a := startGenApp(t, path, body("A", "")) |
| + src := newFakeGens(true) |
| + src.switchTo(host, GenInfo{Socket: path, N: 1, Build: "v1"}) |
| + var once sync.Once |
| + var b *genApp |
| + src.onServing = func() { |
| + once.Do(func() { |
| + a.stop() // the hold stops 1; its socket file goes |
| + b = startGenApp(t, path, body("B", "")) |
| + src.switchTo(host, GenInfo{Socket: path, N: 2, Build: "v2"}) |
| + }) |
| + } |
| + p := appSocketProxy(host, src, NewGenPools()) |
| + r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil)) |
| + rec := httptest.NewRecorder() |
| + p.ServeHTTP(rec, r) |
| + _, n, _ := servedGen(r) |
| + if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" || n != 2 { |
| + t.Fatalf("served %q, version %q, generation %d: want B, v2, 2 (never attributed to the old generation)", |
| + rec.Body.String(), rec.Header().Get("X-Carlos-Version"), n) |
| + } |
| + if b == nil || b.opened.Load() < 1 { |
| + t.Fatal("the replacement was never reached") |
| + } |
| + }) |
| +} |
| + |
| +// The generation that carried a request is recorded on its outcome, for the |
| +// cache to tag with. |
| +func TestAppSocketProxyNotesTheServedGeneration(t *testing.T) { |
| + dir := genSockDir(t) |
| + a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", "")) |
| + src := newFakeGens(true) |
| + src.switchTo("inst.example.com", GenInfo{Socket: a.sock, N: 7, Build: "v1"}) |
| + p := appSocketProxy("inst.example.com", src, NewGenPools()) |
| + r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil)) |
| + p.ServeHTTP(httptest.NewRecorder(), r) |
| + if host, n, ok := servedGen(r); !ok || host != "inst.example.com" || n != 7 { |
| + t.Fatalf("servedGen = %q %d %v, want inst.example.com 7", host, n, ok) |
| + } |
| +} |
| + |
| +// With no current generation the request waits inside the dial's window for |
| +// one, then goes the normal way: through that generation's pool, stamped |
| +// with its build and tagged with its number (spec §7.2). An unbound one-shot |
| +// transport returned the response unattributed, with no platform version. |
| +func TestAppSocketProxyWaitsForAGeneration(t *testing.T) { |
| + dir := genSockDir(t) |
| + a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", "")) |
| + src := newFakeGens(true) |
| + pools := NewGenPools() |
| + p := appSocketProxy("inst.example.com", src, pools) |
| + go func() { |
| + time.Sleep(300 * time.Millisecond) |
| + src.switchTo("inst.example.com", GenInfo{Socket: a.sock, N: 3, Build: "v1"}) |
| + }() |
| + r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil)) |
| + rec := httptest.NewRecorder() |
| + p.ServeHTTP(rec, r) |
| + if rec.Body.String() != "A" || rec.Header().Get("X-Carlos-Version") != "v1" { |
| + t.Fatalf("body %q, version %q: want A from v1 once the generation appeared", rec.Body.String(), rec.Header().Get("X-Carlos-Version")) |
| + } |
| + if host, n, ok := servedGen(r); !ok || host != "inst.example.com" || n != 3 { |
| + t.Fatalf("servedGen = %q %d %v, want generation 3", host, n, ok) |
| + } |
| + if pools.Len() != 1 { |
| + t.Fatalf("%d pools, want generation 3's", pools.Len()) |
| + } |
| +} |