On 2026-10-02 a runtime assigned and applied on two machines was undone two seconds later by a declaration that had the assignments of a minute earlier. Every path composes from the records at compose time and holds the machines it sends — but the number went on 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 host, which rightly refuses a lower number, took the older content as the mesh's newest word. The record of that send was never written either: it is written after the declaration is away, on the sender's context, and the controller sending it was being replaced in that very second — status read "applied, current" over a machine just told otherwise. Now the number is taken before the composition reads anything, in every path, so what was composed earlier is numbered lower however late it goes out and the host's refusal does what it is for; and what was sent is written down on a context that outlives the sender, bounded, so a dying controller still records what it told a machine. The `declare` command — a declaration a person sends by hand — records its send too. Proven: compositions in one order and sends in the other keep the numbers in composition order; a send is recorded after the sender's context is cancelled.
94 lines
4.0 KiB
Go
94 lines
4.0 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) (int64, error) {
|
|
if node == "anchor" {
|
|
return 0, context.DeadlineExceeded
|
|
}
|
|
return 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)
|
|
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)
|
|
}
|
|
}
|