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:
jochen
2026-10-06 12:29:18 +02:00
parent 070ecafc07
commit 2eb9a22c24
59 changed files with 3975 additions and 158 deletions
+32 -3
View File
@@ -78,6 +78,8 @@ type Call struct {
// Holder is the controller process that served it, so one starting can tell its own running
// calls from those a stopped one left.
Holder string `json:"holder,omitempty"`
// Epoch is the controller lease epoch it was served under (novox/hq to-be 45 §6); zero for none.
Epoch uint64 `json:"epoch,omitempty"`
reply string // the subject the answer went to, which the bus names when it refuses it
}
@@ -111,6 +113,18 @@ type CallLog struct {
logger *log.Logger
lost int // writes the keeper could not take, said once each
keepErr error
// epoch is the lease this process serves under (UnderLease): a call carries its epoch, and its
// record is written only while the lease is held. Nil writes every record with no epoch.
epoch func() (uint64, error)
}
// UnderLease makes every call carry the controller lease's epoch, and every write of a call's record
// pass the lease (novox/hq to-be 45 §6): a controller that lost the lease writes no finish over a call
// the next holder has marked abandoned — that mark is the record's last word.
func (l *CallLog) UnderLease(epoch func() (uint64, error)) {
l.mu.Lock()
defer l.mu.Unlock()
l.epoch = epoch
}
// Calls is this process's log: one holder process serves its seats on one connection.
@@ -150,9 +164,9 @@ func (l *CallLog) Durably(ctx context.Context, keeper CallKeeper, holder string,
return fmt.Errorf("marking %s abandoned: %w", c.ID, err)
}
if logger != nil {
logger.Printf("%s (%s.%s, asked %s by %s) was running under %s, which stopped: marked abandoned — "+
"it may have done part of what it was asked, and nothing will finish it", c.ID, c.Seat, c.Verb,
c.Started.Format(time.RFC3339), orSomebody(c.Caller), orSomebody(c.Holder))
logger.Printf("%s (%s.%s, asked %s by %s) was running under %s (epoch %d), which stopped: marked "+
"abandoned — it may have done part of what it was asked, and nothing will finish it", c.ID, c.Seat,
c.Verb, c.Started.Format(time.RFC3339), orSomebody(c.Caller), orSomebody(c.Holder), c.Epoch)
}
}
return nil
@@ -184,6 +198,18 @@ func (l *CallLog) keep(c Call) {
func (l *CallLog) keepWrites() {
for c := range l.writes {
l.mu.Lock()
gate := l.epoch
l.mu.Unlock()
if gate != nil {
if _, err := gate(); err != nil {
if l.logger != nil {
l.logger.Printf("%s (%s.%s, %s) is not kept on the bus: %v — the controller holding the lease "+
"marks it abandoned", c.ID, c.Seat, c.Verb, c.State, err)
}
continue
}
}
var err error
for try := 0; try < keepTries; try++ {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -237,6 +263,9 @@ func (l *CallLog) begin(seat, verb string, args json.RawMessage, reply string) *
c := &Call{ID: "call-" + strconv.FormatInt(l.now().UnixNano(), 10) + "-" + strconv.FormatUint(l.next, 10),
Seat: seat, Verb: verb, Args: args, Started: l.now(), State: CallRunning, reply: reply,
Caller: callerOf(reply), Holder: l.holder}
if l.epoch != nil {
c.Epoch, _ = l.epoch() // zero when not held: its record is then not written either
}
l.calls = append(l.calls, c)
if len(l.calls) > KeptCalls {
l.calls = l.calls[len(l.calls)-KeptCalls:]
+47
View File
@@ -0,0 +1,47 @@
package link
// The contract of every message kind the controller consumes (novox/hq to-be 45 Phase 2, ADR 0227 rule
// 2 "how it is checked": a contract test per consumed message kind in the receiver's repository, and a
// check listing every consumed subject against the tests that name it).
//
// **Each kind says how an older one is told from a newer, and the tests that deliver n, then n−1.** A
// kind that carries no order says why none is needed — a word whose newest is simply the latest heard,
// a request answered once. The test beside it fails a kind the controller can be handed without an
// entry here, and an entry naming a test that does not exist.
// Contract is one consumed kind's order.
type Contract struct {
// Ordered is how an older message of this kind is refused; empty for a kind that carries no order.
Ordered string
// Unordered is why a kind needs no order; empty for an ordered one.
Unordered string
// Tests are the tests that deliver the newer and then the older, and assert the older refused.
Tests []string
}
// Contracts are every kind the controller consumes, by kind.
var Contracts = map[string]Contract{
KindReport: {Ordered: "by the epoch and sequence of the declaration it is about, then the node-engine's " +
"report sequence (order.go); an older account is refused and counted. A report from a node-engine " +
"that orders nothing is judged by the digest it names (issue 267)",
Tests: []string{"TestAnAccountOfAnOlderDeclarationIsRefusedByItsSequence",
"TestAnOlderReportOfTheSameDeclarationIsRefused", "TestAnAccountOfAnOlderDeclarationDoesNotReplaceTheNewer",
"TestAnAccountIsOlderByEpochThenSequenceThenReportSequence"}},
KindBuilt: {Ordered: "by the build's ask: an older ask finishing later is recorded and not registered (issue 219)",
Tests: []string{"TestAnOlderBuildHeardLaterDoesNotReplaceTheNewer"}},
KindSourceMoved: {Ordered: "by the merge's commit against what was built from it: a merge already acted on " +
"or older than the last look is history, not a second plan (issues 250, 266)",
Tests: []string{"TestAMergeOlderThanTheLastLookIsHistory", "TestAMergeMatchesTheSourcesBuiltFromIt"}},
KindHeartbeat: {Unordered: "a word that the machine is there: the newest heard is the newest said, and one " +
"lost is the next one"},
KindToolsHeartbeat: {Unordered: "a word that the node tools are there, as a machine's heartbeat"},
KindEnrolment: {Unordered: "a request answered once, under a token spent once: a second presentation is " +
"refused by the token, not by an order (issue 083)",
Tests: []string{"TestAnEnrolmentMetByAHeldTokenIsAskedToTryAgain"}},
KindModuleMoved: {Unordered: "the catalogue saying a module's current build moved: acted on by reading the " +
"catalogue's record, which is the order, so a late one reads the same record"},
KindCatchUp: {Unordered: "a catalogue asking what it missed: answered from the record, whenever asked"},
KindProvisioner: {Unordered: "a provider's newest word about a consumer, said again every fifteen minutes " +
"while it holds (ADR 0224): the condition keeps the last observed, and S8 says when the words stop",
Tests: []string{"TestAProviderFailingAConsumerBreaksAllWellUntilItRecovers"}},
}
+84
View File
@@ -0,0 +1,84 @@
package link
import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/broker"
)
// Every subject the controller consumes is a kind with a contract, and every test a contract names
// exists (novox/hq to-be 45 Phase 2, ADR 0227 rule 2).
func TestEveryConsumedKindHasAContract(t *testing.T) {
// What the controller can be handed, from the subjects it is granted and the ones it derives.
subjects := []string{EnrolSubject, BuiltSubject, ReportSubject("anchor"), AliveSubject("anchor"),
ToolsAliveSubject("anchor"), "mesh.mod.postgres.event.provisioner.failing"}
subjects = append(subjects, broker.ControllerFollows...)
kinds := map[string]bool{}
for _, s := range subjects {
kind, known := kindOfSubject(s)
if !known {
if strings.Contains(s, "*") {
continue // a pattern among the follows; its concrete subject is listed above
}
t.Errorf("the controller follows %s and has no kind for it", s)
continue
}
kinds[kind] = true
}
for kind := range kinds {
c, ok := Contracts[kind]
switch {
case !ok:
t.Errorf("the controller consumes %s and no contract says how an older one is told from a newer", kind)
case (c.Ordered == "") == (c.Unordered == ""):
t.Errorf("%s's contract says neither, or both, how it is ordered and why it need not be: %+v", kind, c)
case c.Ordered != "" && len(c.Tests) == 0:
t.Errorf("%s is ordered and no test delivers the newer and then the older", kind)
}
}
for kind := range Contracts {
if !kinds[kind] {
t.Errorf("a contract for %s, which the controller does not consume", kind)
}
}
// Every test named exists in this repository.
exists := map[string]bool{}
fset := token.NewFileSet()
err := filepath.WalkDir("../..", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && (d.Name() == "vendor" || d.Name() == ".git") {
return filepath.SkipDir
}
if d.IsDir() || !strings.HasSuffix(path, "_test.go") {
return nil
}
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return err
}
for name, obj := range f.Scope.Objects {
if obj.Kind.String() == "func" && strings.HasPrefix(name, "Test") {
exists[name] = true
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
for kind, c := range Contracts {
for _, name := range c.Tests {
if !exists[name] {
t.Errorf("%s's contract names %s, which no test in this repository is", kind, name)
}
}
}
}
+10
View File
@@ -12,6 +12,10 @@ type Signer interface {
Sign(ctx context.Context, message []byte) ([]byte, error)
}
// ActingGate is what every send passes before it is made (novox/hq to-be 45 §6): whether this process
// may act under the controller's lease. Set by the controller; nil passes every send — a test, a tool.
var ActingGate func(ctx context.Context) error
// Declare sends a node what it should be, signed.
//
// The signature is over the declaration exactly as it is published — the same bytes the node
@@ -25,6 +29,12 @@ func Declare(ctx context.Context, bus Bus, signer Signer, node string,
if !json.Valid(declaration) {
return fmt.Errorf("refusing to send %s something that is not a declaration", node)
}
// **At the send, not only where it was composed**: a lease lost between the two stops this one.
if ActingGate != nil {
if err := ActingGate(ctx); err != nil {
return fmt.Errorf("%s was not sent its declaration: %w", node, err)
}
}
signature, err := signer.Sign(ctx, declaration)
if err != nil {
+45 -13
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"log"
"sort"
"time"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/identity"
@@ -421,23 +422,54 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
// the newer one arrived reported *after* the newer apply's report, and stored last, it read as the
// machine never having applied what it was sent — the plan waited until somebody pushed by hand.
// One row per node means the last write wins, so the order of arrival must not decide it.
if report.Declared != "" {
sent, err := e.Inventory.Outstanding(ctx, report.Node)
//
// **By order, from a node-engine that orders its reports** (novox/hq to-be 45 §6, the contract in
// order.go): the account is kept only if it is not older than the one kept — by the epoch and
// sequence of the declaration it is about, then the node-engine's own report sequence — and refused
// otherwise, said and counted (rule 2). One rule, whatever the account is about and whenever it
// arrives; the digest the mesh last sent decides nothing for it. **By digest, from an older
// node-engine**: its report claims no order, and the rule of issue 267 stands for it.
//
// And whether the node-engine reads an epoch in a declaration, from every account it gives: the
// mesh sends one only to a machine that said so, and one rolled back says so no longer.
if err := e.Inventory.RecordReadsEpoch(ctx, node.ID, report.ReadsEpoch()); err != nil {
return false, err
}
if report.Ordered() {
account := AccountOf(report)
news, err = e.Inventory.RecordOrderedDoing(ctx, node.ID, doing,
inventory.ReportOrder{Epoch: report.Epoch, Sequence: report.Sequence, ReportSequence: report.ReportSequence},
func(kept inventory.ReportOrder) bool {
return account.OlderThan(Account{Order: Order{Epoch: kept.Epoch, Sequence: kept.Sequence},
ReportSequence: kept.ReportSequence})
})
if errors.Is(err, inventory.ErrOlderAccount) {
log.Printf("kept what %s says about the machine, and refused its account of declaration %s (%s, report %d): "+
"the account kept is newer", report.Node, short(report.Declared), report.Order.Words(), report.ReportSequence)
StaleRefusals.Refused(Refusal{Writer: WriterNodeEngine(report.Node), Receiver: "controller", At: time.Now()})
return false, e.Inventory.Seen(ctx, node.ID)
}
if err != nil {
return false, err
}
if Superseded(report.Declared, sent) {
log.Printf("kept what %s says about the machine, and not its account of declaration %s: "+
"the mesh has sent it %s since", report.Node, short(report.Declared), short(sent))
return false, e.Inventory.Seen(ctx, node.ID)
} else {
if report.Declared != "" {
sent, err := e.Inventory.Outstanding(ctx, report.Node)
if err != nil {
return false, err
}
if Superseded(report.Declared, sent) {
log.Printf("kept what %s says about the machine, and not its account of declaration %s: "+
"the mesh has sent it %s since", report.Node, short(report.Declared), short(sent))
return false, e.Inventory.Seen(ctx, node.ID)
}
}
// **Whether this is news** is the store's answer: it holds the previous report, and a machine
// that reconciles every minute says the same thing until something changes (novox/hq ADR 0134).
news, err = e.Inventory.RecordDoing(ctx, node.ID, doing)
if err != nil {
return false, err
}
}
// **Whether this is news** is the store's answer: it holds the previous report, and a machine
// that reconciles every minute says the same thing until something changes (novox/hq ADR 0134).
news, err = e.Inventory.RecordDoing(ctx, node.ID, doing)
if err != nil {
return false, err
}
// And how long the machine took, from the send to this first account of it (novox/hq to-be 45
// Phase 0): what the sent-not-reported bound will be set from. Said if lost, never a failure.
+163
View File
@@ -0,0 +1,163 @@
package link_test
import (
"context"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A report kept by its order (novox/hq to-be 45 §6, ADR 0227 rule 2): the contract test of the report,
// the message kind the controller consumes from every machine. Deliver n, then n−1: refused and counted.
// Of the same declaration, report r then r−1: refused. The digest the mesh last sent decides nothing
// for an ordered report; an unordered one, from an older node-engine, is judged by it as before.
func anOrderedMachine(t *testing.T) (*inventory.Inventory, link.Enrolment, string) {
t.Helper()
inv := inventory.ForTest(t)
node, err := inv.AddNode(context.Background(), "home-server")
if err != nil {
t.Fatal(err)
}
return inv, link.Enrolment{Inventory: inv}, node.ID
}
func ordered(declared string, epoch, sequence, report int64, applied ...string) link.Report {
return link.Report{Node: "home-server", Declared: declared, Applied: applied,
Order: link.Order{Epoch: epoch, Sequence: sequence}, ReportSequence: report}
}
// Issue 267, ordered: a reconcile's account of declaration 11 reaches the mesh after the apply's of 12.
func TestAnAccountOfAnOlderDeclarationIsRefusedByItsSequence(t *testing.T) {
inv, heard, id := anOrderedMachine(t)
ctx := context.Background()
before := countFor(link.WriterNodeEngine("home-server"))
if _, err := heard.Heard(ctx, ordered("d12", 57, 12, 41, "a", "b")); err != nil {
t.Fatal(err)
}
if _, err := heard.Heard(ctx, ordered("d11", 57, 11, 40, "a")); err != nil {
t.Fatal(err)
}
doing, _, err := inv.DoingOf(ctx, "home-server")
if err != nil || doing.Declared != "d12" || doing.Applied != 2 {
t.Fatalf("the older account replaced the newer: %+v (%v)", doing, err)
}
if got := countFor(link.WriterNodeEngine("home-server")) - before; got != 1 {
t.Fatalf("the refusal was counted %d times, want once", got)
}
kept, err := inv.KeptOrder(ctx, id)
if err != nil || kept != (inventory.ReportOrder{Epoch: 57, Sequence: 12, ReportSequence: 41}) {
t.Fatalf("the order kept is %+v (%v)", kept, err)
}
}
func TestAnOlderReportOfTheSameDeclarationIsRefused(t *testing.T) {
inv, heard, _ := anOrderedMachine(t)
ctx := context.Background()
if _, err := heard.Heard(ctx, ordered("d12", 57, 12, 41, "a", "b")); err != nil {
t.Fatal(err)
}
failed := ordered("d12", 57, 12, 40)
failed.Failed = map[string]string{"b": "it failed a moment before it applied"}
if _, err := heard.Heard(ctx, failed); err != nil {
t.Fatal(err)
}
doing, _, _ := inv.DoingOf(ctx, "home-server")
if doing.Outcome != inventory.OutcomeApplied {
t.Fatalf("an older report of the same declaration replaced the newer: %+v", doing)
}
// A newer report of it — the next reconcile — is kept.
again := ordered("d12", 57, 12, 42)
again.Failed = map[string]string{"b": "it failed since"}
if _, err := heard.Heard(ctx, again); err != nil {
t.Fatal(err)
}
if doing, _, _ := inv.DoingOf(ctx, "home-server"); doing.Outcome != inventory.OutcomeFailed {
t.Fatalf("a newer report of the same declaration was not kept: %+v", doing)
}
}
// An ordered account is judged by its order, not by the digest the mesh last sent: the account of 12
// is kept although the mesh has sent 13 since — it is still the newest account of the machine.
func TestAnOrderedAccountIsNotJudgedByTheDigestSent(t *testing.T) {
inv, heard, id := anOrderedMachine(t)
ctx := context.Background()
if err := inv.RecordSent(ctx, id, "d13", nil); err != nil {
t.Fatal(err)
}
if _, err := heard.Heard(ctx, ordered("d12", 57, 12, 41, "a")); err != nil {
t.Fatal(err)
}
if doing, said, _ := inv.DoingOf(ctx, "home-server"); !said || doing.Declared != "d12" {
t.Fatalf("the newest account the machine gave was set aside by the digest: %+v", doing)
}
// And the machine reads an epoch: it orders its reports.
if reads, err := inv.ReadsEpoch(ctx, id); err != nil || !reads {
t.Fatalf("a machine whose reports carry a report sequence is not recorded as reading an epoch: %v", err)
}
}
// A node-engine rolled back to one that orders nothing: its account is taken by the digest rule, the
// order kept is cleared, and it is no longer sent an epoch.
func TestAnUnorderedAccountClearsTheOrderAndTheEpoch(t *testing.T) {
inv, heard, id := anOrderedMachine(t)
ctx := context.Background()
if _, err := heard.Heard(ctx, ordered("d12", 57, 12, 41, "a")); err != nil {
t.Fatal(err)
}
if err := inv.RecordSent(ctx, id, "d13", nil); err != nil {
t.Fatal(err)
}
if _, err := heard.Heard(ctx, link.Report{Node: "home-server", Declared: "d13", Applied: []string{"a", "b"}}); err != nil {
t.Fatal(err)
}
if doing, _, _ := inv.DoingOf(ctx, "home-server"); doing.Declared != "d13" {
t.Fatalf("an older node-engine's account of what was sent was not kept: %+v", doing)
}
if kept, _ := inv.KeptOrder(ctx, id); kept != (inventory.ReportOrder{}) {
t.Fatalf("an unordered account left the order kept: %+v", kept)
}
if reads, _ := inv.ReadsEpoch(ctx, id); reads {
t.Fatal("a node-engine that orders nothing is still sent an epoch")
}
// The next ordered account, whatever its numbers, has nothing to be older than.
if _, err := heard.Heard(ctx, ordered("d14", 58, 14, 1, "a")); err != nil {
t.Fatal(err)
}
if doing, _, _ := inv.DoingOf(ctx, "home-server"); doing.Declared != "d14" {
t.Fatalf("the first ordered account after an unordered one was refused: %+v", doing)
}
}
// A stale refusal names its writer by the epoch the refused declaration claimed, and a refusal whose
// own report was lost is still counted from the machine's own tally.
func TestAStaleRefusalIsCountedByItsWriter(t *testing.T) {
count := link.NewRefusalCount()
now := time.Now()
refusal := link.Report{Node: "anchor", Refused: "older", Order: link.Order{Epoch: 41, Sequence: 11},
OlderThan: &link.Order{Epoch: 57, Sequence: 12}, ReportSequence: 9, RefusedOlder: 3}
if !refusal.StaleRefusalOf() {
t.Fatal("a refusal naming the order it holds is not stale")
}
count.Lifetime("anchor", 2, now) // the machine's tally, as the controller first heard it
count.Refused(link.Refusal{Writer: link.WriterEpoch(refusal.Epoch), Epoch: refusal.Epoch, Receiver: "anchor", At: now})
count.Lifetime("anchor", refusal.RefusedOlder, now)
count.Lifetime("anchor", 5, now) // two more refused, their reports lost
got := count.Within(now.Add(-time.Minute))
if len(got) != 2 || got[0].Count != 2 || got[0].Epoch != 0 || got[1].Writer != "the controller of epoch 41" ||
got[1].Count != 1 || got[1].Receivers[0] != "anchor" {
t.Fatalf("the refusals by writer are %+v", got)
}
}
// countFor is how many refusals of a writer this process has heard in the last minute.
func countFor(writer string) int {
for _, w := range link.StaleRefusals.Within(time.Now().Add(-time.Minute)) {
if w.Writer == writer {
return w.Count
}
}
return 0
}
+118
View File
@@ -0,0 +1,118 @@
package link
import "strconv"
// The order a declaration carries, and the order a report about one carries back (novox/hq to-be 45
// §6, ADR 0227 rule 2).
//
// **This file is the controller's side of the contract with the node-engine for Phase 2**, and the one
// place it is written here. It mirrors mesh-host internal/link/messages.go (`Order`, and the report's
// `report_sequence`, `older_than`, `refused_older`) field for field and rule for rule, as Report has
// always mirrored the host's report: a key added on one side and not the other is a key the other side
// does not know exists.
//
// **The wire.**
//
// - A declaration's order is two top-level keys of its signed envelope, beside "declaration" and
// "resources": `sequence` (since issue 107) and `epoch` (new). Inside the signed bytes, so a message
// cannot be given a newer order than the controller gave it.
// - A report carries the order of the declaration it is about as the same two top-level keys, beside
// "declared"; `report_sequence`, the node-engine's own number for the report; `older_than`, on a
// refusal of a declaration older than one it applied, the order of the one it holds; and
// `refused_older`, how many it has refused so, ever, on every report.
//
// Every key is optional and absent when zero, and zero is "no order claimed", never "first". So each
// side reads the other's older shape:
//
// - **An older node-engine with this controller.** It decodes a declaration strictly and refuses a key
// it does not know, whole — so `epoch` is sent only to a machine whose latest report carried a
// `report_sequence`, which a node-engine that reads the epoch always does. Until then it is sent the
// sequence alone, as today; its reports carry no report sequence and are judged by the digest they
// name, as today (issue 267).
// - **A newer node-engine with an older controller.** It is sent no epoch, which claims none: it
// refuses nothing by epoch. The keys it adds to a report are fields the older controller ignores.
// - **A controller rolled back to a build without the lease** sends no epoch again and is not refused
// for it: a node-engine refuses by epoch only when both declarations claim one. That gives up the
// epoch's protection while such a build runs — the price of a rollback that cannot strand every
// machine.
//
// **Epoch** is the controller lease's epoch (to-be 45 §6): the lease bucket's revision at which the
// instance that composed the declaration took the lease. It only grows: a later holder's is higher, and
// the controller never issues one at or under the highest it has issued (internal/lease, the floor).
// **Sequence** is the declaration's number for that machine, one higher every send, taken from the
// controller's store before the declaration is composed (issues 107, 204).
// Order is where a declaration stands among everything the mesh has sent a machine: the lease epoch it
// was sent under and its sequence. Zero in either claims none.
type Order struct {
Epoch int64 `json:"epoch,omitempty"`
Sequence int64 `json:"sequence,omitempty"`
}
// Older is the node-engine's rule, as mesh-host states it: a declaration of this order is older than
// one of order `than` the machine applied **only when both claim an epoch** — and then when its epoch
// is lower, or its epoch is the same and its sequence lower (both claimed). A higher epoch is a new lease
// holder and is never older, whatever its sequence. Here so the controller's tests state what the
// machines will do with what it sends.
func (o Order) Older(than Order) bool {
if o.Epoch <= 0 || than.Epoch <= 0 {
return false
}
if o.Epoch != than.Epoch {
return o.Epoch < than.Epoch
}
return o.Sequence > 0 && than.Sequence > 0 && o.Sequence < than.Sequence
}
// Words is an order as a person reads it in a log line. Not String: Report embeds Order, and a Stringer
// promoted onto every report would print each one as its order alone.
func (o Order) Words() string {
switch {
case o.Epoch > 0:
return "epoch " + strconv.FormatInt(o.Epoch, 10) + ", sequence " + strconv.FormatInt(o.Sequence, 10)
case o.Sequence > 0:
return "sequence " + strconv.FormatInt(o.Sequence, 10) + ", no epoch"
}
return "no order"
}
// Account is the order of one account of a machine — a report about a declaration: that declaration's
// order and the node-engine's own number for the report.
type Account struct {
Order
ReportSequence int64
}
// AccountOf is a report's account order.
func AccountOf(r Report) Account { return Account{Order: r.Order, ReportSequence: r.ReportSequence} }
// Ordered says a report comes from a node-engine that orders its reports: it carries a report sequence.
// Such a report is judged by its order (OlderThan); one without is judged by the digest it names, as
// before (issue 267).
func (r Report) Ordered() bool { return r.ReportSequence > 0 }
// ReadsEpoch says the node-engine that made the report takes an epoch in a declaration: one that orders
// its reports does (mesh-host: "its presence also says this host reads a declaration's epoch").
func (r Report) ReadsEpoch() bool { return r.ReportSequence > 0 }
// StaleRefusalOf says a report refuses a declaration older than one the machine applied: the node-engine
// names the order it holds (`older_than`), or, from a node-engine older than that, says so in the words
// of its sequence refusal.
func (r Report) StaleRefusalOf() bool {
return r.OlderThan != nil || (r.Refused != "" && IsStaleRefusal(r.Refused))
}
// OlderThan is the controller's rule (to-be 45 §6): whether this account is older than the one kept for
// the machine — **by epoch, then sequence, then report sequence**, each compared only where both claim
// one. An account of a declaration of an older epoch is refused; of an older sequence, refused; an older
// report of the same declaration (a reconcile's, overtaken by the apply's — issue 267), refused. Equal is
// the same report again, redelivered, and is not refused.
func (a Account) OlderThan(kept Account) bool {
if a.Epoch > 0 && kept.Epoch > 0 && a.Epoch != kept.Epoch {
return a.Epoch < kept.Epoch
}
if a.Sequence > 0 && kept.Sequence > 0 && a.Sequence != kept.Sequence {
return a.Sequence < kept.Sequence
}
return a.ReportSequence > 0 && kept.ReportSequence > 0 && a.ReportSequence < kept.ReportSequence
}
+113
View File
@@ -0,0 +1,113 @@
package link
import (
"encoding/json"
"testing"
)
// The contract of order.go, case by case: what a node-engine refuses of a declaration (the rule
// mesh-host states, restated so the controller's tests say what the machines do with what it sends),
// what the controller refuses of a report, and the bytes on the wire both ways.
func TestADeclarationIsOlderOnlyWhenBothClaimAnEpoch(t *testing.T) {
for _, c := range []struct {
name string
arriving Order
held Order
older bool
}{
{"a newer sequence of the same epoch", Order{57, 12}, Order{57, 11}, false},
{"the same declaration again (a reconcile)", Order{57, 12}, Order{57, 12}, false},
{"a lower sequence of the same epoch", Order{57, 11}, Order{57, 12}, true},
// Issue 204: a controller that lost its lease goes on sending.
{"an older epoch, whatever its sequence", Order{41, 99}, Order{57, 12}, true},
{"a newer epoch, whatever its sequence", Order{57, 3}, Order{41, 99}, false},
{"no epoch arriving: a rolled-back controller is not stranded", Order{0, 11}, Order{57, 12}, false},
{"no epoch held: what the machine held before any lease", Order{57, 11}, Order{0, 12}, false},
{"no order at all", Order{}, Order{57, 12}, false},
} {
t.Run(c.name, func(t *testing.T) {
if got := c.arriving.Older(c.held); got != c.older {
t.Fatalf("%s against %s: older %v, want %v", c.arriving.Words(), c.held.Words(), got, c.older)
}
})
}
}
func TestAnAccountIsOlderByEpochThenSequenceThenReportSequence(t *testing.T) {
for _, c := range []struct {
name string
arriving Account
kept Account
older bool
}{
// Issue 267: a reconcile's account of declaration 11 arrives after the apply's of 12.
{"an account of an older declaration", Account{Order{57, 11}, 40}, Account{Order{57, 12}, 41}, true},
{"an older report of the same declaration", Account{Order{57, 12}, 40}, Account{Order{57, 12}, 41}, true},
{"a newer report of the same declaration (a reconcile after the apply)",
Account{Order{57, 12}, 42}, Account{Order{57, 12}, 41}, false},
{"the same report again (redelivered)", Account{Order{57, 12}, 41}, Account{Order{57, 12}, 41}, false},
{"an account of a newer declaration, whatever its report sequence",
Account{Order{57, 13}, 1}, Account{Order{57, 12}, 41}, false},
{"an account of an older epoch", Account{Order{41, 99}, 50}, Account{Order{57, 12}, 41}, true},
{"an account of a newer epoch", Account{Order{58, 1}, 2}, Account{Order{57, 12}, 41}, false},
{"no epoch either side: the sequences", Account{Order{0, 11}, 50}, Account{Order{0, 12}, 41}, true},
{"no report sequence: the declaration's alone", Account{Order{57, 12}, 0}, Account{Order{57, 12}, 41}, false},
{"an unordered declaration: the report sequences", Account{Order{}, 40}, Account{Order{}, 41}, true},
{"nothing kept yet", Account{Order{57, 3}, 1}, Account{}, false},
} {
t.Run(c.name, func(t *testing.T) {
if got := c.arriving.OlderThan(c.kept); got != c.older {
t.Fatalf("%+v against %+v: older %v, want %v", c.arriving, c.kept, got, c.older)
}
})
}
}
// The bytes, as mesh-host's report writes them: every key optional, absent when zero.
func TestTheOrderOnTheWire(t *testing.T) {
// A stale refusal, as the node-engine says it: the refused declaration's order at the top, the one
// it holds in older_than, and how many it has refused ever.
raw := []byte(`{"node":"anchor","refused":"older","declared":"d1","epoch":41,"sequence":11,` +
`"report_sequence":7,"older_than":{"epoch":57,"sequence":12},"refused_older":3}`)
var r Report
if err := json.Unmarshal(raw, &r); err != nil {
t.Fatal(err)
}
if r.Order != (Order{41, 11}) || r.ReportSequence != 7 || r.OlderThan == nil || *r.OlderThan != (Order{57, 12}) ||
r.RefusedOlder != 3 {
t.Fatalf("a node-engine's stale refusal reads as %+v", r)
}
if !r.Ordered() || !r.ReadsEpoch() || !r.StaleRefusalOf() {
t.Fatalf("a node-engine that orders its reports reads as one that does not: %+v", r)
}
// A report from a node-engine older than the contract says none of it.
var older Report
if err := json.Unmarshal([]byte(`{"node":"anchor","applied":["a"],"declared":"d1"}`), &older); err != nil {
t.Fatal(err)
}
if older.Ordered() || older.ReadsEpoch() || older.StaleRefusalOf() {
t.Fatalf("an older node-engine's report reads as ordered: %+v", older)
}
// Its stale refusal, in the words it has always used, is still one.
older.Refused = "this declaration " + StaleRefusal + ": it is sequence 3"
if !older.StaleRefusalOf() {
t.Fatal("an older node-engine's refusal of an older sequence does not read as stale")
}
// And a report with no order puts no order key on the wire.
raw, err := json.Marshal(Report{Node: "anchor", Declared: "d1"})
if err != nil {
t.Fatal(err)
}
var keys map[string]any
if err := json.Unmarshal(raw, &keys); err != nil {
t.Fatal(err)
}
for _, key := range []string{"epoch", "sequence", "report_sequence", "older_than", "refused_older"} {
if _, there := keys[key]; there {
t.Fatalf("an unordered report carries %s: %s", key, raw)
}
}
}
+17
View File
@@ -178,6 +178,23 @@ type Report struct {
// the same way, as the `sent` digest the mesh recorded. Which declaration, not when.
Declared string `json:"declared,omitempty"`
// Order is where the declaration this report is about stands — its `epoch` and `sequence` as the
// declaration carried them — so the mesh keeps accounts by what they are about rather than by when
// they arrived (novox/hq to-be 45 §6; the contract is order.go). Two top-level keys; absent for a
// declaration that claimed no order, and from a node-engine older than the contract.
Order
// ReportSequence is the node-engine's own number for this report: one higher for every report it
// makes, kept on disk across restarts and self-updates. Zero claims none — every report an older
// node-engine makes. Its presence also says the node-engine reads a declaration's epoch.
ReportSequence int64 `json:"report_sequence,omitempty"`
// OlderThan is set on a report refusing a declaration older than one the machine applied: the order
// of the one it holds. The refused declaration is the report's own Declared and Order — whose epoch
// names the controller that sent it (S13).
OlderThan *Order `json:"older_than,omitempty"`
// RefusedOlder is how many declarations the node-engine has refused as older, ever, on every report:
// a refusal whose own report was lost is still counted from the next.
RefusedOlder int64 `json:"refused_older,omitempty"`
// Held is what an adopted node found and is keeping as it was until its module is taken
// (novox/hq ADR 0100). Without it an adopted node reads as converged.
Held []Held `json:"held,omitempty"`
+5
View File
@@ -317,6 +317,11 @@ func (s *Server) reported(ctx context.Context, m Control) {
// the server's, it comes back after the newer was applied whatever the controller does.
outstanding := s.outstanding(ctx, report.Node)
declaredIn := staleAgainst(report)
if report.Ordered() {
// Kept by its order, not by the digest the mesh last sent (novox/hq to-be 45 §6): the
// listener refuses an older account under the one rule, whatever it is about.
declaredIn = ""
}
if Superseded(declaredIn, outstanding) {
s.log.Printf("set aside %s: the mesh has moved past that declaration", what)
_ = m.Took()
+110 -26
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
@@ -176,48 +178,130 @@ const StaleRefusal = "is older than what the mesh last said to this node"
// IsStaleRefusal says a report's refusal is the node-engine refusing a declaration older than it holds.
func IsStaleRefusal(refused string) bool { return strings.Contains(refused, StaleRefusal) }
// RefusalCount keeps when each machine refused a stale declaration, for S13 (novox/hq to-be 45 §3):
// more than five from one writer in five minutes is a writer sending what it has moved past.
// A Refusal is one receiver refusing something older than what it holds (novox/hq to-be 45 §6, ADR 0227
// rule 2): a machine refusing a declaration, or this controller refusing an account.
type Refusal struct {
// Writer is who sent what was refused, in the mesh's words: WriterEpoch for a declaration, a
// machine's node-engine for an account.
Writer string
// Epoch is the lease epoch the refused declaration claimed, for a controller to be named by; zero
// for a declaration that claimed none, and for an account.
Epoch int64
// Receiver is the machine, or "controller", that refused it.
Receiver string
At time.Time
}
// WriterEpoch is how a refused declaration's writer is said before it is named: by the epoch it
// claimed, or as a controller that claimed none.
func WriterEpoch(epoch int64) string {
if epoch > 0 {
return fmt.Sprintf("the controller of epoch %d", epoch)
}
return "a controller that claimed no epoch"
}
// WriterNodeEngine is a machine's node-engine as the writer of an account the controller refused.
func WriterNodeEngine(node string) string { return "the node-engine on " + node }
// RefusalCount keeps every stale refusal heard, for S13 (novox/hq to-be 45 §3): more than five from
// one writer in five minutes is a writer sending what it — or the mesh — has moved past.
type RefusalCount struct {
mu sync.Mutex
per map[string][]time.Time
mu sync.Mutex
list []Refusal
// lifetime is each machine's own count of declarations it refused as older, as its last report
// said it (`refused_older`), and named how many of those this process heard as refusal reports
// since: what the count rose by beyond them is refusals whose reports never arrived.
lifetime map[string]int64
named map[string]int64
}
// StaleRefusals is this process's count.
var StaleRefusals = &RefusalCount{per: map[string][]time.Time{}}
var StaleRefusals = NewRefusalCount()
// Refused records one refusal by a machine.
func (r *RefusalCount) Refused(node string, at time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
r.per[node] = append(r.per[node], at)
// NewRefusalCount is an empty count.
func NewRefusalCount() *RefusalCount {
return &RefusalCount{lifetime: map[string]int64{}, named: map[string]int64{}}
}
// Within is how many refusals each machine made since a moment; older ones are forgotten.
func (r *RefusalCount) Within(since time.Time) map[string]int {
// Refused records one refusal.
func (r *RefusalCount) Refused(f Refusal) {
r.mu.Lock()
defer r.mu.Unlock()
out := map[string]int{}
for node, times := range r.per {
var kept []time.Time
for _, t := range times {
if !t.Before(since) {
kept = append(kept, t)
}
}
if len(kept) == 0 {
delete(r.per, node)
r.list = append(r.list, f)
if f.Writer != WriterNodeEngine(f.Receiver) {
r.named[f.Receiver]++
}
}
// Lifetime reads a machine's own count of declarations it refused as older, from any report: what it
// rose by since the last, beyond the refusals heard by name, is recorded as refused by a writer the
// mesh never heard named — the refusal's report was lost, and the count is still a fact.
func (r *RefusalCount) Lifetime(node string, total int64, at time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
before, known := r.lifetime[node]
r.lifetime[node] = total
named := r.named[node]
r.named[node] = 0
if !known || total <= before {
return // the first word since this controller started, or a node-engine that started over
}
for missing := total - before - named; missing > 0; missing-- {
r.list = append(r.list, Refusal{Writer: "a writer whose refused declaration was never reported",
Receiver: node, At: at})
}
}
// WriterRefusals is one writer's refusals within a window.
type WriterRefusals struct {
Writer string
Epoch int64
Count int
Receivers []string
Last time.Time
}
// Within is every writer's refusals since a moment, most first; older ones are forgotten.
func (r *RefusalCount) Within(since time.Time) []WriterRefusals {
r.mu.Lock()
defer r.mu.Unlock()
kept := r.list[:0]
by := map[string]*WriterRefusals{}
var order []string
for _, f := range r.list {
if f.At.Before(since) {
continue
}
r.per[node] = kept
out[node] = len(kept)
kept = append(kept, f)
w, ok := by[f.Writer]
if !ok {
w = &WriterRefusals{Writer: f.Writer, Epoch: f.Epoch}
by[f.Writer] = w
order = append(order, f.Writer)
}
w.Count++
if !slices.Contains(w.Receivers, f.Receiver) {
w.Receivers = append(w.Receivers, f.Receiver)
}
if f.At.After(w.Last) {
w.Last = f.At
}
}
r.list = kept
out := make([]WriterRefusals, 0, len(order))
for _, writer := range order {
w := by[writer]
sort.Strings(w.Receivers)
out = append(out, *w)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Count > out[j].Count })
return out
}
// holding is whether this process holds the controller's consumer of what nodes say: the controller
// acting, not one standing by for another (issue 213). Until the lease (to-be 45 §6) it is how a
// watchdog knows it is the one that hears.
// acting, not one standing by for another (issue 213). Beside the lease (to-be 45 §6), which decides
// who may act, it is how a watchdog knows this process is the one that hears.
var holding atomic.Bool
// Holding says this process is the controller acting now.