d604e624a3
Slice 1: intent.Hub records one start/end per GET /api/v1/wait request and answers Presence(customer, now): connected (hold open, or one started < 333 s = 243 s cadence + 90 s grace ago), not connected since T, or unknown (hub up < 333 s; in memory only). The host page shows "Box connection". One DEBUG line per presence change. Slice 2 (operator ruling D2, 2026-10-08, 09 §3 decision 186): a host that is online by its report clock but whose box has had no wait-channel connection for >= 360 s may be deleted at once after the tick "I checked: the box is off". Presence is re-read at POST time; the tick alone, unknown presence, a connected box or a shorter gap keep today's 409. The delete logs the operator channel and saves one host_deleted_box_off event. RESET and the customer-delete cascade are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
171 lines
5.1 KiB
Go
171 lines
5.1 KiB
Go
package intent
|
||
|
||
import (
|
||
"time"
|
||
)
|
||
|
||
// Box presence from the wait channel (R-30, design `audits/day-2026-10-08/design-R-30.md` option B).
|
||
//
|
||
// A healthy controller holds GET /api/v1/wait for waitMaxHold (240 s) and starts the next hold at
|
||
// once; measured on the ingress 2026-10-08: one new wait per box every 241–243 s. So "a hold is open,
|
||
// or one STARTED less than PresenceConnectedWindow ago" means the box's controller is running and
|
||
// reaching the hub. A box that loses power ends its hold normally (the hub ends it at 240 s) and then
|
||
// no new wait arrives — that gap is the signal.
|
||
//
|
||
// IN MEMORY ONLY, like the generation counter. A hub that started less than PresenceConnectedWindow
|
||
// ago has not had the chance to see a healthy box's next wait, so it answers PresenceUnknown — never
|
||
// PresenceNotConnected. Callers fall back to the report clock on Unknown (the safe direction).
|
||
// Pinned by presence_test.go TestPresence_HubRestartIsUnknown.
|
||
const (
|
||
// PresenceWaitCadence is the measured interval between two wait starts from one healthy box.
|
||
PresenceWaitCadence = 243 * time.Second
|
||
// PresenceGrace absorbs a slow reconnect (TLS, DNS, a busy box) on top of the cadence.
|
||
PresenceGrace = 90 * time.Second
|
||
// PresenceConnectedWindow = cadence + grace (333 s): a wait started within it means "connected".
|
||
PresenceConnectedWindow = PresenceWaitCadence + PresenceGrace
|
||
)
|
||
|
||
// Presence states.
|
||
const (
|
||
PresenceConnected = "connected"
|
||
PresenceNotConnected = "not_connected"
|
||
PresenceUnknown = "unknown"
|
||
)
|
||
|
||
// Presence is one customer's box-connection verdict at a moment.
|
||
type Presence struct {
|
||
State string
|
||
// Since is the start of the not-connected span (the last hold's end, or the hub's own start when
|
||
// no wait was seen since). Zero unless State == PresenceNotConnected.
|
||
Since time.Time
|
||
// NeverSeen: no wait from this customer since the hub started (Since is then the hub's start).
|
||
NeverSeen bool
|
||
}
|
||
|
||
// NotConnectedFor is how long the box has been without a connection at now (0 unless not connected).
|
||
func (p Presence) NotConnectedFor(now time.Time) time.Duration {
|
||
if p.State != PresenceNotConnected {
|
||
return 0
|
||
}
|
||
if d := now.Sub(p.Since); d > 0 {
|
||
return d
|
||
}
|
||
return 0
|
||
}
|
||
|
||
type presenceRec struct {
|
||
open int
|
||
lastStart time.Time
|
||
lastEnd time.Time
|
||
lastState string // for the debug line on a change
|
||
}
|
||
|
||
func (h *Hub) clock() time.Time {
|
||
if h.now != nil {
|
||
return h.now()
|
||
}
|
||
return time.Now()
|
||
}
|
||
|
||
// SetClock replaces the presence clock (tests). Must be called before use; also resets the hub's
|
||
// start time to the new clock's now.
|
||
func (h *Hub) SetClock(now func() time.Time) {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
h.now = now
|
||
h.born = h.clock()
|
||
}
|
||
|
||
// SetLogger wires a Printf-style logger for the presence debug lines. Nil = silent.
|
||
func (h *Hub) SetLogger(logf func(format string, args ...any)) {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
h.logf = logf
|
||
}
|
||
|
||
func (h *Hub) rec(customerID string) *presenceRec {
|
||
r := h.presence[customerID]
|
||
if r == nil {
|
||
r = &presenceRec{}
|
||
h.presence[customerID] = r
|
||
}
|
||
return r
|
||
}
|
||
|
||
// MarkWaitStart records that a wait request from the customer's box began. Called ONCE per HTTP
|
||
// request by the wait handler (not inside Wait, which runs once per heartbeat window).
|
||
func (h *Hub) MarkWaitStart(customerID string) {
|
||
if customerID == "" {
|
||
return
|
||
}
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
now := h.clock()
|
||
r := h.rec(customerID)
|
||
r.open++
|
||
r.lastStart = now
|
||
h.noteStateLocked(customerID, r, now)
|
||
}
|
||
|
||
// MarkWaitEnd records that a wait request ended (any exit path).
|
||
func (h *Hub) MarkWaitEnd(customerID string) {
|
||
if customerID == "" {
|
||
return
|
||
}
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
r := h.rec(customerID)
|
||
if r.open > 0 {
|
||
r.open--
|
||
}
|
||
r.lastEnd = h.clock()
|
||
}
|
||
|
||
// Presence answers the customer's box-connection verdict at now.
|
||
func (h *Hub) Presence(customerID string, now time.Time) Presence {
|
||
if customerID == "" {
|
||
return Presence{State: PresenceUnknown}
|
||
}
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
r := h.presence[customerID]
|
||
p := h.presenceLocked(r, now)
|
||
if r != nil {
|
||
h.noteStateLocked(customerID, r, now)
|
||
}
|
||
return p
|
||
}
|
||
|
||
func (h *Hub) presenceLocked(r *presenceRec, now time.Time) Presence {
|
||
if r != nil {
|
||
if r.open > 0 || (!r.lastStart.IsZero() && now.Sub(r.lastStart) < PresenceConnectedWindow) {
|
||
return Presence{State: PresenceConnected}
|
||
}
|
||
}
|
||
// The hub itself has not been up long enough to have seen a healthy box's next wait.
|
||
if now.Sub(h.born) < PresenceConnectedWindow {
|
||
return Presence{State: PresenceUnknown}
|
||
}
|
||
if r == nil || r.lastStart.IsZero() {
|
||
return Presence{State: PresenceNotConnected, Since: h.born, NeverSeen: true}
|
||
}
|
||
since := r.lastEnd
|
||
if since.Before(r.lastStart) {
|
||
since = r.lastStart
|
||
}
|
||
return Presence{State: PresenceNotConnected, Since: since}
|
||
}
|
||
|
||
// noteStateLocked writes one DEBUG line when a customer's presence verdict changes.
|
||
func (h *Hub) noteStateLocked(customerID string, r *presenceRec, now time.Time) {
|
||
st := h.presenceLocked(r, now).State
|
||
if st == r.lastState {
|
||
return
|
||
}
|
||
prev := r.lastState
|
||
r.lastState = st
|
||
if h.logf != nil && prev != "" {
|
||
h.logf("[DEBUG] box presence %s: %s -> %s (open holds %d)", customerID, prev, st, r.open)
|
||
}
|
||
}
|