Act under a lease, keep accounts by order, one writer at composition (hq to-be 45 Phase 2)
Two controllers could both act (issue 204), a reconcile's report could overtake the apply after it and the digest decided (issue 267), and a grant could make a second writer of a machine's report. - The lease (internal/lease, ADR 0229): mesh-controller_lease key `holder`, 15 s age, renewed every 5 s by compare-and-set; the epoch is the revision it was taken at. The gate is the clock (stops 3 s before expiry); a refused renewal is a loss and the process exits; a holder that stops gives it back. serve takes it before asserting the bus. Epochs kept in the store (migration 0068 controller_epoch) as a floor: a bucket raised from nothing is compacted past it. Unleased (no epoch, S12 urgent) only when nobody holds it and the bus will not let it be written. A shell command acts under the holder's epoch, or its own lease when none. - Declarations carry `epoch` inside the signed envelope, only to a machine whose latest account carried a report_sequence (mesh-host #35); would-send is composed with the epoch last sent. Allot and the send both pass the gate. - Reports: contract in internal/link/order.go (epoch, sequence, report_sequence, older_than, refused_older). Accounts kept by epoch, then sequence, then report sequence; older refused, counted; unordered reports keep the digest rule. Plans by compare-and-set on a revision, with epoch. Conditions and calls carry the epoch and are not written off the lease. - S12 and S13 (naming the writer by epoch) watched, D5 run; reset of the bucket said. Writers table compiled in and enforced in PermissionsFor; the controller no longer publishes mesh.control.>. A contract per consumed kind, and the empty-on-error lint over the repository. - mesh-host pinned to its main with the epoch in the validator (D1 validates the envelope as sent). Needs mesh-host's genesis lock with the lease grant (mesh-host PR) for TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -31,10 +32,17 @@ func calm(now time.Time) *signalFacts {
|
||||
loop: loopFacts{took: now.Add(-time.Second), pending: 1},
|
||||
mergesPassed: now.Add(-time.Minute),
|
||||
selfCheck: selfCheckFacts{last: now.Add(-time.Minute), every: 5 * time.Minute},
|
||||
lostConsumers: map[string]bool{}, staleRefusals: map[string]int{},
|
||||
lostConsumers: map[string]bool{}, epochs: map[int64]inventory.Epoch{},
|
||||
lease: leaseFacts{held: true, epoch: 57, renewed: now.Add(-2 * time.Second)},
|
||||
}
|
||||
}
|
||||
|
||||
// refusedBy is n refusals of one writer, the last a moment ago.
|
||||
func refusedBy(now time.Time, epoch int64, n int) []link.WriterRefusals {
|
||||
return []link.WriterRefusals{{Writer: link.WriterEpoch(epoch), Epoch: epoch, Count: n,
|
||||
Receivers: []string{"anchor", "laptop"}, Last: now.Add(-time.Second)}}
|
||||
}
|
||||
|
||||
// suppression is one row's signal held back: inside its bound, and past it.
|
||||
type suppression struct{ inside, past func(f *signalFacts) }
|
||||
|
||||
@@ -106,9 +114,13 @@ var suppressions = map[string]suppression{
|
||||
inside: func(f *signalFacts) { f.machines[0].toolsHeard = f.now.Add(-179 * time.Second) },
|
||||
past: func(f *signalFacts) { f.machines[0].toolsHeard = f.now.Add(-181 * time.Second) },
|
||||
},
|
||||
"S12": {
|
||||
inside: func(f *signalFacts) { f.lease.renewed = f.now.Add(-15 * time.Second) },
|
||||
past: func(f *signalFacts) { f.lease.renewed = f.now.Add(-16 * time.Second) },
|
||||
},
|
||||
"S13": {
|
||||
inside: func(f *signalFacts) { f.staleRefusals = map[string]int{"anchor": 5} },
|
||||
past: func(f *signalFacts) { f.staleRefusals = map[string]int{"anchor": 6} },
|
||||
inside: func(f *signalFacts) { f.staleRefusals = refusedBy(f.now, 41, 5) },
|
||||
past: func(f *signalFacts) { f.staleRefusals = refusedBy(f.now, 41, 6) },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -273,3 +285,58 @@ func TestAControllerStandingBySaysNothing(t *testing.T) {
|
||||
t.Fatalf("%+v", open)
|
||||
}
|
||||
}
|
||||
|
||||
// **A stale writer is named** (novox/hq to-be 45 §3, S13): by the controller instance that held the epoch
|
||||
// its refused declarations claimed, and how that epoch ended — the question issue 204 could not answer.
|
||||
func TestAStaleWriterIsNamedByItsEpoch(t *testing.T) {
|
||||
now := time.Now()
|
||||
f := calm(now)
|
||||
ended := now.Add(-time.Minute)
|
||||
f.staleRefusals = refusedBy(now, 41, 6)
|
||||
f.epochs[41] = inventory.Epoch{Epoch: 41, Instance: "controller@anchor pid 7 since 2026-10-06T10:00:00Z",
|
||||
Host: "anchor", Ended: &ended, How: inventory.EpochExpired}
|
||||
got := watchStaleRefusals(f)
|
||||
if len(got) != 1 || got[0].Key() != "core.controller.epoch-41.stale-writer" ||
|
||||
!strings.Contains(got[0].Summary, "pid 7") || !strings.Contains(got[0].Summary, "expired") ||
|
||||
got[0].Machine != "anchor" || !slices.Equal(got[0].Also, []string{"laptop"}) {
|
||||
t.Fatalf("the stale writer is not named: %+v", got)
|
||||
}
|
||||
// An account the controller refused names the machine whose node-engine sent it.
|
||||
f.staleRefusals = []link.WriterRefusals{{Writer: link.WriterNodeEngine("laptop"), Count: 6,
|
||||
Receivers: []string{"controller"}, Last: now}}
|
||||
got = watchStaleRefusals(f)
|
||||
if len(got) != 1 || got[0].Key() != "machine.laptop.stale-writer" || got[0].Machine != "laptop" {
|
||||
t.Fatalf("a node-engine sending older accounts is not named: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **The lease lost is said for an hour, and serving without it for as long as it lasts** (S12).
|
||||
func TestALeaseLostOrMissingIsSaid(t *testing.T) {
|
||||
now := time.Now()
|
||||
f := calm(now)
|
||||
expired := now.Add(-59 * time.Minute)
|
||||
f.lease.ended = []inventory.Epoch{{Epoch: 41, Instance: "controller@anchor pid 7", Host: "anchor",
|
||||
Ended: &expired, How: inventory.EpochExpired}}
|
||||
got := watchLease(f)
|
||||
if len(got) != 1 || got[0].Key() != "core.controller.lease.lost" || !strings.Contains(got[0].Summary, "epoch 41") ||
|
||||
got[0].Severity != conditions.Urgent {
|
||||
t.Fatalf("a holder that stopped renewing was not said: %+v", got)
|
||||
}
|
||||
long := now.Add(-61 * time.Minute)
|
||||
f.lease.ended[0].Ended = &long
|
||||
if got := watchLease(f); len(got) != 0 {
|
||||
t.Fatalf("a loss an hour old is still said: %+v", got)
|
||||
}
|
||||
f.lease.ended[0].How, f.lease.ended[0].Ended = inventory.EpochReleased, &expired
|
||||
if got := watchLease(f); len(got) != 0 {
|
||||
t.Fatalf("a lease given back is said as lost: %+v", got)
|
||||
}
|
||||
f.lease = leaseFacts{held: true, epoch: 501, renewed: now, reset: now.Add(-time.Minute), resetSaid: "moved past 500"}
|
||||
if got := watchLease(f); len(got) != 1 || got[0].Key() != "core.controller.lease.reset" {
|
||||
t.Fatalf("a lease bucket raised again from nothing was not said: %+v", got)
|
||||
}
|
||||
f.lease = leaseFacts{unleased: "the bus refused the key"}
|
||||
if got := watchLease(f); len(got) != 1 || got[0].Key() != "core.controller.lease.unleased" {
|
||||
t.Fatalf("serving without the lease was not said: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user