diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 5060dde2..cc0a51d0 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -181,10 +181,21 @@ type Node struct { // online. Written by the HA prober. Runtime-only. Unhealthy bool `gorm:"-"` - // SessionEpoch identifies a poll session. Connect bumps it; a - // Disconnect carrying a stale value is dropped, so a deferred - // disconnect from a previous session cannot overwrite a newer - // Connect. Runtime-only. + // ActiveSessions counts live poll sessions for this node. + // [State.Connect] increments it and every session release + // ([State.Disconnect]) decrements it, so the node goes offline + // exactly when its last session ends — regardless of the order in + // which overlapping sessions' cleanups run. Never persisted, like + // SessionEpoch. + ActiveSessions int `gorm:"-"` + + // SessionEpoch identifies a poll session generation; Connect bumps + // it. It complements ActiveSessions rather than duplicating it: + // the epoch is monotonic, which the HA prober needs to detect that + // a probe target reconnected mid-cycle — a refcount can return to + // its old value, a generation cannot. poll.go also uses the epoch + // returned by Connect as a "Connect ran" sentinel for its cleanup, + // and Disconnect logs it. Runtime-only. SessionEpoch uint64 `gorm:"-"` } diff --git a/hscontrol/types/types_clone.go b/hscontrol/types/types_clone.go index a14dc6fd..315cabf8 100644 --- a/hscontrol/types/types_clone.go +++ b/hscontrol/types/types_clone.go @@ -106,6 +106,7 @@ var _NodeCloneNeedsRegeneration = Node(struct { DeletedAt *time.Time IsOnline *bool Unhealthy bool + ActiveSessions int SessionEpoch uint64 }{}) diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index 3a9b3b42..51db6253 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -262,10 +262,21 @@ func (v NodeView) IsOnline() views.ValuePointer[bool] { return views.ValuePointe // online. Written by the HA prober. Runtime-only. func (v NodeView) Unhealthy() bool { return v.ж.Unhealthy } -// SessionEpoch identifies a poll session. Connect bumps it; a -// Disconnect carrying a stale value is dropped, so a deferred -// disconnect from a previous session cannot overwrite a newer -// Connect. Runtime-only. +// ActiveSessions counts live poll sessions for this node. +// [State.Connect] increments it and every session release +// ([State.Disconnect]) decrements it, so the node goes offline +// exactly when its last session ends — regardless of the order in +// which overlapping sessions' cleanups run. Never persisted, like +// SessionEpoch. +func (v NodeView) ActiveSessions() int { return v.ж.ActiveSessions } + +// SessionEpoch identifies a poll session generation; Connect bumps +// it. It complements ActiveSessions rather than duplicating it: +// the epoch is monotonic, which the HA prober needs to detect that +// a probe target reconnected mid-cycle — a refcount can return to +// its old value, a generation cannot. poll.go also uses the epoch +// returned by Connect as a "Connect ran" sentinel for its cleanup, +// and Disconnect logs it. Runtime-only. func (v NodeView) SessionEpoch() uint64 { return v.ж.SessionEpoch } func (v NodeView) String() string { return v.ж.String() } @@ -295,6 +306,7 @@ var _NodeViewNeedsRegeneration = Node(struct { DeletedAt *time.Time IsOnline *bool Unhealthy bool + ActiveSessions int SessionEpoch uint64 }{})