feat(hub): Direction-2 immediate-sync wait channel (v0.58.0)
GET /api/v1/wait long-poll: the box holds an authed hanging GET; the hub completes it the instant any operator intent bumps that customer's in-memory generation, then the box fires its ordinary report and the ACK delivers everything through the unchanged machinery. 240s hold with a 25s heartbeat newline defeats the nginx 60s proxy_read_timeout with no ingress annotation; WriteTimeout lifted per-connection via ResponseController. - internal/intent: per-customer generation counter + waiter registry (Bump/Wait/Close), coalescing to latest, race-closer, in-memory by design. Red-proofs: counter-vs-queue + race-closer (run-fail-reverted). - api/wait.go: the endpoint (per-customer only; global key 400; A cannot see B). - web bumps after every intent write (fire-after-commit): config CRUD, claim resend, offsite re-issue/freeze, password regen, block/unblock, floors (global bumps all config-managed), controller log-tail + log-bundle. - main.go: one intent hub shared by web+api; Close() before server.Shutdown. Pairs with controller v0.140.0 (the long-poll client). Grounding: documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// waitMaxHold / waitHeartbeat are effectively constants — vars only so tests can shrink them.
|
||||
var (
|
||||
// waitMaxHold is how long the hub holds a wait before completing it with the (unchanged)
|
||||
// generation. Kept well under the ingress/NAT idle ceilings the transport spike measured
|
||||
// (SPIKE-immediate-sync-transport-2026-07-16.md): the nginx 60 s proxy_read_timeout is defeated
|
||||
// by the heartbeat below, and the operator NAT held an idle hold > 600 s. 240 s → a box
|
||||
// reconnects roughly every 4 min in steady state (negligible fleet load).
|
||||
waitMaxHold = 240 * time.Second
|
||||
// waitHeartbeat is the spacing of keepalive newline bytes written while holding. nginx's
|
||||
// proxy_read_timeout is measured BETWEEN successive reads from the upstream, so a byte every
|
||||
// 25 s keeps the default 60 s from ever firing — no ingress annotation / manifest change is
|
||||
// needed (§13 proves this live; the annotation is the documented fallback if it doesn't hold).
|
||||
// 25 s mirrors the WG PersistentKeepalive cadence and doubles as NAT keepalive.
|
||||
waitHeartbeat = 25 * time.Second
|
||||
)
|
||||
|
||||
// handleWait is the Direction-2 immediate-sync long-poll (option b of the transport spike). A box
|
||||
// holds this authenticated GET carrying its last-seen generation (?gen=N); the hub completes it the
|
||||
// instant any operator intent lands for the box's customer (intent.Hub.Bump from the web handlers),
|
||||
// or after waitMaxHold, whichever comes first. The response is CONTENTLESS — a single {"gen":N}
|
||||
// line preceded by heartbeat newlines. The box reacts by firing its ordinary out-of-cycle report;
|
||||
// the report ACK then delivers config/escrow/claim/floor through the UNCHANGED machinery. The hub
|
||||
// never connects inbound and never rides state on this channel (ground truth #1).
|
||||
func (h *Handler) handleWait(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, isGlobal, ok := h.checkAuthCustomer(r)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Per-customer only: the key RESOLVES the customer and no customer_id parameter is accepted,
|
||||
// so customer A can never observe B's generation. A global operator key has no single customer
|
||||
// to wait on → 400 (the controller always holds a per-customer key).
|
||||
if isGlobal {
|
||||
http.Error(w, "wait is per-customer (present a customer key)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if h.intentHub == nil {
|
||||
http.Error(w, "wait channel unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
var lastSeen uint64
|
||||
if s := r.URL.Query().Get("gen"); s != "" {
|
||||
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
|
||||
lastSeen = v
|
||||
}
|
||||
}
|
||||
|
||||
// Lift THIS connection's write deadline past the hold. The global http.Server.WriteTimeout is
|
||||
// 60 s (main.go — deliberately untouched); http.NewResponseController overrides it per-connection
|
||||
// (Go 1.20+). Without this the held response is cut at 60 s regardless of the ingress.
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(waitMaxHold + 30*time.Second))
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // ask nginx not to buffer the streamed hold
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := rc.Flush(); err != nil {
|
||||
return // client already gone
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(waitMaxHold)
|
||||
for {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
// Hold elapsed with no change: complete with the current generation (Scenario B).
|
||||
h.writeWaitGen(w, rc, h.intentHub.Generation(customerID))
|
||||
return
|
||||
}
|
||||
sub := remaining
|
||||
if sub > waitHeartbeat {
|
||||
sub = waitHeartbeat
|
||||
}
|
||||
gen := h.intentHub.Wait(r.Context(), customerID, lastSeen, sub)
|
||||
if gen != lastSeen {
|
||||
h.writeWaitGen(w, rc, gen) // intent moved → complete immediately
|
||||
return
|
||||
}
|
||||
// No change within this heartbeat window: emit a keepalive byte so the ingress read-timeout
|
||||
// never fires. A write/flush failure means the client is gone — return quietly (no log
|
||||
// noise, no goroutine leak; the intent.Wait above already deregistered).
|
||||
if _, err := w.Write([]byte("\n")); err != nil {
|
||||
return
|
||||
}
|
||||
if err := rc.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeWaitGen emits the single contentless completion line and flushes.
|
||||
func (h *Handler) writeWaitGen(w http.ResponseWriter, rc *http.ResponseController, gen uint64) {
|
||||
fmt.Fprintf(w, "{\"gen\":%d}\n", gen)
|
||||
_ = rc.Flush()
|
||||
}
|
||||
Reference in New Issue
Block a user