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.
94 lines
4.1 KiB
Go
94 lines
4.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
)
|
|
|
|
// A declaration composed earlier is numbered lower than one composed later, whatever order the two
|
|
// are sent in (novox/hq issue 204). The number used to be taken at send time, after composing, so a
|
|
// declaration composed before an assignment changed and sent after a newer one carried the higher
|
|
// number — and the machine, which refuses a lower number, took the older content as the mesh's
|
|
// newest word. Taken before the composition reads anything, the order of numbers is the order of
|
|
// compositions, and the host's refusal does what it is for.
|
|
func TestADeclarationComposedEarlierIsNumberedLowerWhateverOrderItIsSent(t *testing.T) {
|
|
allot := numbered()
|
|
var composed []string
|
|
compose := func(stamp string) func(string) (sendable, error) {
|
|
return func(node string) (sendable, error) {
|
|
composed = append(composed, stamp)
|
|
return sendable{Resources: []map[string]any{{"id": node + "." + stamp}}}, nil
|
|
}
|
|
}
|
|
// Composed first — before an assignment changed — and sent last.
|
|
stale, _ := composeEach([]string{"anchor"}, allot, compose("before"))
|
|
// Composed after the change, sent first.
|
|
fresh, _ := composeEach([]string{"anchor"}, allot, compose("after"))
|
|
|
|
if stale[0].declared.Sequence != 1 || fresh[0].declared.Sequence != 2 {
|
|
t.Fatalf("the numbers do not follow the compositions: before=%d after=%d",
|
|
stale[0].declared.Sequence, fresh[0].declared.Sequence)
|
|
}
|
|
// Sent in the other order, the numbers do not change — so the machine that has applied the
|
|
// fresh one (2) refuses the stale one (1) when it arrives late.
|
|
if !(stale[0].declared.Sequence < fresh[0].declared.Sequence) {
|
|
t.Fatal("a declaration composed earlier must carry the lower number, however late it is sent")
|
|
}
|
|
if len(composed) != 2 || composed[0] != "before" {
|
|
t.Fatalf("compositions happened in an unexpected order: %v", composed)
|
|
}
|
|
}
|
|
|
|
// The number is taken before the first read of the composition, not after it: an allotter that
|
|
// fails leaves nothing composed for that machine, and the others are still composed.
|
|
func TestTheNumberIsTakenBeforeComposingAndItsFailureIsARefusal(t *testing.T) {
|
|
calls := 0
|
|
allot := func(node string) (order, error) {
|
|
if node == "anchor" {
|
|
return order{}, context.DeadlineExceeded
|
|
}
|
|
return order{sequence: 7}, nil
|
|
}
|
|
sending, refusals := composeEach([]string{"anchor", "laptop"}, allot, func(node string) (sendable, error) {
|
|
calls++
|
|
if node == "anchor" {
|
|
t.Fatal("anchor was composed although its number could not be taken")
|
|
}
|
|
return sendable{}, nil
|
|
})
|
|
if calls != 1 || len(sending) != 1 || sending[0].node != "laptop" || sending[0].declared.Sequence != 7 {
|
|
t.Fatalf("laptop should be composed with its number and anchor refused: %v / %v", sending, refusals)
|
|
}
|
|
if len(refusals) != 1 {
|
|
t.Fatalf("anchor's failed number should be a refusal naming it: %v", refusals)
|
|
}
|
|
}
|
|
|
|
// What was sent is written down even when the sender's context is already cancelled (issue 204): a
|
|
// controller replaced mid-send had told the machine and never recorded it, so status read "applied,
|
|
// current" over a machine that had just been sent something else.
|
|
func TestASendIsRecordedEvenWhenTheSenderIsBeingCancelled(t *testing.T) {
|
|
inv := inventory.ForTest(t)
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cancel() // the sender is going away: its context is cancelled between the send and the record
|
|
body := []byte(`{"declaration":1,"resources":[]}`)
|
|
digest, err := recordSent(ctx, inv, "anchor", body, nil, 0)
|
|
if err != nil {
|
|
// NodeByName on the cancelled context may itself refuse; the record must still be possible
|
|
// through the detached context, so look the node up again on a live one.
|
|
t.Fatalf("recording a send after cancellation failed: %v", err)
|
|
}
|
|
outstanding, err := inv.Outstanding(t.Context(), "anchor")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if outstanding != digest || digest != digestOf(body) {
|
|
t.Fatalf("the send was not recorded: outstanding %q, sent %q", outstanding, digest)
|
|
}
|
|
}
|