mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-23 12:47:48 +00:00
f719ce760c
* refactor: extract DescribeInstanceStatus selection and projection
The request semantics — the instance-ID filter, filter parsing, the visibility
check, state membership defaulting to running-only, and the status filters that
can only run against a built frame — live inside the node handler's vmMgr.View
closure, so nothing else can apply them. A second caller answering the same
request from cached records would have to restate them, and the copy that
drifts is the one no customer request exercises until it does.
StatusSelection parses the request once; StatusFor applies it to one VM and
projects, returning nil when the request does not cover it. Per-VM rather than
per-slice so the node keeps reading VM fields inside its lock exactly as before.
buildInstanceStatus takes az rather than reading s.config.AZ, because a caller
projecting another node's record must use the AZ on that record. The node
passes its own config value, so behaviour is unchanged. systemImpaired stays a
parameter: it is node-local memory pressure only an answering node can report.
No behaviour change; the twenty existing status tests pass untouched.
* feat: read node liveness from the heartbeat store
A gateway answering from cached records has no way to tell a node that is
quietly healthy from one that has died, because an idle node writes no records
and record age measures activity rather than liveness. Liveness reads the
heartbeats the daemon already publishes and judges a node stale after three and
a half missed beats.
Unknown is a distinct answer from Stale and the two must not collapse. A node
nobody has ever heard from may simply never have published, and a store the
gateway cannot reach is not evidence that anything died — both report Unknown,
and a failed read clears the previous answers rather than keeping them.
The threshold is deliberately tighter than the IPsec barrier's six beats. That
one gates a cluster encryption decision where calling a live node dead churns a
security barrier; this one decides whether to admit the control plane cannot
confirm a host, where the costly error is the opposite one. The IPsec window is
left alone. A test in the daemon asserts the publish interval and the gateway's
copy of it have not drifted, since the gateway cannot import that package.
* feat: report impaired status for instances on a node that stopped answering
DescribeInstanceStatus fans out over NATS and returns whatever the nodes send
back. A node that is down sends nothing, so its instances disappear from the
answer entirely and a dead host reads as no host.
Backfill from the instance record cache: for any cached instance no node
answered for, synthesise a status frame and mark the system status from the
heartbeat store. A stale node reports impaired; an unreadable heartbeat store
reports insufficient-data, because not knowing a host is healthy is not knowing
it is not.
A frame an answering node returned is never touched. The covered set is what
guarantees that, and it is asserted directly rather than only through the
handler, where dedupStatuses being first-writer-wins makes either guard alone
sufficient. A live node's omission is its own answer and is not backfilled, and
a cache that has not finished its first whole-set sync contributes nothing.
The zero-value StatusSynthesis disables the whole path, so a gateway wired
without a cache answers exactly as it did before.
* fix: use fan-out responder identity, not heartbeat age, to gate status synthesis
DescribeInstanceStatus previously skipped synthesising an instance whenever
its owning node's heartbeat was still live, even when that node did not
answer this particular fan-out. A node that goes down loses its instances
from the response entirely until its heartbeat crosses the stale threshold.
Switch the Gather call to ExpectedResponders so it tracks per-node identity,
and use SuccessResponders to decide whether an omission was deliberate.
Heartbeat age now only selects the label (impaired vs insufficient-data),
never whether to synthesise. Falls back to the old heartbeat-only rule when
any frame cannot be attributed to a node.
* test: tag existing status-synthesis tests with node identity
answerWith never set X-Node-ID, so every existing test in
status_synthesis_test.go silently ran down the Unidentified fallback
branch instead of the responder-set path real daemons take. Give
answerWith a node-ID parameter (reusing subscribeAsNode) and retag each
call to match the record it is about, so these tests exercise the
success-responder guard they are named for. Keep exactly one test,
via a new answerAnonymously helper, on the fallback branch deliberately.
* test: tag DescribeInstanceStatus_test.go replies with node identity
Same rot as status_synthesis_test.go: raw nc.Subscribe replies carry no
X-Node-ID, so every describe here also sat on the 3s Gather deadline.
These tests mostly pass StatusSynthesis{}, so synthesize bails at
Records == nil before the fallback branch is ever reached — the payoff
is dropping the per-test timeout, not fixing a correctness gap.
127 lines
3.5 KiB
Go
127 lines
3.5 KiB
Go
package instancecache
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mulgadc/spinifex/spinifex/kvstore"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
const (
|
|
// HeartbeatInterval mirrors the cadence the daemon publishes heartbeats
|
|
// at. Exported so the daemon can assert the two have not drifted; a
|
|
// longer publish interval would make NodeStaleAfter flap.
|
|
HeartbeatInterval = 10 * time.Second
|
|
|
|
// NodeStaleAfter is three and a half missed beats. Two flaps every
|
|
// instance on a node over a GC pause or a NATS reconnect, four is slow to
|
|
// tell the truth.
|
|
NodeStaleAfter = 7 * HeartbeatInterval / 2
|
|
|
|
// heartbeatPrefix is the cluster-state key prefix the daemon writes each
|
|
// node's heartbeat under.
|
|
heartbeatPrefix = "heartbeat."
|
|
|
|
// livenessMemo collapses a burst of requests into one KV read. Kept far
|
|
// below NodeStaleAfter so it cannot change a verdict: the worst it does is
|
|
// report a node stale a second late.
|
|
livenessMemo = time.Second
|
|
)
|
|
|
|
// NodeState is what the heartbeat store says about a node. Unknown is a
|
|
// distinct answer from Stale: an unreadable store is not evidence that a node
|
|
// is gone, and the two must not collapse into one.
|
|
type NodeState int
|
|
|
|
const (
|
|
NodeUnknown NodeState = iota
|
|
NodeLive
|
|
NodeStale
|
|
)
|
|
|
|
// heartbeat is the subset of the daemon's heartbeat record this needs.
|
|
// Decoding only these two fields keeps the gateway independent of the capacity
|
|
// figures alongside them, which change for unrelated reasons.
|
|
type heartbeat struct {
|
|
Node string `json:"node"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
|
|
// Liveness answers whether a node is still heartbeating. It is a read-only
|
|
// view over the cluster-state bucket and holds no watch of its own.
|
|
type Liveness struct {
|
|
store *kvstore.Store[heartbeat]
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
seen map[string]time.Time
|
|
fetchedAt time.Time
|
|
readOK bool
|
|
}
|
|
|
|
// NewLiveness opens a reader over the cluster-state bucket. The caller
|
|
// resolves the bucket name, as it does for the record cache, so this package
|
|
// never has to know the daemon's key-space constants.
|
|
func NewLiveness(js jetstream.JetStream, bucket kvstore.Config) *Liveness {
|
|
return &Liveness{
|
|
store: kvstore.New[heartbeat](js, bucket),
|
|
now: time.Now,
|
|
seen: make(map[string]time.Time),
|
|
}
|
|
}
|
|
|
|
// State reports whether node is live, stale, or unknown. A node with no
|
|
// heartbeat at all is Unknown rather than Stale: it may simply never have
|
|
// published one, which is not proof that it died.
|
|
func (l *Liveness) State(ctx context.Context, node string) NodeState {
|
|
if l == nil || node == "" {
|
|
return NodeUnknown
|
|
}
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.refreshLocked(ctx)
|
|
|
|
if !l.readOK {
|
|
return NodeUnknown
|
|
}
|
|
written, ok := l.seen[node]
|
|
if !ok {
|
|
return NodeUnknown
|
|
}
|
|
if l.now().UTC().Sub(written) > NodeStaleAfter {
|
|
return NodeStale
|
|
}
|
|
return NodeLive
|
|
}
|
|
|
|
// refreshLocked re-reads every heartbeat when the memo has expired. A failed
|
|
// read clears readOK rather than keeping the previous answers, so a store the
|
|
// gateway cannot reach degrades to Unknown instead of asserting liveness it
|
|
// can no longer confirm.
|
|
func (l *Liveness) refreshLocked(ctx context.Context) {
|
|
if l.now().Sub(l.fetchedAt) < livenessMemo {
|
|
return
|
|
}
|
|
l.fetchedAt = l.now()
|
|
|
|
beats, err := l.store.List(ctx, heartbeatPrefix)
|
|
if err != nil {
|
|
l.readOK = false
|
|
return
|
|
}
|
|
|
|
seen := make(map[string]time.Time, len(beats))
|
|
for _, b := range beats {
|
|
written, err := time.Parse(time.RFC3339, b.Timestamp)
|
|
if err != nil || b.Node == "" {
|
|
continue
|
|
}
|
|
seen[b.Node] = written.UTC()
|
|
}
|
|
l.seen = seen
|
|
l.readOK = true
|
|
}
|