Files
felhom.eu/hub/internal/intent/presence.go
T
admin d604e624a3 hub: box presence from the wait channel + delete a switched-off box at once (R-30, D2)
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
2026-10-08 15:17:05 +02:00

171 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}