| index bfd0fda66..affdadd91 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -1823,10 +1823,17 @@ func fleetProxy(fleet, boxLabel string, hub *TunnelHub) *httputil.ReverseProxy { |
| // A var, not a const, so a test can shorten it — the same reason backhaulPort |
| // is one. There is deliberately no "off" value: an unbounded cross-region |
| // path is the thing this closes. |
| -var backhaulMaxInflight = defaultBackhaulMaxInflight |
| +// |
| +// Atomic rather than a plain int because it is READ on the request path by |
| +// every backhauling goroutine: it is written once today, before any listener |
| +// binds, but a future env re-read (or a second Edge composed in one process) |
| +// would turn a plain int into a real data race rather than a stale read. |
| +var backhaulMaxInflight atomic.Int64 |
| |
| const defaultBackhaulMaxInflight = 64 |
| |
| +func init() { backhaulMaxInflight.Store(defaultBackhaulMaxInflight) } |
| + |
| // backhaulInflightFromEnv reads CARLOS_BACKHAUL_MAX_INFLIGHT. Unset, |
| // unparseable or ≤ 0 all mean the default: a typo'd knob must not silently |
| // uncap (or, worse, zero-cap) a deployment's backhaul. |
| @@ -1844,20 +1851,28 @@ func backhaulInflightFromEnv() int { |
| } |
| |
| // backhaulAcquire reserves one in-flight slot toward box, or reports |
| -// saturation. release must be called exactly once when the hop's ServeHTTP |
| -// returns — a streaming or upgraded response holds its slot for the life of |
| -// the stream, deliberately: a long-lived stream IS sustained cross-region |
| -// egress, which is the thing being bounded. |
| +// saturation. releaseSlot must be called exactly once when the hop's |
| +// ServeHTTP returns — a streaming or upgraded response holds its slot for the |
| +// life of the stream, deliberately: a long-lived stream IS sustained |
| +// cross-region egress, which is the thing being bounded. |
| // |
| // The counter is per home BOX and the map is never keyed by anything a |
| -// stranger controls (backhaulInflight's own doc). Over-admission by a hair is |
| -// impossible and under-admission is not attempted: the count is incremented |
| -// first and rolled back on refusal, so two racing requests at the limit both |
| -// see the excess and one of them refuses. |
| -func (e *Edge) backhaulAcquire(box string) (release func(), ok bool) { |
| - v, _ := e.backhaulInflight.LoadOrStore(box, new(atomic.Int64)) |
| +// stranger controls (backhaulInflight's own doc). The count is incremented |
| +// FIRST and rolled back on refusal, so the cap can never be exceeded: two |
| +// requests racing at the limit both see the excess and both refuse, and a |
| +// request arriving inside another's rollback window can be refused while a |
| +// slot is in fact free. Both errors are in the safe direction — a refusal is |
| +// a 503 the client retries in two seconds, where an over-admission would be |
| +// cross-region bytes the cap exists to prevent. |
| +func (e *Edge) backhaulAcquire(box string) (releaseSlot func(), ok bool) { |
| + // Load first, like backhaulTransport: LoadOrStore would allocate a |
| + // counter per backhauled request just to throw it away on the hit path. |
| + v, hit := e.backhaulInflight.Load(box) |
| + if !hit { |
| + v, _ = e.backhaulInflight.LoadOrStore(box, new(atomic.Int64)) |
| + } |
| n := v.(*atomic.Int64) |
| - if n.Add(1) > int64(backhaulMaxInflight) { |
| + if n.Add(1) > backhaulMaxInflight.Load() { |
| n.Add(-1) |
| return nil, false |
| } |
| @@ -1943,17 +1958,17 @@ func (e *Edge) backhaul(w http.ResponseWriter, r *http.Request, host string) boo |
| // edge, which would lie to a legitimate client about where its app lives. |
| // The log key is per home BOX, like "backhaul-error "+box below and for |
| // the same bounded-key reason. |
| - release, ok := e.backhaulAcquire(box) |
| + releaseSlot, ok := e.backhaulAcquire(box) |
| if !ok { |
| e.logThrottled("backhaul-saturated "+box, |
| - fmt.Sprintf("backhaul to %s (%s) is at its in-flight cap (%d) — answering 503 + Retry-After: 2", box, eip, backhaulMaxInflight)) |
| + fmt.Sprintf("backhaul to %s (%s) is at its in-flight cap (%d) — answering 503 + Retry-After: 2", box, eip, backhaulMaxInflight.Load())) |
| w.Header().Set("Retry-After", "2") |
| http.Error(w, "temporarily busy", http.StatusServiceUnavailable) |
| return true |
| } |
| // Held until the hop's ServeHTTP returns, which for a stream or an |
| // upgraded connection is the life of the stream (backhaulAcquire). |
| - defer release() |
| + defer releaseSlot() |
| // The MAC is over the CONCRETE request host — never the wildcard map key |
| // that may have matched it — because the home edge verifies against its own |
| // literal request host (hopMAC canonicalizes both, mesh.go). |
| @@ -2197,10 +2212,10 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| log.Fatalf("edge: tunnels: %v", err) |
| } |
| |
| - // The backhaul in-flight cap, read once here rather than per request |
| - // (backhaulMaxInflight). Inert on a single-box deployment, where nothing |
| - // is ever backhauled at all. |
| - backhaulMaxInflight = backhaulInflightFromEnv() |
| + // The backhaul in-flight cap, read from the environment once here rather |
| + // than per request (backhaulMaxInflight). Inert on a single-box |
| + // deployment, where nothing is ever backhauled at all. |
| + backhaulMaxInflight.Store(int64(backhaulInflightFromEnv())) |
| |
| go e.Watch(make(chan struct{})) // process-lifetime; never stopped |
| |