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:
@@ -0,0 +1,353 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// Acting under the lease (novox/hq to-be 45 §6, ADR 0227 rule 1).
|
||||
//
|
||||
// **Only the instance holding the lease acts**: sends a declaration, writes a plan, a condition or a
|
||||
// call. Every one of those passes theLease.epoch, which answers the epoch the act carries or why it may
|
||||
// not happen. Three ways a process stands to the lease:
|
||||
//
|
||||
// - **The serving controller** takes it before it does anything else — before it asserts the bus's
|
||||
// objects, which are the controller's to write — waiting while another holds it, and renews it.
|
||||
// A renewal refused or failed is the lease lost: the gate closes at once and the process exits, so
|
||||
// its service manager restarts it as a candidate (serve, in push.go).
|
||||
// - **A command run at a shell** — `push` in the installer, the lab, a person repairing a mesh whose
|
||||
// controller is down (issue 201) — acts **under the holder's epoch** when a controller holds the
|
||||
// lease: it is the same mesh's word, composed and sent under the store's hold of each machine like
|
||||
// the serving controller's, and the epoch it carries is read at the moment it acts, so a handover
|
||||
// between makes it stale and refused like any other. **When nobody holds the lease, the command
|
||||
// takes it** for as long as it runs and gives it back; a controller starting meanwhile waits for
|
||||
// it, as it would for another controller.
|
||||
// - **A process with no bus** — a test, a command that only reads — acts with no epoch and is
|
||||
// refused nothing: there is nothing to order against, and nothing it does reaches a machine.
|
||||
//
|
||||
// **Unleased, said and temporary.** A serving controller whose bus refuses it the lease's key — the bus's
|
||||
// user list is older than this build and does not grant the bucket yet — and that sees no other holder
|
||||
// serves without one, as every controller did before the lease: declarations carry no epoch, which no
|
||||
// node-engine refuses. Said once, kept as a condition (S12), and tried again every renewal interval; the
|
||||
// first push that sends the bus its new user list grants it, and the next try takes it. Refusing to act
|
||||
// instead would be a controller that can never send the user list that lets it act.
|
||||
|
||||
// actor is this process's standing to the lease.
|
||||
type actor struct {
|
||||
mu sync.Mutex
|
||||
// held is the lease this process holds: the serving controller's, or a command's own.
|
||||
held *lease.Lease
|
||||
// unleased is why a serving controller acts without the lease; empty while it holds it or is not
|
||||
// serving.
|
||||
unleased string
|
||||
// serving is a serving controller, which never borrows another's epoch.
|
||||
serving bool
|
||||
// kv is the lease bucket, for a command to read the holder's epoch from.
|
||||
kv jetstream.KeyValue
|
||||
close func()
|
||||
// noBus is a process with no bus configured.
|
||||
noBus bool
|
||||
// reset is when the lease bucket was found raised again from nothing and its revisions moved past
|
||||
// the highest epoch issued, and what was said of it; zero when it was not (S12).
|
||||
reset time.Time
|
||||
resetSaid string
|
||||
}
|
||||
|
||||
// theLease is this process's standing to the lease.
|
||||
var theLease = &actor{}
|
||||
|
||||
// instance names this process among controller instances: its machine, its process and when it
|
||||
// started. The lease's holder and every call this process keeps carry it.
|
||||
var instance = func() string {
|
||||
host, _ := os.Hostname()
|
||||
return fmt.Sprintf("controller@%s pid %d since %s", host, os.Getpid(), time.Now().UTC().Format(time.RFC3339))
|
||||
}()
|
||||
|
||||
// epoch is the gate: the epoch an act carries — zero for none — or why it may not happen.
|
||||
func (a *actor) epoch(ctx context.Context) (uint64, error) {
|
||||
a.mu.Lock()
|
||||
held, serving, unleased, noBus := a.held, a.serving, a.unleased, a.noBus
|
||||
a.mu.Unlock()
|
||||
switch {
|
||||
case held != nil:
|
||||
return held.Epoch()
|
||||
case serving && unleased != "":
|
||||
return 0, nil
|
||||
case serving:
|
||||
return 0, lease.ErrNotHeld
|
||||
case noBus:
|
||||
return 0, nil
|
||||
}
|
||||
return a.forACommand(ctx)
|
||||
}
|
||||
|
||||
// forACommand is a command's epoch: the holder's, or a lease of its own when nobody holds one.
|
||||
func (a *actor) forACommand(ctx context.Context) (uint64, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.held != nil {
|
||||
return a.held.Epoch()
|
||||
}
|
||||
if a.kv == nil {
|
||||
address, err := broker.BusAddress()
|
||||
if err != nil {
|
||||
// No bus: this process reaches no machine, and has nothing to order against.
|
||||
a.noBus = true
|
||||
return 0, nil
|
||||
}
|
||||
js, err := broker.Dial(address)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("the bus cannot be reached, so whether a controller holds the lease cannot be "+
|
||||
"read and nothing is done: %w", err)
|
||||
}
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
js.Close()
|
||||
return 0, err
|
||||
}
|
||||
reading, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := broker.EnsureLeaseBucket(reading, api); err != nil {
|
||||
js.Close()
|
||||
return 0, err
|
||||
}
|
||||
kv, err := api.KeyValue(reading, broker.LeaseBucket)
|
||||
if err != nil {
|
||||
js.Close()
|
||||
return 0, err
|
||||
}
|
||||
a.kv, a.close = kv, js.Close
|
||||
if _, found, err := lease.Current(reading, kv); err == nil && !found {
|
||||
// Nobody: this command takes it for as long as it runs.
|
||||
l, err := lease.Open(reading, api, broker.LeaseBucket, lease.Options{Holder: holderOf(instance),
|
||||
Say: func(format string, args ...any) { fmt.Printf(format+"\n", args...) }})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
epoch, err := l.TryTake(reading)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("no controller holds the lease and this command could not take it: %w", err)
|
||||
}
|
||||
keeping, stop := context.WithCancel(context.Background())
|
||||
go l.Keep(keeping)
|
||||
a.held = l
|
||||
closeBus := a.close
|
||||
a.close = func() {
|
||||
stop()
|
||||
l.Release(context.Background())
|
||||
closeBus()
|
||||
}
|
||||
return epoch, nil
|
||||
}
|
||||
}
|
||||
reading, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
holder, found, err := lease.Current(reading, a.kv)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("who holds the controller lease cannot be read, so nothing is done: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return 0, errors.New("the controller that held the lease while this command ran let go of it; nothing " +
|
||||
"more is done under an epoch nobody holds — run the command again")
|
||||
}
|
||||
return holder.Epoch, nil
|
||||
}
|
||||
|
||||
// release gives back what this process holds, at its end.
|
||||
func (a *actor) release() {
|
||||
a.mu.Lock()
|
||||
closing := a.close
|
||||
a.close = nil
|
||||
a.mu.Unlock()
|
||||
if closing != nil {
|
||||
closing()
|
||||
}
|
||||
}
|
||||
|
||||
// holderOf is this process as the lease's holder.
|
||||
func holderOf(instance string) lease.Holder {
|
||||
host, _ := os.Hostname()
|
||||
return lease.Holder{Instance: instance, Host: host, Build: version}
|
||||
}
|
||||
|
||||
// serveUnderTheLease takes the lease for the serving controller, waiting while another holds it, and
|
||||
// keeps it until ctx ends. Lost is closed when it is lost; the caller exits on it.
|
||||
func (a *actor) serveUnderTheLease(ctx context.Context, inv *inventory.Inventory, address string) (lost <-chan struct{}, err error) {
|
||||
a.mu.Lock()
|
||||
a.serving = true
|
||||
a.mu.Unlock()
|
||||
js, err := broker.Dial(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the mesh is on the bus at %s and this control plane cannot reach it to take the "+
|
||||
"lease: %w", broker.BareAddress(address), err)
|
||||
}
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
js.Close()
|
||||
return nil, err
|
||||
}
|
||||
asserting, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err = broker.EnsureLeaseBucket(asserting, api)
|
||||
cancel()
|
||||
if err != nil {
|
||||
js.Close()
|
||||
return nil, err
|
||||
}
|
||||
say := func(format string, args ...any) { fmt.Printf(format+"\n", args...) }
|
||||
l, err := lease.Open(ctx, api, broker.LeaseBucket, lease.Options{Holder: holderOf(instance),
|
||||
Floor: inv.HighestEpoch, Say: say, Moved: func(was, floor uint64) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.reset = time.Now()
|
||||
a.resetSaid = fmt.Sprintf("the lease bucket was at revision %d with epoch %d already issued: it was "+
|
||||
"raised again from nothing (a bus whose data was replaced), and its revisions were moved past %d so "+
|
||||
"no machine refuses the next epoch", was, floor, floor)
|
||||
}})
|
||||
if err != nil {
|
||||
js.Close()
|
||||
return nil, err
|
||||
}
|
||||
gone := make(chan struct{})
|
||||
epoch, err := l.Take(ctx)
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
js.Close()
|
||||
return nil, ctx.Err()
|
||||
case err != nil && !errors.Is(err, lease.ErrUnwritable):
|
||||
// Whether another controller acts cannot be told: this one does not act, and exits to try again.
|
||||
js.Close()
|
||||
return nil, err
|
||||
case err != nil:
|
||||
// Nobody holds it and the bus will not let it be written: unleased, said, tried again (see above).
|
||||
a.mu.Lock()
|
||||
a.unleased = err.Error()
|
||||
a.mu.Unlock()
|
||||
say("this controller serves WITHOUT the lease: %v. Its declarations carry no epoch; it tries again "+
|
||||
"every %s, and the first push that sends the bus its user list grants it", err, lease.RenewEvery)
|
||||
go a.takeWhenGranted(ctx, l, inv, gone)
|
||||
default:
|
||||
a.took(ctx, l, inv, epoch, gone)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.close = func() {
|
||||
if held, err := l.Epoch(); err == nil {
|
||||
ending, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
if err := inv.EndEpoch(ending, held, inventory.EpochReleased); err != nil {
|
||||
say("how epoch %d ended could not be recorded: %v", held, err)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
l.Release(context.Background())
|
||||
js.Close()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return gone, nil
|
||||
}
|
||||
|
||||
// took is the lease taken: recorded, earlier epochs nobody gave back ended as expired, kept.
|
||||
func (a *actor) took(ctx context.Context, l *lease.Lease, inv *inventory.Inventory, epoch uint64, gone chan struct{}) {
|
||||
a.mu.Lock()
|
||||
a.held, a.unleased = l, ""
|
||||
a.mu.Unlock()
|
||||
h := holderOf(instance)
|
||||
recording, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
expired, err := inv.TookEpoch(recording, inventory.Epoch{Epoch: epoch, Instance: h.Instance, Host: h.Host,
|
||||
Build: h.Build, Taken: time.Now()})
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Printf("epoch %d could not be recorded as taken, so a stale refusal from it will not name it: %v\n", epoch, err)
|
||||
}
|
||||
for _, e := range expired {
|
||||
fmt.Printf("the controller of epoch %d (%s) stopped renewing the lease without giving it back: it is "+
|
||||
"taken over at epoch %d\n", e.Epoch, e.Instance, epoch)
|
||||
}
|
||||
go l.Keep(ctx)
|
||||
go func() {
|
||||
<-l.Lost()
|
||||
if ctx.Err() == nil {
|
||||
why := l.LostWhy()
|
||||
ending, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_ = inv.EndEpoch(ending, epoch, inventory.EpochLost)
|
||||
cancel()
|
||||
fmt.Printf("the controller lease was lost (epoch %d): %v — this controller stops and exits, to "+
|
||||
"be started again as a candidate\n", epoch, why)
|
||||
}
|
||||
close(gone)
|
||||
}()
|
||||
}
|
||||
|
||||
// takeWhenGranted tries the lease again every renewal interval while serving unleased, and stops this
|
||||
// controller if another took it meanwhile: two serving at once is what the lease is for.
|
||||
func (a *actor) takeWhenGranted(ctx context.Context, l *lease.Lease, inv *inventory.Inventory, gone chan struct{}) {
|
||||
tick := time.NewTicker(lease.RenewEvery)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
epoch, err := l.TryTake(ctx)
|
||||
if errors.Is(err, lease.ErrTaken) {
|
||||
fmt.Printf("another controller took the lease while this one served without it: %v — this one "+
|
||||
"stops and exits\n", err)
|
||||
close(gone)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// Still not written, or not readable this time: unleased, said by S12, tried again.
|
||||
a.mu.Lock()
|
||||
a.unleased = err.Error()
|
||||
a.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
a.took(ctx, l, inv, epoch, gone)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// standing is what `status` and the self-check say of this process and the lease.
|
||||
type standing struct {
|
||||
Epoch uint64
|
||||
Held bool
|
||||
Renewed time.Time
|
||||
Unleased string
|
||||
// Reset is when the lease bucket was found raised again from nothing, and ResetSaid what of it.
|
||||
Reset time.Time
|
||||
ResetSaid string
|
||||
}
|
||||
|
||||
func (a *actor) standing() standing {
|
||||
a.mu.Lock()
|
||||
held, unleased, reset, resetSaid := a.held, a.unleased, a.reset, a.resetSaid
|
||||
a.mu.Unlock()
|
||||
st := standing{Unleased: unleased, Reset: reset, ResetSaid: resetSaid}
|
||||
if held == nil {
|
||||
return st
|
||||
}
|
||||
epoch, err := held.Epoch()
|
||||
st.Epoch, st.Held, st.Renewed = epoch, err == nil, held.Renewed()
|
||||
return st
|
||||
}
|
||||
|
||||
// The gates, given to what acts: a declaration's send (link) and a plan's write (the inventory).
|
||||
func init() {
|
||||
link.ActingGate = func(ctx context.Context) error {
|
||||
_, err := theLease.epoch(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("this controller may not send: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -707,12 +707,14 @@ func heldBy(ctx context.Context) map[string]string {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not read what this mesh has built, so a module naming a "+
|
||||
"base will be told that base is missing: %v\n", err)
|
||||
// empty-on-error: said above; a build that names a base is refused by name for want of it
|
||||
return nil
|
||||
}
|
||||
defer open.Close()
|
||||
held, err := open.inventory.Held(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not read what this mesh has built: %v\n", err)
|
||||
// empty-on-error: said above; a build that names a base is refused by name for want of it
|
||||
return nil
|
||||
}
|
||||
address, err := whereABuilderReachesTheStore(ctx, open.inventory)
|
||||
|
||||
@@ -46,7 +46,9 @@ func keeperOn(ctx context.Context, conn *nats.Conn) (*conditions.Keeper, error)
|
||||
Say: func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) },
|
||||
// What status leads with changed: composed again soon (a nudge outside the serving controller
|
||||
// does nothing).
|
||||
Changed: statusFrom.nudge}), nil
|
||||
Changed: statusFrom.nudge,
|
||||
// Written under the lease, carrying its epoch (novox/hq to-be 45 §6).
|
||||
Epoch: func() (uint64, error) { return theLease.epoch(context.WithoutCancel(ctx)) }}), nil
|
||||
}
|
||||
|
||||
// withKeeper runs f with the serving controller's keeper, or one of its own that says everything
|
||||
|
||||
@@ -79,9 +79,9 @@ var probeRegistry = []probe{
|
||||
"machine that is heard from", From: "issues 208, 218", Kind: "holder-silent", Phase: 1, run: probeHolders},
|
||||
{ID: "D4", Asserts: "every kept archive is held by a manifest", From: "issue 253",
|
||||
Kind: "archives-unheld", Phase: 1, run: probeArchives},
|
||||
{ID: "D5", Asserts: "exactly one lease holder; no message from a stale epoch in the last interval",
|
||||
From: "issue 204", Kind: "lease-split", Phase: 2,
|
||||
Deferred: "the lease and epoch are built in Phase 2 (to-be 45 §6): nothing holds one yet"},
|
||||
{ID: "D5", Asserts: "exactly one lease holder — the key names this controller at its epoch, and the record " +
|
||||
"holds no other epoch open; no message from a stale epoch refused in the last interval",
|
||||
From: "issue 204", Kind: "lease-split", Phase: 2, run: probeLease},
|
||||
{ID: "D6", Asserts: "every durable consumer the mesh expects exists with its definition, and is near its " +
|
||||
"stream's head", From: "issues 248, 266", Kind: "consumer-wrong", Phase: 1, run: probeConsumers},
|
||||
{ID: "D7", Asserts: "every stream the controller defines exists with its definition, and its own buckets",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A declaration carries the lease's epoch (novox/hq to-be 45 §6) — to a machine whose node-engine said
|
||||
// it reads one, and to no other: an older node-engine refuses a key it does not know, whole.
|
||||
|
||||
// bodiesDelivery records each send as the mesh does, and keeps the bodies.
|
||||
type bodiesDelivery struct {
|
||||
recordedDelivery
|
||||
bodies map[string][]byte
|
||||
}
|
||||
|
||||
func (b *bodiesDelivery) declare(ctx context.Context, s readyNode, body []byte) (string, error) {
|
||||
b.bodies[s.node] = body
|
||||
return b.recordedDelivery.declare(ctx, s, body)
|
||||
}
|
||||
|
||||
func TestAMachineIsSentTheEpochOnlyOnceItSaysItReadsOne(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
epoch := uint64(57)
|
||||
was := epochForActs
|
||||
epochForActs = func(context.Context) (uint64, error) { return epoch, nil }
|
||||
t.Cleanup(func() { epochForActs = was })
|
||||
|
||||
anchor, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordReadsEpoch(ctx, anchor.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &bodiesDelivery{recordedDelivery: recordedDelivery{inv: inv}, bodies: map[string][]byte{}}
|
||||
if _, err := sendRound(ctx, open, []string{"anchor", "laptop"}, composeForPush(open, gens), d, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
carried := func(node string) (epoch float64, has bool) {
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(d.bodies[node], &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epoch, has = envelope["epoch"].(float64)
|
||||
return epoch, has
|
||||
}
|
||||
if e, has := carried("anchor"); !has || e != 57 {
|
||||
t.Fatalf("the machine that reads an epoch was sent %v: %s", e, d.bodies["anchor"])
|
||||
}
|
||||
if _, has := carried("laptop"); has {
|
||||
t.Fatalf("a machine that never said it reads an epoch was sent one: %s", d.bodies["laptop"])
|
||||
}
|
||||
|
||||
// A new holder of the lease is not a change of the machine: neither reads as behind.
|
||||
epoch = 58
|
||||
would, err := wouldSend(ctx, open, mustNodes(t, open))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, node := range []string{"anchor", "laptop"} {
|
||||
sent, err := inv.Outstanding(ctx, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if would[node] != sent {
|
||||
t.Fatalf("%s reads as behind after the lease changed hands, with nothing else changed", node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A process that may not act composes nothing and sends nothing: its number is not taken.
|
||||
func TestNothingIsComposedOrSentWithoutTheLease(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
was := epochForActs
|
||||
epochForActs = func(context.Context) (uint64, error) { return 0, errors.New("this controller lost the lease") }
|
||||
t.Cleanup(func() { epochForActs = was })
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &bodiesDelivery{recordedDelivery: recordedDelivery{inv: open.inventory}, bodies: map[string][]byte{}}
|
||||
refused, err := sendRound(ctx, open, []string{"anchor"}, composeForPush(open, gens), d, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d.bodies) != 0 || len(refused) != 1 || !strings.Contains(refused[0], "lost the lease") {
|
||||
t.Fatalf("a controller without the lease composed %d and refused %v", len(d.bodies), refused)
|
||||
}
|
||||
anchor, _ := open.inventory.NodeByName(ctx, "anchor")
|
||||
if seq, _ := open.inventory.Sequence(ctx, anchor.ID); seq != 0 {
|
||||
t.Fatalf("a controller without the lease took sequence %d", seq)
|
||||
}
|
||||
|
||||
// And at the send itself: the gate every declaration passes.
|
||||
gate := link.ActingGate
|
||||
link.ActingGate = func(context.Context) error { return errors.New("this controller lost the lease") }
|
||||
t.Cleanup(func() { link.ActingGate = gate })
|
||||
if err := link.Declare(ctx, nil, nil, "anchor", []byte(`{"declaration":1}`), 0); err == nil ||
|
||||
!strings.Contains(err.Error(), "lost the lease") {
|
||||
t.Fatalf("a declaration was let through the gate: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func (r *recordedDelivery) grant(context.Context, []readyNode) error { return ni
|
||||
|
||||
func (r *recordedDelivery) declare(ctx context.Context, s readyNode, body []byte) (string, error) {
|
||||
r.declared = append(r.declared, s.node)
|
||||
return recordSent(ctx, r.inv, s.node, body, s.declared.Builds)
|
||||
return recordSent(ctx, r.inv, s.node, body, s.declared.Builds, s.declared.Epoch)
|
||||
}
|
||||
|
||||
// aResolver is a module built from a repository, at a commit, with something on the machine that
|
||||
|
||||
@@ -45,11 +45,11 @@ func TestADeclarationComposedEarlierIsNumberedLowerWhateverOrderItIsSent(t *test
|
||||
// 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) {
|
||||
allot := func(node string) (order, error) {
|
||||
if node == "anchor" {
|
||||
return 0, context.DeadlineExceeded
|
||||
return order{}, context.DeadlineExceeded
|
||||
}
|
||||
return 7, nil
|
||||
return order{sequence: 7}, nil
|
||||
}
|
||||
sending, refusals := composeEach([]string{"anchor", "laptop"}, allot, func(node string) (sendable, error) {
|
||||
calls++
|
||||
@@ -77,7 +77,7 @@ func TestASendIsRecordedEvenWhenTheSenderIsBeingCancelled(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
)
|
||||
|
||||
// A command run at a shell (novox/hq to-be 45 §6): under the holder's epoch while a controller holds the
|
||||
// lease, read at the moment it acts; under a lease of its own while none does, given back as it ends.
|
||||
func TestACommandActsUnderTheHoldersEpochOrItsOwn(t *testing.T) {
|
||||
url, kv := aBusForTheLease(t)
|
||||
t.Setenv(broker.NATSVar, url)
|
||||
ctx := t.Context()
|
||||
|
||||
// Nobody holds it: the command takes it, and gives it back.
|
||||
cmd := &actor{}
|
||||
own, err := cmd.epoch(ctx)
|
||||
if err != nil || own == 0 {
|
||||
t.Fatalf("a command with nobody holding the lease acts as %d (%v)", own, err)
|
||||
}
|
||||
if h, found, _ := lease.Current(ctx, kv); !found || h.Epoch != own {
|
||||
t.Fatalf("the command's lease is not on the bus: %+v", h)
|
||||
}
|
||||
cmd.release()
|
||||
if _, found, _ := lease.Current(ctx, kv); found {
|
||||
t.Fatal("the command did not give its lease back as it ended")
|
||||
}
|
||||
|
||||
// A controller holds it: a command acts under that epoch.
|
||||
l, err := lease.Open(ctx, mustJetStream(t, url), broker.LeaseBucket, lease.Options{Holder: lease.Holder{Instance: "serving"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := l.TryTake(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
borrower := &actor{}
|
||||
defer borrower.release()
|
||||
if got, err := borrower.epoch(ctx); err != nil || got != held {
|
||||
t.Fatalf("a command acts as %d (%v), want the holder's %d", got, err, held)
|
||||
}
|
||||
// The holder lets go: the command does not go on under an epoch nobody holds.
|
||||
l.Release(ctx)
|
||||
if _, err := borrower.epoch(ctx); err == nil {
|
||||
t.Fatal("a command acted under an epoch nobody holds any more")
|
||||
}
|
||||
}
|
||||
|
||||
// mustJetStream is a connection of its own to the test bus.
|
||||
func mustJetStream(t *testing.T, url string) jetstream.JetStream {
|
||||
t.Helper()
|
||||
conn, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(conn.Close)
|
||||
js, err := jetstream.New(conn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return js
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
)
|
||||
|
||||
// Two controllers at once (novox/hq to-be 45 §6, issue 204; the half of replay R1 that lives here): two
|
||||
// serving controllers over one store and one bus. The second waits while the first holds the lease; on
|
||||
// a handover it takes it at a higher epoch, the record says which held what and how each ended; and the
|
||||
// one that lost it acts no more — no declaration composed, no plan and no condition written — the
|
||||
// moment it lost it.
|
||||
//
|
||||
// MESH_TEST_POSTGRES=… MESH_TEST_NATS=nats://127.0.0.1:14222 go test ./cmd/mesh-controller/ -run Controllers
|
||||
|
||||
// aBusForTheLease is the test bus with the controller's lease bucket new.
|
||||
func aBusForTheLease(t *testing.T) (string, jetstream.KeyValue) {
|
||||
t.Helper()
|
||||
url := os.Getenv("MESH_TEST_NATS")
|
||||
if url == "" {
|
||||
t.Skip("MESH_TEST_NATS unset")
|
||||
}
|
||||
conn, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(conn.Close)
|
||||
js, err := jetstream.New(conn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = js.DeleteKeyValue(t.Context(), broker.LeaseBucket)
|
||||
if err := broker.EnsureLeaseBucket(t.Context(), js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kv, err := js.KeyValue(t.Context(), broker.LeaseBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = js.DeleteKeyValue(context.Background(), broker.LeaseBucket) })
|
||||
return url, kv
|
||||
}
|
||||
|
||||
func TestTwoControllersOneActs(t *testing.T) {
|
||||
url, kv := aBusForTheLease(t)
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// Controller A takes the lease.
|
||||
a := &actor{}
|
||||
aCtx, stopA := context.WithCancel(ctx)
|
||||
defer stopA()
|
||||
lostA, err := a.serveUnderTheLease(aCtx, inv, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epochA, err := a.epoch(ctx)
|
||||
if err != nil || epochA == 0 {
|
||||
t.Fatalf("A holds no epoch: %d, %v", epochA, err)
|
||||
}
|
||||
|
||||
// Controller B starts while A holds it, and waits — acting on nothing meanwhile.
|
||||
b := &actor{}
|
||||
bCtx, stopB := context.WithCancel(ctx)
|
||||
defer stopB()
|
||||
tookB := make(chan (<-chan struct{}), 1)
|
||||
go func() {
|
||||
lost, err := b.serveUnderTheLease(bCtx, inv, url)
|
||||
if err != nil {
|
||||
t.Errorf("B: %v", err)
|
||||
close(tookB)
|
||||
return
|
||||
}
|
||||
tookB <- lost
|
||||
}()
|
||||
select {
|
||||
case <-tookB:
|
||||
t.Fatal("B took the lease while A held it")
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
if _, err := b.epoch(ctx); !errors.Is(err, lease.ErrNotHeld) {
|
||||
t.Fatalf("B, waiting, may act: %v", err)
|
||||
}
|
||||
|
||||
// A hands over, as a controller being replaced does: B takes the lease at once, at a higher epoch.
|
||||
stopA()
|
||||
a.release()
|
||||
var lostB <-chan struct{}
|
||||
select {
|
||||
case lostB = <-tookB:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("B did not take the lease A gave back")
|
||||
}
|
||||
select {
|
||||
case <-lostA:
|
||||
default:
|
||||
t.Fatal("A, having given the lease back, is not told it no longer holds it")
|
||||
}
|
||||
epochB, err := b.epoch(ctx)
|
||||
if err != nil || epochB <= epochA {
|
||||
t.Fatalf("B acts as epoch %d after A's %d (%v): an epoch only grows", epochB, epochA, err)
|
||||
}
|
||||
if _, err := a.epoch(ctx); err == nil {
|
||||
t.Fatal("A acts after giving the lease back")
|
||||
}
|
||||
ea, _, _ := inv.EpochOf(ctx, epochA)
|
||||
eb, _, _ := inv.EpochOf(ctx, epochB)
|
||||
if ea.How != inventory.EpochReleased || eb.Ended != nil || eb.Instance != instance {
|
||||
t.Fatalf("the record of the handover reads %+v then %+v", ea, eb)
|
||||
}
|
||||
|
||||
// Something else writes the lease's key — a third controller on a clock that read it as expired:
|
||||
// B's next renewal is refused, and B stops acting at once.
|
||||
if _, err := kv.Put(ctx, lease.Key, []byte(`{"instance":"a third controller","epoch":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-lostB:
|
||||
case <-time.After(2 * lease.RenewEvery):
|
||||
t.Fatal("B was not told it lost the lease")
|
||||
}
|
||||
if _, err := b.epoch(ctx); !errors.Is(err, lease.ErrNotHeld) {
|
||||
t.Fatalf("B acts after losing the lease: %v", err)
|
||||
}
|
||||
// Nothing B does is written: a declaration's number is not taken, a plan is not saved, a condition
|
||||
// is not raised.
|
||||
inv.ActsUnder(b.epoch)
|
||||
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := inventory.Plan{ID: "plan-after-loss", Repository: "novox/app", Commit: "c0ffee00", Created: time.Now(),
|
||||
State: inventory.PlanBuilding}
|
||||
if err := inv.SavePlan(ctx, &saved); err == nil {
|
||||
t.Fatal("B wrote a plan after losing the lease")
|
||||
}
|
||||
store := conditions.NewInMemory()
|
||||
keeper := conditions.NewKeeper(ctx, conditions.Options{Store: store, History: store,
|
||||
Epoch: func() (uint64, error) { return b.epoch(ctx) }})
|
||||
defer keeper.Close(context.Background())
|
||||
if _, err := keeper.Observe(ctx, conditions.Observation{Scope: conditions.ScopeCore, ID: "x", Kind: "x",
|
||||
Severity: conditions.Warning, Summary: "x", Source: "test"}); err == nil {
|
||||
t.Fatal("B raised a condition after losing the lease")
|
||||
}
|
||||
if ended, _, _ := inv.EpochOf(ctx, epochB); ended.How != inventory.EpochLost {
|
||||
t.Fatalf("B's epoch does not say it was lost: %+v", ended)
|
||||
}
|
||||
}
|
||||
|
||||
// A controller whose bus refuses it the lease's key, with nobody holding it, serves without the lease:
|
||||
// it acts with no epoch — refused by no node-engine — says so, and takes the lease once it can.
|
||||
func TestAControllerTheBusRefusesTheLeaseServesUnleasedAndSaysSo(t *testing.T) {
|
||||
a := &actor{serving: true, unleased: "the bus refused the lease's key"}
|
||||
if epoch, err := a.epoch(context.Background()); err != nil || epoch != 0 {
|
||||
t.Fatalf("an unleased controller answers %d, %v: it acts, claiming no epoch", epoch, err)
|
||||
}
|
||||
if st := a.standing(); st.Unleased == "" || st.Held {
|
||||
t.Fatalf("its standing says %+v", st)
|
||||
}
|
||||
// One that neither holds nor is unleased — still waiting — acts on nothing.
|
||||
waiting := &actor{serving: true}
|
||||
if _, err := waiting.epoch(context.Background()); !errors.Is(err, lease.ErrNotHeld) {
|
||||
t.Fatalf("a controller waiting for the lease may act: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,9 @@ func run() error {
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
// Whatever this process holds of the controller's lease is given back as it ends (novox/hq to-be
|
||||
// 45 §6), so the next controller takes it at once rather than after its age.
|
||||
defer theLease.release()
|
||||
|
||||
switch args[0] {
|
||||
case "build":
|
||||
|
||||
@@ -280,7 +280,7 @@ func retryPlan(ctx context.Context, open *stores, id string) (string, error) {
|
||||
}
|
||||
}
|
||||
resumed(&p, fmt.Sprintf("tier %d retried by hand: %s asked again", p.Tier, strings.Join(failed, ", ")))
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p.State != inventory.PlanBuilding {
|
||||
@@ -323,7 +323,7 @@ func joinAPlan(ctx context.Context, open *stores, module string) (bool, string,
|
||||
askModule(ctx, &p, module, byName)
|
||||
s := p.Modules[module]
|
||||
resumed(&p, fmt.Sprintf("tier %d: %s rebuilt by hand", p.Tier, module))
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if s.State != "asked" {
|
||||
@@ -393,7 +393,7 @@ func retryRollouts(ctx context.Context, open *stores, p *inventory.Plan) (string
|
||||
}
|
||||
p.State = inventory.PlanRolling
|
||||
p.Note = fmt.Sprintf("tier %d retried by hand; sent %s first again", p.Tier, strings.Join(said, "; "))
|
||||
if err := open.inventory.SavePlan(ctx, *p); err != nil {
|
||||
if err := open.inventory.SavePlan(ctx, p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s retried at tier %d of %d: sent %s first again; the rest follow once it reports it "+
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestAControllerLeavesThePlansToTheOneHoldingThem(t *testing.T) {
|
||||
plan := inventory.Plan{ID: "plan-213", Repository: "r", Commit: "abc", Created: now, Updated: now,
|
||||
State: inventory.PlanRolling, Tier: 1, Tiers: [][]string{{"app"}},
|
||||
Modules: map[string]*inventory.PlanModule{"app": {State: "built"}}}
|
||||
if err := open.inventory.SavePlan(ctx, plan); err != nil {
|
||||
if err := open.inventory.SavePlan(ctx, &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/nats-io/nats.go/micro"
|
||||
"github.com/novox/mesh-host/validate"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
@@ -61,6 +63,15 @@ func probeDeclarations(ctx context.Context, d *doctor) ([]conditions.Observation
|
||||
if err == nil && gensErr == nil {
|
||||
var declared sendable
|
||||
if declared, err = declarationWith(ctx, open, n.Name, plan, settings, gens, Reading); err == nil {
|
||||
// With the order it was last sent, so the validator reads the envelope a machine is sent
|
||||
// — its epoch included (novox/hq to-be 45 §6).
|
||||
var serr error
|
||||
if declared.Sequence, serr = open.inventory.Sequence(ctx, n.ID); serr == nil {
|
||||
declared.Epoch, serr = open.inventory.SentEpoch(ctx, n.ID)
|
||||
}
|
||||
if serr != nil {
|
||||
return nil, serr // the store, not the machine: the probe could not run
|
||||
}
|
||||
var body []byte
|
||||
if body, err = declared.Body(); err == nil {
|
||||
problems = validate.Declaration(body)
|
||||
@@ -818,3 +829,73 @@ func askSeatTool(ctx context.Context, conn *nats.Conn, seat, verb, node string)
|
||||
|
||||
// oneLine is a message of several lines said on one, its runs of space made one.
|
||||
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }
|
||||
|
||||
// probeLease is D5 (novox/hq to-be 45 §4, §6): exactly one lease holder — the key on the bus names this
|
||||
// controller at the epoch it acts under, and the mesh's record has that epoch and no other open — and no
|
||||
// message from a stale epoch refused in the last interval.
|
||||
func probeLease(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||
if d.js == nil {
|
||||
return nil, errors.New("this controller is not on the bus")
|
||||
}
|
||||
st := theLease.standing()
|
||||
var out []conditions.Observation
|
||||
split := func(token, summary, said string) {
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeCore, ID: "controller.lease", Token: token,
|
||||
Machine: d.host, Severity: conditions.Urgent, Summary: summary, Said: said})
|
||||
}
|
||||
if st.Unleased != "" {
|
||||
split("unheld", "this controller acts without the lease, so nothing keeps another from acting beside it: "+
|
||||
st.Unleased, st.Unleased)
|
||||
return out, nil
|
||||
}
|
||||
api, err := jetstream.New(d.js.Conn())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, broker.LeaseBucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the lease bucket cannot be read: %w", err)
|
||||
}
|
||||
holder, found, err := lease.Current(ctx, kv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the lease cannot be read: %w", err)
|
||||
}
|
||||
switch {
|
||||
case !found:
|
||||
split("split", fmt.Sprintf("nobody holds the lease on the bus, and this controller acts as epoch %d", st.Epoch),
|
||||
"the lease's key is absent")
|
||||
case holder.Instance != instance || holder.Epoch != st.Epoch:
|
||||
split("split", fmt.Sprintf("the lease on the bus names %s at epoch %d, and this controller (%s) acts as "+
|
||||
"epoch %d: two controllers believe they may act", holder.Instance, holder.Epoch, instance, st.Epoch),
|
||||
fmt.Sprintf("held by %s, epoch %d", holder.Instance, holder.Epoch))
|
||||
}
|
||||
// The record: one epoch open, this one.
|
||||
epochs, err := d.open.inventory.EpochsSince(ctx, time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the epochs the mesh issued cannot be read: %w", err)
|
||||
}
|
||||
var open []string
|
||||
for _, e := range epochs {
|
||||
if e.Ended == nil && e.Epoch != st.Epoch {
|
||||
open = append(open, fmt.Sprintf("epoch %d (%s)", e.Epoch, e.Instance))
|
||||
}
|
||||
}
|
||||
if len(open) > 0 {
|
||||
split("open-epochs", fmt.Sprintf("the mesh's record holds %s open beside this controller's epoch %d: a "+
|
||||
"holder that neither gave the lease back nor was found expired", strings.Join(open, ", "), st.Epoch),
|
||||
strings.Join(open, ", "))
|
||||
}
|
||||
// And no message from a stale epoch in the last interval.
|
||||
for _, w := range link.StaleRefusals.Within(time.Now().Add(-doctorEvery)) {
|
||||
if w.Epoch <= 0 || uint64(w.Epoch) >= st.Epoch {
|
||||
continue
|
||||
}
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeCore, ID: fmt.Sprintf("controller.epoch-%d", w.Epoch),
|
||||
Token: "stale-epoch", Machine: firstOf(w.Receivers), Severity: conditions.Urgent,
|
||||
Summary: fmt.Sprintf("%d declaration(s) from epoch %d — older than this controller's %d — reached %s in the "+
|
||||
"last %s and were refused: a controller that lost the lease is still sending", w.Count, w.Epoch, st.Epoch,
|
||||
strings.Join(w.Receivers, ", "), doctorEvery),
|
||||
Said: fmt.Sprintf("%d refused, the last at %s", w.Count, w.Last.UTC().Format(time.RFC3339))})
|
||||
}
|
||||
return sortedFound(out), nil
|
||||
}
|
||||
|
||||
+94
-25
@@ -63,7 +63,7 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En
|
||||
return link.ConnectNats(js, enroller, listener), nil
|
||||
}
|
||||
|
||||
func serve(ctx context.Context) error {
|
||||
func serve(ctx context.Context) (err error) {
|
||||
// The one process whose log is read over time, so the one that says each change to a node's
|
||||
// unmet seat dependencies once (novox/hq ADR 0207).
|
||||
logUnheldChanges = true
|
||||
@@ -104,6 +104,41 @@ func serve(ctx context.Context) error {
|
||||
// being live is refused, because a mesh half on each is one where a declaration goes out on one
|
||||
// and the report comes back on the other, and every component logs success while it happens.
|
||||
|
||||
// **The lease, before anything that acts** (novox/hq to-be 45 §6): asserting the bus's objects is the
|
||||
// controller's to do, and so is everything after. A controller starting while another holds it waits
|
||||
// here, said; one that loses it stops: every act's gate closes at once, and ctx ends so the process
|
||||
// exits and is started again as a candidate.
|
||||
busAddress, err := broker.BusAddress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lost, err := theLease.serveUnderTheLease(ctx, inv, busAddress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Given back before the store closes, so the epoch is recorded as given back rather than found
|
||||
// expired by the next holder.
|
||||
defer theLease.release()
|
||||
ctx, stopActing := context.WithCancel(ctx)
|
||||
defer stopActing()
|
||||
go func() {
|
||||
select {
|
||||
case <-lost:
|
||||
stopActing()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
select {
|
||||
case <-lost:
|
||||
if err == nil {
|
||||
err = errors.New("the controller lease was lost; this controller stopped acting and exits, to " +
|
||||
"be started again as a candidate")
|
||||
}
|
||||
default:
|
||||
}
|
||||
}()
|
||||
|
||||
work := link.Enrolment{Inventory: inv, Identity: ident, Broker: known,
|
||||
OnNATS: true}
|
||||
// `status` from a summary kept current here (novox/hq to-be 45 Phase 0): a machine saying
|
||||
@@ -170,6 +205,9 @@ func serve(ctx context.Context) error {
|
||||
givenEvents = bus
|
||||
// Composed now and kept current, before the verb that answers from it is served.
|
||||
go statusFrom.keep(ctx)
|
||||
// Every call carries the lease's epoch, and its record is written only under the lease (novox/hq
|
||||
// to-be 45 §6).
|
||||
link.Calls.UnderLease(func() (uint64, error) { return theLease.epoch(ctx) })
|
||||
// A call that outlasts its caller's patience is followed by `calls` (novox/hq issue 265).
|
||||
link.Calls.Follow = catalogue.ControllerSeatName + ".calls"
|
||||
// And every call is kept on the bus, so a restart of this process keeps what came of each
|
||||
@@ -178,7 +216,7 @@ func serve(ctx context.Context) error {
|
||||
said := log.New(os.Stdout, "", log.LstdFlags)
|
||||
if keeper, err := link.CallsOnTheBus(ctx, bus.Conn); err != nil {
|
||||
fmt.Printf("calls are kept in memory only, and lost when this controller stops: %v\n", err)
|
||||
} else if err := link.Calls.Durably(ctx, keeper, controllerProcess(), said); err != nil {
|
||||
} else if err := link.Calls.Durably(ctx, keeper, instance, said); err != nil {
|
||||
fmt.Printf("calls are kept on the bus from now on; the ones kept before could not be read: %v\n", err)
|
||||
}
|
||||
// What is wrong, kept and said (novox/hq to-be 45 §2): the condition store, the watchdogs of the
|
||||
@@ -257,7 +295,12 @@ func declare(ctx context.Context, args []string) error {
|
||||
// is still what the machine was last told, and status must not read it as current for the one
|
||||
// the mesh would compose. Which builds it carried is recorded as not known (novox/hq issue 259):
|
||||
// the mesh did not compose it, so a push that does not name this machine treats it as held.
|
||||
if _, err := recordSent(ctx, inv, node, raw, nil); err != nil {
|
||||
// The epoch it carried, if a person wrote one in, is what the machine heard.
|
||||
var carried struct {
|
||||
Epoch uint64 `json:"epoch"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &carried)
|
||||
if _, err := recordSent(ctx, inv, node, raw, nil, carried.Epoch); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("sent %s a signed declaration (%d bytes)\n", node, len(raw))
|
||||
@@ -586,7 +629,7 @@ type readyNode struct {
|
||||
//
|
||||
// The all-or-nothing rule is kept where it means something — sendTo, which rotates a credential
|
||||
// across two machines that must agree — and dropped here, where it never did.
|
||||
func composeEach(names []string, allot func(node string) (int64, error),
|
||||
func composeEach(names []string, allot func(node string) (order, error),
|
||||
compose func(node string) (sendable, error)) ([]readyNode, []string) {
|
||||
|
||||
var sending []readyNode
|
||||
@@ -600,7 +643,7 @@ func composeEach(names []string, allot func(node string) (int64, error),
|
||||
// took the older content as the newer word: on 2026-10-02 a runtime assigned and applied on
|
||||
// two machines was undone two seconds later by exactly that. Taken here, before the first
|
||||
// read, what was composed earlier is numbered lower whatever order the sends happen in.
|
||||
seq, err := allot(name)
|
||||
numbered, err := allot(name)
|
||||
if err != nil {
|
||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||
continue
|
||||
@@ -610,7 +653,7 @@ func composeEach(names []string, allot func(node string) (int64, error),
|
||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||
continue
|
||||
}
|
||||
declared.Sequence = seq
|
||||
declared.Sequence, declared.Epoch = numbered.sequence, numbered.epoch
|
||||
if len(declared.Resources) == 0 {
|
||||
// Sent, not skipped (novox/hq issue 127). A node whose declaration composes to
|
||||
// nothing may have HELD something before — the broker opening a placement gave it,
|
||||
@@ -794,7 +837,7 @@ func (b overTheBus) declare(ctx context.Context, s readyNode, body []byte) (stri
|
||||
}
|
||||
// After it is away, not before. A digest recorded for something that failed to send would make
|
||||
// the machine look current for a declaration it never received.
|
||||
digest, err := recordSent(ctx, b.open.inventory, s.node, body, s.declared.Builds)
|
||||
digest, err := recordSent(ctx, b.open.inventory, s.node, body, s.declared.Builds, s.declared.Epoch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -947,7 +990,7 @@ func sendToEach(ctx context.Context, open *stores, names []string) ([]string, er
|
||||
var refusals []string
|
||||
for _, name := range names {
|
||||
// Numbered before composing, for the reason composeEach gives (novox/hq issue 204).
|
||||
seq, err := allot(ctx, inv, name)
|
||||
numbered, err := allot(ctx, inv, name)
|
||||
if err != nil {
|
||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||
continue
|
||||
@@ -963,7 +1006,7 @@ func sendToEach(ctx context.Context, open *stores, names []string) ([]string, er
|
||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||
continue
|
||||
}
|
||||
declared.Sequence = seq
|
||||
declared.Sequence, declared.Epoch = numbered.sequence, numbered.epoch
|
||||
reportLeftOut(name, declared)
|
||||
sending = append(sending, readyNode{name, declared})
|
||||
}
|
||||
@@ -1120,6 +1163,11 @@ func wouldSendFrom(ctx context.Context, open *stores,
|
||||
if declared.Sequence, err = open.inventory.Sequence(ctx, n.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// And the epoch it was last sent under, for the same reason: a new holder of the lease is not a
|
||||
// change of the machine (novox/hq to-be 45 §6).
|
||||
if declared.Epoch, err = open.inventory.SentEpoch(ctx, n.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := declared.Body()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1245,17 +1293,46 @@ func seatHolders(ctx context.Context, inv *inventory.Inventory) (map[string]brok
|
||||
|
||||
// number gives one send the next sequence for its node (novox/hq 04-ISSUES/107).
|
||||
// allotting is allot over one inventory, in the shape composeEach takes.
|
||||
func allotting(ctx context.Context, inv *inventory.Inventory) func(node string) (int64, error) {
|
||||
return func(node string) (int64, error) { return allot(ctx, inv, node) }
|
||||
func allotting(ctx context.Context, inv *inventory.Inventory) func(node string) (order, error) {
|
||||
return func(node string) (order, error) { return allot(ctx, inv, node) }
|
||||
}
|
||||
|
||||
// allot takes the next sequence for a machine — the number its next declaration carries.
|
||||
func allot(ctx context.Context, inv *inventory.Inventory, node string) (int64, error) {
|
||||
// epochForActs is the lease's gate as a composition asks it; a variable so a test can act under an epoch
|
||||
// without a bus.
|
||||
var epochForActs = func(ctx context.Context) (uint64, error) { return theLease.epoch(ctx) }
|
||||
|
||||
// order is what a declaration carries of its writer's order (link/order.go): its sequence, and the
|
||||
// epoch of the lease it is composed under — zero for a machine that has not said it reads one.
|
||||
type order struct {
|
||||
sequence int64
|
||||
epoch uint64
|
||||
}
|
||||
|
||||
// allot takes the next sequence for a machine — the number its next declaration carries — under the
|
||||
// lease: a process that may not act takes none, and composes nothing (novox/hq to-be 45 §6).
|
||||
func allot(ctx context.Context, inv *inventory.Inventory, node string) (order, error) {
|
||||
epoch, err := epochForActs(ctx)
|
||||
if err != nil {
|
||||
return order{}, fmt.Errorf("nothing was composed for %s: %w", node, err)
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return order{}, err
|
||||
}
|
||||
return inv.NextSequence(ctx, record.ID)
|
||||
if epoch > 0 {
|
||||
reads, err := inv.ReadsEpoch(ctx, record.ID)
|
||||
if err != nil {
|
||||
return order{}, err
|
||||
}
|
||||
if !reads {
|
||||
epoch = 0
|
||||
}
|
||||
}
|
||||
seq, err := inv.NextSequence(ctx, record.ID)
|
||||
if err != nil {
|
||||
return order{}, err
|
||||
}
|
||||
return order{sequence: seq, epoch: epoch}, nil
|
||||
}
|
||||
|
||||
// recordSent writes down what a machine was just sent, and returns the digest.
|
||||
@@ -1270,7 +1347,7 @@ func allot(ctx context.Context, inv *inventory.Inventory, node string) (int64, e
|
||||
// And the build of each module it carried (novox/hq issue 259, ADR 0221), nil when that is not known:
|
||||
// what tells a machine held back by a policy or a plan from one a push left behind.
|
||||
func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body []byte,
|
||||
builds map[string]string) (string, error) {
|
||||
builds map[string]string, epoch uint64) (string, error) {
|
||||
kept, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
record, err := inv.NodeByName(kept, node)
|
||||
@@ -1278,7 +1355,7 @@ func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body
|
||||
return "", err
|
||||
}
|
||||
digest := digestOf(body)
|
||||
if err := inv.RecordSent(kept, record.ID, digest, builds); err != nil {
|
||||
if err := inv.RecordSentUnder(kept, record.ID, digest, builds, epoch); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return digest, nil
|
||||
@@ -1304,11 +1381,3 @@ func reportUnheldPushed(w io.Writer, named bool, asked []string, unheld map[stri
|
||||
fmt.Fprintf(w, "%s: %d unmet seat dependenc(ies) — see `status`\n", node, len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
// controllerProcess names this serving process among controllers: the machine, the process and when
|
||||
// it started — what a call kept on the bus carries, so the next controller can tell a call this one
|
||||
// left running from one it is running itself (novox/hq to-be 45 §6).
|
||||
func controllerProcess() string {
|
||||
host, _ := os.Hostname()
|
||||
return fmt.Sprintf("controller@%s pid %d since %s", host, os.Getpid(), time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestASkippedMachineIsStillAnError(t *testing.T) {
|
||||
}
|
||||
|
||||
// numbered is an allotter for tests: one higher per call, as the inventory's is per machine.
|
||||
func numbered() func(string) (int64, error) {
|
||||
func numbered() func(string) (order, error) {
|
||||
var n int64
|
||||
return func(string) (int64, error) { n++; return n, nil }
|
||||
return func(string) (order, error) { n++; return order{sequence: n}, nil }
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestAFailedPlanIsRetriedAndGoesOnThroughItsLaterTiers(t *testing.T) {
|
||||
Note: "a failed to build in tier 0",
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1",
|
||||
Why: link.KilledByHand}}}
|
||||
if err := open.inventory.SavePlan(ctx, failed); err != nil {
|
||||
if err := open.inventory.SavePlan(ctx, &failed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestARebuildJoinsThePlanHoldingTheModule(t *testing.T) {
|
||||
Created: before, State: inventory.PlanFailed, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Note: "a failed to build in tier 0",
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1", Why: link.CancelledByHand}}}
|
||||
if err := open.inventory.SavePlan(ctx, failed); err != nil {
|
||||
if err := open.inventory.SavePlan(ctx, &failed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rebuildCommand(ctx, []string{"a"}); err != nil {
|
||||
@@ -433,7 +433,7 @@ func TestCancelDeletesTheAskAndFailsThePlanThatAskedIt(t *testing.T) {
|
||||
plan := inventory.Plan{ID: "plan-cancel", Repository: "novox/a", Commit: "c0ffee", Created: asked,
|
||||
State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked, Build: id}}}
|
||||
if err := open.inventory.SavePlan(ctx, plan); err != nil {
|
||||
if err := open.inventory.SavePlan(ctx, &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -631,21 +631,21 @@ func TestAPlanStoppedAtItsFirstMachineIsRetried(t *testing.T) {
|
||||
Note: "a stopped at its first machine in tier 0: laptop refused what it was sent",
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "built", BuiltAt: &long, Commit: "c0ffee",
|
||||
First: []string{"laptop"}, FirstAt: &long, Why: "laptop refused what it was sent"}}}
|
||||
if err := open.inventory.SavePlan(ctx, stopped); err != nil {
|
||||
if err := open.inventory.SavePlan(ctx, &stopped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A newer plan holding a refuses it: sending the older build would put it back.
|
||||
newer := inventory.Plan{ID: "plan-newer", Repository: "novox/other", Commit: "d00d", Created: long.Add(time.Hour),
|
||||
State: inventory.PlanDone, Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{}}
|
||||
if err := open.inventory.SavePlan(ctx, newer); err != nil {
|
||||
if err := open.inventory.SavePlan(ctx, &newer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := retryPlan(ctx, open, stopped.ID); err == nil || !strings.Contains(err.Error(), "plan-newer") {
|
||||
t.Fatalf("retried under a newer plan: %v", err)
|
||||
}
|
||||
newer.State = inventory.PlanSuperseded
|
||||
if err := open.inventory.SavePlan(ctx, newer); err != nil {
|
||||
if err := open.inventory.SavePlan(ctx, &newer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -683,7 +683,7 @@ func TestAPlanIsAnsweredOnlyByTheBuildItAskedFor(t *testing.T) {
|
||||
plan := inventory.Plan{ID: "plan-own", Repository: "novox/a", Commit: "c0ffee", Created: asked,
|
||||
State: inventory.PlanBuilding, Tiers: [][]string{{"a"}},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked, Build: "build-own"}}}
|
||||
if err := open.inventory.SavePlan(ctx, plan); err != nil {
|
||||
if err := open.inventory.SavePlan(ctx, &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planBuilt(ctx, open, "a", "0ldc0mm1t", "", time.Now().UTC(), "build-replay")
|
||||
|
||||
@@ -441,7 +441,7 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string,
|
||||
state.BuiltAt = &now
|
||||
state.Commit = commit
|
||||
}
|
||||
if err := inv.SavePlan(ctx, *p); err != nil {
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
||||
continue
|
||||
}
|
||||
@@ -500,7 +500,7 @@ func advanceHeld(ctx context.Context, open *stores) {
|
||||
// Kept in the plan, so `plans` says why it has not moved rather than the log alone;
|
||||
// the state is left as it was and the step is tried again on the next tick.
|
||||
p.Note = "tier " + fmt.Sprint(p.Tier) + ": " + err.Error() + " — tried again"
|
||||
if err := inv.SavePlan(ctx, *p); err != nil {
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
||||
}
|
||||
break
|
||||
@@ -508,7 +508,7 @@ func advanceHeld(ctx context.Context, open *stores) {
|
||||
if p.State == inventory.PlanFailed {
|
||||
sayUnsent(p, rollsOut)
|
||||
}
|
||||
if err := inv.SavePlan(ctx, *p); err != nil {
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
||||
break
|
||||
}
|
||||
@@ -1076,7 +1076,7 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
u, err := inv.UpgradeOf(ctx, m)
|
||||
return err == nil && u.RollOut
|
||||
})
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s %s at tier %d of %d; what was asked still builds and registers, nothing further is asked\n",
|
||||
|
||||
@@ -22,6 +22,11 @@ type sendable struct {
|
||||
// under the node's hold just before the body is made (novox/hq 04-ISSUES/107). Zero is not sent
|
||||
// at all, which a host reads as "no order claimed" — the shape of every declaration before this.
|
||||
Sequence int64
|
||||
// Epoch is the controller lease's epoch it was composed under (novox/hq to-be 45 §6): a machine that
|
||||
// heard a later epoch refuses it. Zero is not sent at all — every machine whose node-engine has not
|
||||
// said it reads one is sent none, because an older node-engine refuses a key it does not know, whole
|
||||
// (link/order.go, the contract).
|
||||
Epoch uint64
|
||||
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
||||
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
||||
Adoption *adoptionEnvelope
|
||||
@@ -70,6 +75,9 @@ func (s sendable) Body() ([]byte, error) {
|
||||
if s.Sequence > 0 {
|
||||
envelope["sequence"] = s.Sequence
|
||||
}
|
||||
if s.Epoch > 0 {
|
||||
envelope["epoch"] = s.Epoch
|
||||
}
|
||||
if len(s.LeftOut) > 0 {
|
||||
envelope["left_out"] = s.LeftOut
|
||||
}
|
||||
|
||||
+113
-13
@@ -5,7 +5,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -70,6 +72,8 @@ const (
|
||||
// staleRefusalsAllowed in staleRefusalsWithin are what S13 lets pass from one writer.
|
||||
staleRefusalsAllowed = 5
|
||||
staleRefusalsWithin = 5 * time.Minute
|
||||
// leaseBound is how long the lease may go unrenewed (S12): the key's age.
|
||||
leaseBound = broker.LeaseTTL
|
||||
)
|
||||
|
||||
// callBounds are the verbs that may run longer than callDefault, and how long (S7).
|
||||
@@ -164,13 +168,20 @@ var signalsTable = []signalRow{
|
||||
return newestOf(f.machines, func(m machineFacts) time.Time { return m.toolsHeard })
|
||||
}},
|
||||
{Row: "S12", Signal: "the controller lease renewed", Emitter: "controller", Trigger: "every 5 s",
|
||||
Bound: "15 s", Kind: "lease-lost", Severity: conditions.Urgent, Phase: 2,
|
||||
Deferred: "the lease is built in Phase 2 (to-be 45 §6): there is nothing renewed to watch yet, and a " +
|
||||
"second controller is caught today by its consumers being bound (standingBy)"},
|
||||
Bound: "15 s (the key's age); a holder that lost the lease, or stopped renewing and was taken over, and a " +
|
||||
"lease bucket found raised again from nothing, are said for an hour after; a controller serving without " +
|
||||
"the lease, for as long as it does",
|
||||
Kind: "lease-lost", Severity: conditions.Urgent, Phase: 2,
|
||||
needs: func(f *signalFacts) error { return f.leaseErr }, watch: watchLease,
|
||||
newest: func(f *signalFacts) time.Time { return f.lease.renewed }},
|
||||
{Row: "S13", Signal: "stale refusals", Emitter: "every receiver (rule 2)", Trigger: "each refusal",
|
||||
Bound: "more than 5 from one machine in 5 min", Kind: "stale-writer", Severity: conditions.Warning, Phase: 1,
|
||||
Bound: "more than 5 from one writer in 5 min: a controller epoch, a controller that claimed none, or a " +
|
||||
"machine's node-engine whose accounts the controller refused",
|
||||
Kind: "stale-writer", Severity: conditions.Warning, Phase: 2,
|
||||
needs: func(*signalFacts) error { return nil }, watch: watchStaleRefusals,
|
||||
newest: func(f *signalFacts) time.Time { return time.Time{} }},
|
||||
newest: func(f *signalFacts) time.Time {
|
||||
return newestOf(f.staleRefusals, func(w link.WriterRefusals) time.Time { return w.Last })
|
||||
}},
|
||||
{Row: "S14", Signal: "facts snapshot exported", Emitter: "controller", Trigger: "daily",
|
||||
Bound: "2 days", Kind: "facts-stale", Severity: conditions.Warning, Phase: 5,
|
||||
Deferred: "the facts snapshot is built in Phase 5 (to-be 45 §9): nothing exports one yet"},
|
||||
@@ -450,16 +461,105 @@ func watchTools(f *signalFacts) []conditions.Observation {
|
||||
|
||||
func watchStaleRefusals(f *signalFacts) []conditions.Observation {
|
||||
var out []conditions.Observation
|
||||
for node, n := range f.staleRefusals {
|
||||
if n <= staleRefusalsAllowed {
|
||||
for _, w := range f.staleRefusals {
|
||||
if w.Count <= staleRefusalsAllowed {
|
||||
continue
|
||||
}
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: node, Kind: "stale-writer",
|
||||
Machine: node, Severity: conditions.Warning,
|
||||
Summary: fmt.Sprintf("%s refused %d declarations in %s as older than the one it holds: a controller "+
|
||||
"is sending what it has moved past (which one is said once declarations carry an epoch, Phase 2)",
|
||||
node, n, staleRefusalsWithin),
|
||||
Said: fmt.Sprintf("%d stale refusals in %s", n, staleRefusalsWithin)})
|
||||
scope, id, machine := conditions.ScopeCore, "controller.unnamed", ""
|
||||
named := w.Writer
|
||||
switch {
|
||||
case w.Epoch > 0:
|
||||
id = fmt.Sprintf("controller.epoch-%d", w.Epoch)
|
||||
if e, ok := f.epochs[w.Epoch]; ok {
|
||||
named = fmt.Sprintf("the controller of epoch %d (%s%s)", w.Epoch, e.Instance, endedWords(e))
|
||||
}
|
||||
case w.Writer == link.WriterNodeEngine(firstOf(w.Receivers)) || strings.HasPrefix(w.Writer, "the node-engine on "):
|
||||
node := strings.TrimPrefix(w.Writer, "the node-engine on ")
|
||||
scope, id, machine = conditions.ScopeMachine, node, node
|
||||
}
|
||||
if machine == "" && len(w.Receivers) > 0 {
|
||||
machine = w.Receivers[0]
|
||||
}
|
||||
var also []string
|
||||
for _, r := range w.Receivers {
|
||||
if r != machine && r != "controller" {
|
||||
also = append(also, r)
|
||||
}
|
||||
}
|
||||
out = append(out, conditions.Observation{Scope: scope, ID: id, Kind: "stale-writer", Token: "stale-writer",
|
||||
Machine: machine, Also: also, Severity: conditions.Warning,
|
||||
Summary: fmt.Sprintf("%s was refused %d time(s) in %s as older than what its receivers hold (%s): a "+
|
||||
"writer is sending what the mesh has moved past", named, w.Count, staleRefusalsWithin,
|
||||
strings.Join(w.Receivers, ", ")),
|
||||
Said: fmt.Sprintf("%d stale refusals in %s, the last at %s", w.Count, staleRefusalsWithin,
|
||||
w.Last.UTC().Format(time.RFC3339))})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstOf is a list's first, empty for none.
|
||||
func firstOf(list []string) string {
|
||||
if len(list) == 0 {
|
||||
return ""
|
||||
}
|
||||
return list[0]
|
||||
}
|
||||
|
||||
// endedWords is how an epoch ended, for a sentence naming it; nothing while it is held.
|
||||
func endedWords(e inventory.Epoch) string {
|
||||
if e.Ended == nil {
|
||||
return ", still holding the lease"
|
||||
}
|
||||
return fmt.Sprintf(", %s at %s", e.How, e.Ended.UTC().Format("15:04:05 MST"))
|
||||
}
|
||||
|
||||
// watchLease is S12: the lease held and renewed by this controller, and every holder that lost it.
|
||||
func watchLease(f *signalFacts) []conditions.Observation {
|
||||
var out []conditions.Observation
|
||||
l := f.lease
|
||||
if l.unleased != "" {
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeCore, ID: "controller.lease", Token: "unleased",
|
||||
Kind: "lease-lost", Machine: f.host, Severity: conditions.Urgent,
|
||||
Summary: "the controller serves WITHOUT the lease: nothing keeps a second controller from acting " +
|
||||
"beside it, and its declarations carry no epoch. A bus whose user list is older than this " +
|
||||
"controller does not grant it the lease's bucket: a push of the machine holding the bus sends " +
|
||||
"the list that does, and the controller takes the lease within five seconds — " + l.unleased,
|
||||
Said: l.unleased})
|
||||
} else if l.held && !l.renewed.IsZero() && f.now.Sub(l.renewed) > leaseBound {
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeCore, ID: "controller.lease", Token: "late",
|
||||
Kind: "lease-lost", Machine: f.host, Severity: conditions.Urgent,
|
||||
Summary: fmt.Sprintf("the controller has not renewed its lease (epoch %d) since %s, past the key's age "+
|
||||
"of %s: another controller may take it", l.epoch, l.renewed.UTC().Format(time.RFC3339), leaseBound),
|
||||
Said: fmt.Sprintf("not renewed for %s", ago(f.now.Sub(l.renewed)))})
|
||||
}
|
||||
if !l.reset.IsZero() && f.now.Sub(l.reset) <= advisoryQuiet {
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeCore, ID: "controller.lease", Token: "reset",
|
||||
Kind: "lease-lost", Machine: f.host, Severity: conditions.Urgent,
|
||||
Summary: "the controller lease's bucket was raised again from nothing: " + l.resetSaid,
|
||||
Said: l.resetSaid})
|
||||
}
|
||||
var lost []string
|
||||
newest := inventory.Epoch{}
|
||||
for _, e := range l.ended {
|
||||
if e.Ended == nil || e.How == inventory.EpochReleased || f.now.Sub(*e.Ended) > advisoryQuiet {
|
||||
continue
|
||||
}
|
||||
lost = append(lost, fmt.Sprintf("epoch %d (%s) %s at %s", e.Epoch, e.Instance, e.How,
|
||||
e.Ended.UTC().Format("15:04:05 MST")))
|
||||
if newest.Ended == nil || e.Ended.After(*newest.Ended) {
|
||||
newest = e
|
||||
}
|
||||
}
|
||||
if len(lost) > 0 {
|
||||
how := "lost it: its renewal was refused or could not be made"
|
||||
if newest.How == inventory.EpochExpired {
|
||||
how = "stopped renewing it without giving it back, and was taken over"
|
||||
}
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeCore, ID: "controller.lease", Token: "lost",
|
||||
Kind: "lease-lost", Machine: newest.Host, Severity: conditions.Urgent,
|
||||
Summary: fmt.Sprintf("the controller of epoch %d (%s) %s; the controller of epoch %d acts now", newest.Epoch,
|
||||
newest.Instance, how, l.epoch),
|
||||
Said: strings.Join(lost, "; ")})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -134,19 +135,28 @@ func seatBase(world catalogue.World, seatName string) (string, error) {
|
||||
// seatBases is the clone base of every seat a recipe's context may name, for a build request
|
||||
// (novox/hq ADR 0155). A seat nobody holds is left out rather than refused here: the build may not
|
||||
// name it at all, and if it does the builder refuses with the seat's name.
|
||||
//
|
||||
// **What cannot be read is said, not passed over as no seats** (novox/hq to-be 45 Phase 2, the
|
||||
// empty-on-error lint): the build still goes ahead — one that names no seat needs none — and one that
|
||||
// does is refused naming it, but the reason is the store, and that is said here where it is known.
|
||||
func seatBases(ctx context.Context) map[string]string {
|
||||
unread := func(what string, err error) map[string]string {
|
||||
fmt.Fprintf(os.Stderr, "could not read %s, so a build naming a seat's clone base will be told that "+
|
||||
"seat is not held: %v\n", what, err)
|
||||
return nil
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
return unread("the mesh's store", err)
|
||||
}
|
||||
defer open.Close()
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
return unread("the catalogue", err)
|
||||
}
|
||||
world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "")
|
||||
if err != nil {
|
||||
return nil
|
||||
return unread("who holds which seat", err)
|
||||
}
|
||||
bases := map[string]string{}
|
||||
for _, seatName := range []string{gitSeat} {
|
||||
|
||||
@@ -184,9 +184,16 @@ type nudgingListener struct {
|
||||
}
|
||||
|
||||
func (l nudgingListener) Heard(ctx context.Context, report link.Report) (bool, error) {
|
||||
// A declaration refused as older than the one the machine holds is counted (novox/hq to-be 45 S13).
|
||||
if link.IsStaleRefusal(report.Refused) {
|
||||
link.StaleRefusals.Refused(report.Node, time.Now())
|
||||
// A declaration refused as older than the one the machine holds is counted by its writer — the
|
||||
// controller epoch it claimed (novox/hq to-be 45 §6, S13) — and so is what the machine's own count
|
||||
// says it refused beyond the refusals heard.
|
||||
now := time.Now()
|
||||
if report.StaleRefusalOf() {
|
||||
link.StaleRefusals.Refused(link.Refusal{Writer: link.WriterEpoch(report.Epoch), Epoch: report.Epoch,
|
||||
Receiver: report.Node, At: now})
|
||||
}
|
||||
if report.Ordered() {
|
||||
link.StaleRefusals.Lifetime(report.Node, report.RefusedOlder, now)
|
||||
}
|
||||
news, err := l.Enrolment.Heard(ctx, report)
|
||||
if news {
|
||||
|
||||
@@ -94,6 +94,8 @@ func openInventory(ctx context.Context) (*inventory.Inventory, error) {
|
||||
inv.Close()
|
||||
return nil, err
|
||||
}
|
||||
// A plan is written only under the lease, carrying its epoch (novox/hq to-be 45 §6).
|
||||
inv.ActsUnder(theLease.epoch)
|
||||
// Load the seat set from the store, so the control plane reads the set as data rather than as
|
||||
// the slice it was compiled with (novox/hq ADR 0122). A store not yet seeded — or one whose
|
||||
// seat table a migration has not reached — returns nothing, and UseSeats leaves the compiled
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestAPersonClosesAStuckPlan(t *testing.T) {
|
||||
stuck := inventory.Plan{ID: "plan-97b1b2b", Repository: "novox/mesh-catalog", Commit: "97b1b2b",
|
||||
Created: time.Now().UTC(), State: inventory.PlanRolling, Tier: 1, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "built"}, "b": {}}}
|
||||
if err := open.inventory.SavePlan(ctx, stuck); err != nil {
|
||||
if err := open.inventory.SavePlan(ctx, &stuck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := plansCommand(ctx, []string{"close", stuck.ID}); err == nil || !strings.Contains(err.Error(), "--why") {
|
||||
|
||||
@@ -383,13 +383,13 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
||||
strings.Join(plan.Tiers[len(plan.Tiers)-1], ", "))
|
||||
}
|
||||
if err := inv.SavePlan(ctx, plan); err != nil {
|
||||
if err := inv.SavePlan(ctx, &plan); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
// Closed after the newer plan is kept, never before: a controller replaced between the two leaves
|
||||
// both open, which the next merge settles, rather than neither.
|
||||
for _, old := range superseded {
|
||||
if err := inv.SavePlan(ctx, old); err != nil {
|
||||
if err := inv.SavePlan(ctx, &old); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
fmt.Printf(" %s (%s at %s) is %s\n", old.ID, old.Repository, short(old.Commit), old.Note)
|
||||
@@ -411,7 +411,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
if err := askTier(ctx, inv, &plan); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
if err := inv.SavePlan(ctx, plan); err != nil {
|
||||
if err := inv.SavePlan(ctx, &plan); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -73,7 +73,25 @@ type signalFacts struct {
|
||||
|
||||
selfCheck selfCheckFacts
|
||||
|
||||
staleRefusals map[string]int
|
||||
// staleRefusals are the writers refused as older lately, and epochs the mesh's record of each epoch
|
||||
// they name (novox/hq to-be 45 §6, S13).
|
||||
staleRefusals []link.WriterRefusals
|
||||
epochs map[int64]inventory.Epoch
|
||||
|
||||
// lease is this controller's standing to the lease, and the epochs that ended lately (S12).
|
||||
lease leaseFacts
|
||||
leaseErr error
|
||||
}
|
||||
|
||||
type leaseFacts struct {
|
||||
held bool
|
||||
epoch uint64
|
||||
renewed time.Time
|
||||
unleased string
|
||||
ended []inventory.Epoch
|
||||
// reset is when the lease bucket was found raised again from nothing; resetSaid what of it.
|
||||
reset time.Time
|
||||
resetSaid string
|
||||
}
|
||||
|
||||
type machineFacts struct {
|
||||
@@ -246,12 +264,23 @@ func blindRow(row signalRow, err error) conditions.Observation {
|
||||
func (w *watchdogs) gather(ctx context.Context) *signalFacts {
|
||||
now := time.Now()
|
||||
f := &signalFacts{now: now, started: w.started, toolsHeardFrom: link.ToolsBeats.Started(), calls: link.Calls.Running(),
|
||||
staleRefusals: link.StaleRefusals.Within(now.Add(-staleRefusalsWithin)), lostConsumers: map[string]bool{}}
|
||||
staleRefusals: link.StaleRefusals.Within(now.Add(-staleRefusalsWithin)), lostConsumers: map[string]bool{},
|
||||
epochs: map[int64]inventory.Epoch{}}
|
||||
if w.doctor != nil {
|
||||
f.selfCheck = selfCheckFacts{last: w.doctor.lastRunEnded(), every: doctorEvery}
|
||||
}
|
||||
inv := w.open.inventory
|
||||
f.host = controlHost(ctx, inv)
|
||||
f.lease, f.leaseErr = gatherLease(ctx, inv, now)
|
||||
for _, r := range f.staleRefusals {
|
||||
if r.Epoch <= 0 {
|
||||
continue
|
||||
}
|
||||
// Named where the record has it; a writer the record cannot name is still said by its epoch.
|
||||
if e, found, err := inv.EpochOf(ctx, uint64(r.Epoch)); err == nil && found {
|
||||
f.epochs[r.Epoch] = e
|
||||
}
|
||||
}
|
||||
f.machines, f.machinesErr = w.gatherMachines(ctx, inv, now)
|
||||
f.plans, f.plansErr = gatherPlans(ctx, inv, now)
|
||||
f.loop, f.loopErr = w.gatherLoop()
|
||||
@@ -270,6 +299,19 @@ func (w *watchdogs) gather(ctx context.Context) *signalFacts {
|
||||
return f
|
||||
}
|
||||
|
||||
// gatherLease is this controller's standing to the lease and the epochs that ended within the hour.
|
||||
func gatherLease(ctx context.Context, inv *inventory.Inventory, now time.Time) (leaseFacts, error) {
|
||||
st := theLease.standing()
|
||||
f := leaseFacts{held: st.Held, epoch: st.Epoch, renewed: st.Renewed, unleased: st.Unleased, reset: st.Reset,
|
||||
resetSaid: st.ResetSaid}
|
||||
ended, err := inv.EpochsSince(ctx, now.Add(-advisoryQuiet))
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("the epochs the mesh issued cannot be read: %w", err)
|
||||
}
|
||||
f.ended = ended
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// controlHost is the machine running the controller, as the mesh names it: the one the controller
|
||||
// module is assigned to, or this process's host name where that is not one machine.
|
||||
func controlHost(ctx context.Context, inv *inventory.Inventory) string {
|
||||
|
||||
@@ -28,4 +28,4 @@ require (
|
||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||
// `go mod vendor` fails loudly, at once, everywhere.
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261006081854-6953b5bafdb2
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261006081854-6953b5bafdb2 h1:b4+F4tTfrPkuJTST+rkwIHpEb4mkVJR9158hr6nnc4g=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261006081854-6953b5bafdb2/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e h1:g9h4QRaAMg5yaJLwqtb0FoOs23DVGUYpW6qvnQ3oY5A=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -31,8 +31,15 @@ var (
|
||||
HandActsBucket = BucketName(ControllerSeat, "hand-acts")
|
||||
ConditionsBucket = BucketName(ControllerSeat, "conditions")
|
||||
ConditionHistoryBucket = BucketName(ControllerSeat, "condition-history")
|
||||
// LeaseBucket holds the controller's lease (to-be 45 §6): one key, `holder`, which the instance
|
||||
// allowed to act writes by compare-and-set and renews; its revision when taken is the epoch.
|
||||
LeaseBucket = BucketName(ControllerSeat, "lease")
|
||||
)
|
||||
|
||||
// LeaseTTL is how long the lease's key lives unrenewed (to-be 45 §6): fifteen seconds, renewed
|
||||
// every five. The bucket's age, so the bus forgets a holder that stopped renewing.
|
||||
const LeaseTTL = 15 * time.Second
|
||||
|
||||
// The bounds to-be 45 §6 sets for calls: the last thousand, or fourteen days, whichever is fewer.
|
||||
// A call is two keys — its record, and its answer apart so a listing does not read every answer —
|
||||
// so the stream holds twice as many messages as it keeps calls.
|
||||
@@ -52,12 +59,12 @@ const (
|
||||
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
||||
func IsControllerBucket(bucket string) bool {
|
||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
||||
bucket == ConditionHistoryBucket
|
||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket
|
||||
}
|
||||
|
||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||
func ControllerBuckets() []string {
|
||||
return []string{CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket}
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket}
|
||||
}
|
||||
|
||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||
@@ -74,6 +81,9 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := EnsureLeaseBucket(ctx, js); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: CallsBucket,
|
||||
Description: "the calls of the mesh's own verbs and what came of each (novox/hq to-be 45 §6, issue " +
|
||||
@@ -141,3 +151,24 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureLeaseBucket creates the lease's bucket if absent and brings its options to match (to-be 45
|
||||
// §6). **Before the lease is taken, by any candidate**: it is the lease's own precondition, and asserting
|
||||
// a bucket that exists changes nothing. File storage, so its revisions — the epochs — outlive a restart of
|
||||
// the bus; one raised again from nothing is moved past the highest epoch issued when the lease is taken.
|
||||
func EnsureLeaseBucket(ctx context.Context, js jetstream.JetStream) error {
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: LeaseBucket,
|
||||
Description: "the controller's lease (novox/hq to-be 45 §6): the key `holder`, written by compare-and-set " +
|
||||
"by the one controller instance that may act and renewed every five seconds; its revision when taken " +
|
||||
"is the epoch every declaration and plan write carries",
|
||||
History: 1,
|
||||
TTL: LeaseTTL,
|
||||
MaxValueSize: 4 << 10,
|
||||
MaxBytes: 1 << 20,
|
||||
Storage: jetstream.FileStorage,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", LeaseBucket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+11
-1
@@ -229,7 +229,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// stream definitions (design 25 §3), so it alone reaches the JetStream API — and it alone
|
||||
// issues memberships (novox/hq ADR 0160), which it publishes into the assignments stream
|
||||
// after each push; refused by the server on 2026-10-01 until this line named them.
|
||||
pub = []string{"mesh.control.>", "mesh.node.>", "mesh.assignment.>", "$JS.API.>"}
|
||||
//
|
||||
// **Not what the machines say** (novox/hq to-be 45 §1): `mesh.control.>` is subscribed, never
|
||||
// published — a machine's report has one writer, its node-engine, and the controller granted
|
||||
// that subject would be a second (the writers table refuses it). It was granted from the first
|
||||
// composition and nothing ever published under it.
|
||||
pub = []string{"mesh.node.>", "mesh.assignment.>", "$JS.API.>"}
|
||||
// **And where its consumers deliver.** A push consumer delivers on `_DELIVER.<its name>`,
|
||||
// and a client bound to it subscribes exactly that; the server refused it for every
|
||||
// principal the first time one bound a consumer (2026-09-28). Each kind below is granted
|
||||
@@ -604,6 +609,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
|
||||
sort.Strings(pub)
|
||||
sort.Strings(sub)
|
||||
// One writer per piece of state (novox/hq to-be 45 §1): a grant that would make a second is
|
||||
// refused here, at composition, naming the state and its writer.
|
||||
if err := CheckWriters(p, pub); err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
return Permissions{
|
||||
Publish: pub,
|
||||
Subscribe: sub,
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ accounts {
|
||||
jetstream: enabled
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*"] }
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The writers table (novox/hq to-be 45 §1, ADR 0227 rule 1), compiled in.
|
||||
//
|
||||
// **Every kind of state the core keeps has one writer; anyone else asks it.** The table is the design's,
|
||||
// row for row, with what each row writes on the bus where it writes there. Two things read it: the
|
||||
// composition of every principal's grants (PermissionsFor), which **refuses a grant that lets a
|
||||
// principal publish on a subject another writes** — so a second writer cannot be granted by accident,
|
||||
// and the composition says which state and whose — and the test beside it, which walks the rows
|
||||
// against the design's list and the composition of a whole mesh. Changing a writer is a change to
|
||||
// this table, through a decision.
|
||||
|
||||
// WriterRow is one row of the writers table.
|
||||
type WriterRow struct {
|
||||
State string
|
||||
Writer string
|
||||
KeptIn string
|
||||
Others string
|
||||
// Subjects are the bus subjects a write of this state is a publish to; none for state kept off the
|
||||
// bus (the controller's store, a module's own) or not built yet.
|
||||
Subjects []string
|
||||
// Writes says a principal granted a publish pattern overlapping Subjects is this state's writer —
|
||||
// given the pattern, because a machine writes its own report and no other's.
|
||||
Writes func(p Principal, pattern string) bool
|
||||
// Shared says why more than one principal may publish here; empty for one writer.
|
||||
Shared string
|
||||
}
|
||||
|
||||
// isController, and the other writers' tests, are who a row's writer is as a principal.
|
||||
func isController(p Principal, _ string) bool { return p.Kind == KindController }
|
||||
|
||||
// ownMachine is a node writing a subject whose third token is its own name, and no wildcard there.
|
||||
func ownMachine(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
return p.Kind == KindNode && len(tokens) > 2 && tokens[2] == p.Node
|
||||
}
|
||||
|
||||
// holdsSeatOf is a principal holding the seat a `mesh.seat.<seat>.…` pattern names: its module's own
|
||||
// principal, or the node tools carrying a module that holds it (ADR 0175, the runtime is its modules).
|
||||
func holdsSeatOf(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
if len(tokens) < 3 {
|
||||
return false
|
||||
}
|
||||
seat := tokens[2]
|
||||
holds := func(seats []Seat) bool {
|
||||
return slices.ContainsFunc(seats, func(s Seat) bool { return s.Name == seat })
|
||||
}
|
||||
switch p.Kind {
|
||||
case KindModule:
|
||||
return holds(p.Holds)
|
||||
case KindNodeTools:
|
||||
return slices.ContainsFunc(p.Carries, func(d Declared) bool { return holds(d.Holds) })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ownModule is a module publishing under its own name, or the node tools carrying it.
|
||||
func ownModule(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
if len(tokens) < 3 {
|
||||
return false
|
||||
}
|
||||
module := tokens[2]
|
||||
switch p.Kind {
|
||||
case KindModule:
|
||||
return p.Module == module
|
||||
case KindNodeTools:
|
||||
return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// kvOf is a bucket's write subjects.
|
||||
func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} }
|
||||
|
||||
// WritersTable is to-be 45 §1, in its order.
|
||||
var WritersTable = []WriterRow{
|
||||
{State: "a machine's declaration", Writer: "controller (lease holder)", KeptIn: "the bus, last per subject",
|
||||
Others: "read", Subjects: []string{"mesh.node.*.declare"}, Writes: isController},
|
||||
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
|
||||
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
|
||||
Subjects: []string{"mesh.control.*.report"}, Writes: ownMachine},
|
||||
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
|
||||
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
|
||||
{State: "plans and their tiers", Writer: "controller (lease holder), compare-and-set on the plan's revision",
|
||||
KeptIn: "the controller's store", Others: "read through plans"},
|
||||
{State: "conditions", Writer: "controller", KeptIn: "key-value " + ConditionsBucket + " (and its history, " +
|
||||
ConditionHistoryBucket + ")", Others: "raise or clear only through observations the controller reads",
|
||||
Subjects: append(kvOf(ConditionsBucket), kvOf(ConditionHistoryBucket)...), Writes: isController},
|
||||
{State: "calls and their outcomes", Writer: "controller", KeptIn: "key-value " + CallsBucket,
|
||||
Others: "read by id", Subjects: kvOf(CallsBucket), Writes: isController},
|
||||
{State: "the hand-act log", Writer: "controller, through the verbs that act", KeptIn: "key-value " + HandActsBucket,
|
||||
Others: "—", Subjects: kvOf(HandActsBucket), Writes: isController},
|
||||
{State: "stream definitions and bus permissions", Writer: "controller", KeptIn: "the bus", Others: "—",
|
||||
// A stream's definition, and a durable consumer's by the API that names it so. Not every
|
||||
// consumer create: a module watching its own bucket makes and deletes an ordered consumer on the
|
||||
// bucket's stream (ADR 0201), which defines nothing the mesh keeps.
|
||||
Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>",
|
||||
"$JS.API.CONSUMER.DURABLE.CREATE.>"},
|
||||
Writes: isController},
|
||||
{State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state",
|
||||
Others: "the controller asks",
|
||||
Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},
|
||||
Writes: holdsSeatOf,
|
||||
Shared: "every machine holding the build seat answers the asks it took; each outcome names its ask"},
|
||||
{State: "a merge announced", Writer: "one announcer per forge (the hook, or the poll when the hook is absent — never both)",
|
||||
KeptIn: "the bus", Others: "—", Subjects: []string{"mesh.mod.*.event.pull.merged"}, Writes: ownModule},
|
||||
{State: "a provider's standing", Writer: "the provider", KeptIn: "the provider's events",
|
||||
Others: "the controller keeps the newest word as a condition",
|
||||
Subjects: []string{"mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered"},
|
||||
Writes: ownModule},
|
||||
{State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state",
|
||||
Others: "—"},
|
||||
{State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest",
|
||||
Others: "the build seat reads"},
|
||||
}
|
||||
|
||||
// CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives
|
||||
// another writer (to-be 45 §1): which state, whose, and the pattern that would make a second writer.
|
||||
func CheckWriters(p Principal, publish []string) error {
|
||||
var problems []string
|
||||
for _, pattern := range publish {
|
||||
for _, row := range WritersTable {
|
||||
if row.Writes == nil {
|
||||
continue
|
||||
}
|
||||
for _, subject := range row.Subjects {
|
||||
if !SubjectsOverlap(pattern, subject) || row.Writes(p, pattern) {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s may publish %s, which writes %s (%s), whose writer is %s",
|
||||
p.Username(), pattern, row.State, subject, row.Writer))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("a second writer would be granted (novox/hq to-be 45 §1, one writer per piece of state):\n %s",
|
||||
strings.Join(problems, "\n "))
|
||||
}
|
||||
|
||||
// SubjectsOverlap says some subject matches both patterns: `*` is one token, `>` one or more to the end.
|
||||
func SubjectsOverlap(a, b string) bool {
|
||||
x, y := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; ; i++ {
|
||||
switch {
|
||||
case i == len(x) && i == len(y):
|
||||
return true
|
||||
case i == len(x) || i == len(y):
|
||||
return false
|
||||
case x[i] == ">" || y[i] == ">":
|
||||
return true
|
||||
case x[i] == "*" || y[i] == "*" || x[i] == y[i]:
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The writers table, checked (novox/hq to-be 45 §1, ADR 0227 rule 1, "how it is checked"): it is the
|
||||
// design's table row for row; every row that writes on the bus names its writer; a whole mesh composes
|
||||
// with one writer per piece of state; and a grant that would make a second writer is refused, naming
|
||||
// the state and whose it is.
|
||||
|
||||
// designRows are the states of to-be 45 §1, in its order. A row added to the design is added here and
|
||||
// to the table; one dropped from either fails.
|
||||
var designRows = []string{
|
||||
"a machine's declaration",
|
||||
"a machine's applied state and its report",
|
||||
"the controller lease",
|
||||
"plans and their tiers",
|
||||
"conditions",
|
||||
"calls and their outcomes",
|
||||
"the hand-act log",
|
||||
"stream definitions and bus permissions",
|
||||
"builds and their outcomes",
|
||||
"a merge announced",
|
||||
"a provider's standing",
|
||||
"the operator-channel's open messages",
|
||||
"the facts snapshot",
|
||||
}
|
||||
|
||||
func TestTheWritersTableIsTheDesigns(t *testing.T) {
|
||||
var states []string
|
||||
for _, row := range WritersTable {
|
||||
states = append(states, row.State)
|
||||
if row.Writer == "" || row.KeptIn == "" || row.Others == "" {
|
||||
t.Errorf("%q does not say who writes it, where it is kept and what others do", row.State)
|
||||
}
|
||||
if len(row.Subjects) > 0 && row.Writes == nil {
|
||||
t.Errorf("%q is written on the bus and names no writer among the principals", row.State)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(states, designRows) {
|
||||
t.Fatalf("the writers table is not to-be 45 §1's:\n have %q\n want %q", states, designRows)
|
||||
}
|
||||
}
|
||||
|
||||
// A mesh of every kind of principal composes: nobody is granted a second writer's subject.
|
||||
func TestAWholeMeshComposesWithOneWriterPerState(t *testing.T) {
|
||||
builder := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Emits: []string{"built", "started"}}
|
||||
principals := []Principal{
|
||||
{Kind: KindController, PasswordHash: "x"},
|
||||
{Kind: KindNode, Node: "one", PasswordHash: "x"},
|
||||
{Kind: KindEnrolment, Node: "two", PasswordHash: "x"},
|
||||
{Kind: KindPerson, Module: "jochen", Invokes: []string{"*"}, PasswordHash: "x"},
|
||||
{Kind: KindModule, Node: "one", Module: "gitea", Emits: []string{"pull.merged"}, PasswordHash: "x"},
|
||||
{Kind: KindModule, Node: "one", Module: "postgres", Emits: []string{"provisioner.failing", "provisioner.recovered"},
|
||||
PasswordHash: "x"},
|
||||
{Kind: KindModule, Node: "one", Module: "build-agent", Holds: []Seat{builder}, PasswordHash: "x"},
|
||||
{Kind: KindNodeTools, Node: "one", Module: RuntimeModule, PasswordHash: "x", Carries: []Declared{
|
||||
{Module: "gitea", Emits: []string{"pull.merged"}},
|
||||
{Module: "build-agent", Holds: []Seat{builder}}}},
|
||||
}
|
||||
for _, p := range principals {
|
||||
if _, err := PermissionsFor(p); err != nil {
|
||||
t.Errorf("%s does not compose: %v", p.Username(), err)
|
||||
}
|
||||
}
|
||||
if _, err := ComposeAccounts(principals); err != nil {
|
||||
t.Fatalf("the mesh does not compose: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecondWriterIsRefusedAtComposition(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
p Principal
|
||||
publish []string
|
||||
state string
|
||||
}{
|
||||
{"the controller publishing what machines say", Principal{Kind: KindController},
|
||||
[]string{"mesh.control.>"}, "a machine's applied state and its report"},
|
||||
{"a machine publishing another's report", Principal{Kind: KindNode, Node: "one"},
|
||||
[]string{"mesh.control.two.report"}, "a machine's applied state and its report"},
|
||||
{"a machine publishing every machine's", Principal{Kind: KindNode, Node: "one"},
|
||||
[]string{"mesh.control.*.>"}, "a machine's applied state and its report"},
|
||||
{"a module writing the lease", Principal{Kind: KindModule, Module: "shop"},
|
||||
[]string{"$KV.mesh-controller_lease.>"}, "the controller lease"},
|
||||
{"a module sending a declaration", Principal{Kind: KindModule, Module: "shop"},
|
||||
[]string{"mesh.node.one.declare"}, "a machine's declaration"},
|
||||
{"a module defining a stream", Principal{Kind: KindModule, Module: "shop"},
|
||||
[]string{"$JS.API.>"}, "stream definitions and bus permissions"},
|
||||
{"a module announcing another forge's merge", Principal{Kind: KindModule, Module: "shop"},
|
||||
[]string{"mesh.mod.gitea.event.pull.merged"}, "a merge announced"},
|
||||
{"a module saying a build it did not do", Principal{Kind: KindModule, Module: "shop"},
|
||||
[]string{"mesh.seat.node-build-agent.event.built"}, "builds and their outcomes"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := CheckWriters(c.p, c.publish)
|
||||
if err == nil {
|
||||
t.Fatalf("%v granted to %s was not refused", c.publish, c.p.Username())
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.state) {
|
||||
t.Fatalf("the refusal does not name %q: %v", c.state, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// And the writers themselves are not refused.
|
||||
for _, ok := range []struct {
|
||||
p Principal
|
||||
publish []string
|
||||
}{
|
||||
{Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.one.>"}},
|
||||
{Principal{Kind: KindController}, []string{"mesh.node.>", "$JS.API.>", "$KV.mesh-controller_lease.>"}},
|
||||
{Principal{Kind: KindModule, Module: "gitea"}, []string{"mesh.mod.gitea.event.pull.merged"}},
|
||||
{Principal{Kind: KindModule, Module: "shop"}, []string{"$JS.API.CONSUMER.CREATE.KV_shop_carts.>"}},
|
||||
} {
|
||||
if err := CheckWriters(ok.p, ok.publish); err != nil {
|
||||
t.Errorf("a writer was refused its own: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubjectsOverlap(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"mesh.control.>", "mesh.control.*.report", true},
|
||||
{"mesh.control.one.>", "mesh.control.*.report", true},
|
||||
{"mesh.control.one.alive", "mesh.control.*.report", false},
|
||||
{"mesh.control.*", "mesh.control.*.report", false},
|
||||
{"$JS.API.>", "$JS.API.STREAM.CREATE.>", true},
|
||||
{"$JS.API.CONSUMER.CREATE.KV_x.>", "$JS.API.STREAM.CREATE.>", false},
|
||||
{"a.b", "a.b", true},
|
||||
{"a.b", "a.b.c", false},
|
||||
{"a.>", "a", false},
|
||||
} {
|
||||
if got := SubjectsOverlap(c.a, c.b); got != c.want || SubjectsOverlap(c.b, c.a) != c.want {
|
||||
t.Errorf("%s ~ %s: %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,9 +129,8 @@ type Condition struct {
|
||||
Resolver string `json:"resolver"`
|
||||
// Silenced is null when no silence is in force: said, not left out, so a reader need not guess.
|
||||
Silenced *Silence `json:"silenced"`
|
||||
// Epoch is the controller lease epoch that last wrote it. Zero until the lease exists (to-be 45
|
||||
// Phase 2): no controller holds an epoch yet, and a number invented here would be one nobody
|
||||
// could compare.
|
||||
// Epoch is the controller lease epoch that last wrote it (to-be 45 §6). Zero where it was written
|
||||
// by a controller serving without the lease, or before the lease existed.
|
||||
Epoch uint64 `json:"epoch"`
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ type Keeper struct {
|
||||
now func() time.Time
|
||||
say func(format string, args ...any)
|
||||
changed func()
|
||||
epoch func() (uint64, error)
|
||||
|
||||
mu sync.Mutex
|
||||
// cleared is when each recently cleared condition cleared and how often it had been raised, so
|
||||
@@ -88,6 +89,9 @@ type Options struct {
|
||||
Say func(format string, args ...any)
|
||||
// Changed is told of every transition, at once — for `status`, which leads with what is open.
|
||||
Changed func()
|
||||
// Epoch is the controller lease this keeper writes under (to-be 45 §6): every write carries its
|
||||
// epoch, and none is made while it is not held. Nil writes with no epoch (a test, the memory store).
|
||||
Epoch func() (uint64, error)
|
||||
}
|
||||
|
||||
// TellFor is how long one transition is offered to the bus before it is said lost.
|
||||
@@ -97,6 +101,7 @@ var TellFor = 10 * time.Minute
|
||||
// that cleared just before this controller started and is raised again now is a reopening.
|
||||
func NewKeeper(ctx context.Context, o Options) *Keeper {
|
||||
k := &Keeper{store: o.Store, history: o.History, teller: o.Teller, now: o.Now, say: o.Say, changed: o.Changed,
|
||||
epoch: o.Epoch,
|
||||
cleared: map[string]clearing{}, out: make(chan Event, 1024), drained: make(chan struct{})}
|
||||
if k.now == nil {
|
||||
k.now = time.Now
|
||||
@@ -137,6 +142,20 @@ func (k *Keeper) Unsaid() int {
|
||||
return k.unsaid
|
||||
}
|
||||
|
||||
// stamp is the gate every write passes: the epoch it carries, set on the condition, or why this
|
||||
// keeper may not write — a controller that lost the lease raises, observes and clears nothing.
|
||||
func (k *Keeper) stamp(c *Condition) error {
|
||||
if k.epoch == nil {
|
||||
return nil
|
||||
}
|
||||
epoch, err := k.epoch()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the condition %s is not written: %w", c.Key, err)
|
||||
}
|
||||
c.Epoch = epoch
|
||||
return nil
|
||||
}
|
||||
|
||||
// tries bounds one compare-and-set: two writers rarely race more than once.
|
||||
const tries = 8
|
||||
|
||||
@@ -174,6 +193,9 @@ func (k *Keeper) Observe(ctx context.Context, o Observation) (Condition, error)
|
||||
}
|
||||
}
|
||||
k.mu.Unlock()
|
||||
if err := k.stamp(&c); err != nil {
|
||||
return Condition{}, err
|
||||
}
|
||||
body, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return Condition{}, err
|
||||
@@ -214,6 +236,9 @@ func (k *Keeper) Observe(ctx context.Context, o Observation) (Condition, error)
|
||||
if len(c.Evidence) > KeptEvidence {
|
||||
c.Evidence = c.Evidence[:KeptEvidence]
|
||||
}
|
||||
if err := k.stamp(&c); err != nil {
|
||||
return Condition{}, err
|
||||
}
|
||||
body, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return Condition{}, err
|
||||
@@ -247,6 +272,9 @@ func (k *Keeper) Clear(ctx context.Context, key, why string) (bool, error) {
|
||||
// Unreadable is not resolved: kept, and said, rather than removed unread.
|
||||
return false, fmt.Errorf("the condition %s on the bus cannot be read, so it is not cleared: %w", key, err)
|
||||
}
|
||||
if err := k.stamp(&c); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := k.store.Delete(ctx, key, entry.Revision); errors.Is(err, ErrMoved) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
@@ -318,6 +346,9 @@ func (k *Keeper) Silence(ctx context.Context, key string, d time.Duration, by, w
|
||||
}
|
||||
now := k.now().UTC()
|
||||
c.Silenced = &Silence{Until: now.Add(d), By: by, Why: strings.TrimSpace(why), Since: now}
|
||||
if err := k.stamp(&c); err != nil {
|
||||
return Condition{}, err
|
||||
}
|
||||
body, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return Condition{}, err
|
||||
@@ -362,6 +393,9 @@ func (k *Keeper) EndSilences(ctx context.Context) error {
|
||||
}
|
||||
was := held.Silenced.Why
|
||||
held.Silenced = nil
|
||||
if err := k.stamp(&held); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.Marshal(held)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -68,25 +68,25 @@ func TestAPlansTierIsMeasuredWhenItIsLeft(t *testing.T) {
|
||||
p := Plan{ID: "plan-1", Repository: "novox/mesh-tools", Commit: "abc", Created: time.Now().UTC(),
|
||||
State: PlanBuilding, Tiers: [][]string{{"mesh-tools"}, {"builder"}},
|
||||
Modules: map[string]*PlanModule{"mesh-tools": {}, "builder": {}}}
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.State = PlanRolling // the same tier, saved again
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ds, _ := inv.Durations(ctx, DurationPlanTier, time.Now().Add(-time.Hour)); len(ds) != 0 {
|
||||
t.Fatalf("a tier not left was measured: %+v", ds)
|
||||
}
|
||||
p.Tier = 1
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.State = PlanDone
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SavePlan(ctx, p); err != nil { // saved again once ended: nothing more to measure
|
||||
if err := inv.SavePlan(ctx, &p); err != nil { // saved again once ended: nothing more to measure
|
||||
t.Fatal(err)
|
||||
}
|
||||
ds, err := inv.Durations(ctx, DurationPlanTier, time.Now().Add(-time.Hour))
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Order and one writer (novox/hq to-be 45 §6, Phase 2).
|
||||
--
|
||||
-- The controller acts only while it holds its lease, and every act carries the lease's epoch: a
|
||||
-- declaration, a plan write. A report carries the order of the declaration it accounts for, and the
|
||||
-- controller keeps the highest per machine, refusing an older account rather than letting the last
|
||||
-- one written win (issue 267). These are the records each of those needs.
|
||||
|
||||
-- Every epoch the mesh issued: which controller instance held it, from when, and how it ended —
|
||||
-- given back, or lost (its key expired, or a renewal was refused). The highest one is the floor no
|
||||
-- new epoch may be at or under, even when the lease's bucket was raised again from nothing; and a
|
||||
-- stale refusal names its writer from here.
|
||||
create table controller_epoch (
|
||||
epoch bigint primary key,
|
||||
instance text not null,
|
||||
host text not null default '',
|
||||
build text not null default '',
|
||||
taken timestamptz not null default now(),
|
||||
ended timestamptz,
|
||||
-- how: 'released' (given back), 'lost' (its own renewal was refused or failed), 'expired'
|
||||
-- (found expired by the next holder: it stopped renewing without saying so).
|
||||
how text not null default ''
|
||||
);
|
||||
|
||||
-- The epoch a machine was last sent, so what the mesh WOULD send is composed with it and reads as
|
||||
-- byte for byte what it DID send when nothing else changed — a new holder's epoch is not a change
|
||||
-- of the machine. Null for a declaration sent without one.
|
||||
alter table node add column sent_epoch bigint;
|
||||
-- Whether the machine's node-engine said it reads an epoch in a declaration (its report's `reads`):
|
||||
-- until it has, it is sent none, because an older node-engine refuses a key it does not know, whole.
|
||||
alter table node add column reads_epoch boolean not null default false;
|
||||
|
||||
-- The order of the account kept for each machine: the declaration's sequence and epoch it is about
|
||||
-- and the node-engine's own report sequence. Null where the kept account carried none.
|
||||
alter table node_report add column reported_sequence bigint;
|
||||
alter table node_report add column reported_epoch bigint;
|
||||
alter table node_report add column report_sequence bigint;
|
||||
|
||||
-- A plan is written by compare-and-set on its revision, and says the epoch that wrote it last.
|
||||
alter table release_plan add column revision bigint not null default 0;
|
||||
alter table release_plan add column epoch bigint;
|
||||
@@ -14,12 +14,17 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
// Inventory is this context, holding the store it exclusively owns.
|
||||
type Inventory struct{ store *store.Store }
|
||||
type Inventory struct {
|
||||
store *store.Store
|
||||
// acting is the gate a write that acts passes (ActsUnder, novox/hq to-be 45 §6); nil passes all.
|
||||
acting func(ctx context.Context) (uint64, error)
|
||||
}
|
||||
|
||||
// Open connects to the inventory store.
|
||||
func Open(ctx context.Context) (*Inventory, error) {
|
||||
@@ -763,14 +768,35 @@ func sameFailure(a, b Doing) bool {
|
||||
// ADR 0134). A machine reconciles continuously and reports each time; the same outcome about the same
|
||||
// declaration is the same state said again, and a fact per report would be a fact per minute per
|
||||
// machine that tells nobody anything. Read here because the previous row is read here anyway.
|
||||
//
|
||||
// **An account that claims no order clears the order kept** (novox/hq to-be 45 §6): the account kept is
|
||||
// then one the next ordered report has nothing to compare against, and it is taken as it was before
|
||||
// reports carried an order. RecordOrderedDoing keeps an ordered one.
|
||||
func (i *Inventory) RecordDoing(ctx context.Context, node string, d Doing) (news bool, err error) {
|
||||
news, err = recordDoing(ctx, i.store.Pool(), node, d)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`update node_report set reported_sequence = null, reported_epoch = null, report_sequence = null
|
||||
where node = $1`, node)
|
||||
return news, err
|
||||
}
|
||||
|
||||
// queries is what recordDoing writes through: the pool, or a transaction an ordered account holds.
|
||||
type queries interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
func recordDoing(ctx context.Context, q queries, node string, d Doing) (news bool, err error) {
|
||||
failed, err := json.Marshal(d.Failed)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var before Doing
|
||||
var beforeFailed []byte
|
||||
found := i.store.Pool().QueryRow(ctx,
|
||||
found := q.QueryRow(ctx,
|
||||
`select outcome, refused, failed, failing_since, failures, coalesce(declared,'')
|
||||
from node_report where node = $1`,
|
||||
node).Scan(&before.Outcome, &before.Refused, &beforeFailed, &before.Since, &before.Times,
|
||||
@@ -795,7 +821,7 @@ func (i *Inventory) RecordDoing(ctx context.Context, node string, d Doing) (news
|
||||
since, times = before.Since, before.Times+1
|
||||
}
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
_, err = q.Exec(ctx,
|
||||
`insert into node_report (node, outcome, refused, failed, applied, at, declared,
|
||||
failing_since, failures)
|
||||
values ($1, $2, $3, $4, $5, now(), $6, $7, $8)
|
||||
@@ -923,6 +949,18 @@ func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) {
|
||||
// is a push's to send to a machine it did not name. Nil records that it is not known, as for a
|
||||
// declaration sent by hand.
|
||||
func (i *Inventory) RecordSent(ctx context.Context, node, digest string, builds map[string]string) error {
|
||||
return i.RecordSentUnder(ctx, node, digest, builds, 0)
|
||||
}
|
||||
|
||||
// RecordSentUnder is RecordSent for a declaration that carried an epoch (novox/hq to-be 45 §6): kept
|
||||
// beside the digest, so what the mesh would send is composed with it. Zero is one that carried none.
|
||||
func (i *Inventory) RecordSentUnder(ctx context.Context, node, digest string, builds map[string]string,
|
||||
epoch uint64) error {
|
||||
var sentEpoch *int64
|
||||
if epoch > 0 {
|
||||
e := int64(epoch)
|
||||
sentEpoch = &e
|
||||
}
|
||||
var carried *string
|
||||
if builds != nil {
|
||||
raw, err := json.Marshal(builds)
|
||||
@@ -933,7 +971,8 @@ func (i *Inventory) RecordSent(ctx context.Context, node, digest string, builds
|
||||
carried = &text
|
||||
}
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`update node set sent = $2, sent_at = now(), sent_builds = $3::jsonb where id = $1`, node, digest, carried)
|
||||
`update node set sent = $2, sent_at = now(), sent_builds = $3::jsonb, sent_epoch = $4 where id = $1`,
|
||||
node, digest, carried, sentEpoch)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Order and one writer, as this context keeps them (novox/hq to-be 45 §6, Phase 2): the epochs the
|
||||
// mesh issued, the epoch each machine was sent, whether its node-engine reads one, and the order of
|
||||
// the account kept for it.
|
||||
|
||||
// ActsUnder gives this inventory the gate every write that acts passes (a plan's): the epoch of the
|
||||
// controller lease this process acts under, or why it may not act. Nil — a test, a command reading —
|
||||
// writes with no epoch and refuses nothing.
|
||||
func (i *Inventory) ActsUnder(epoch func(ctx context.Context) (uint64, error)) { i.acting = epoch }
|
||||
|
||||
// actingEpoch is the epoch a write carries, nil when there is no gate or it claims none.
|
||||
func (i *Inventory) actingEpoch(ctx context.Context) (*int64, error) {
|
||||
if i.acting == nil {
|
||||
return nil, nil
|
||||
}
|
||||
epoch, err := i.acting(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if epoch == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
e := int64(epoch)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// Epoch is one epoch the mesh issued.
|
||||
type Epoch struct {
|
||||
Epoch uint64
|
||||
Instance string
|
||||
Host string
|
||||
Build string
|
||||
Taken time.Time
|
||||
// Ended is when it ended, nil while it is held; How is how: EpochReleased, EpochLost, EpochExpired.
|
||||
Ended *time.Time
|
||||
How string
|
||||
}
|
||||
|
||||
// How an epoch ends.
|
||||
const (
|
||||
// EpochReleased is a holder that gave the lease back.
|
||||
EpochReleased = "released"
|
||||
// EpochLost is a holder whose own renewal was refused or failed, and which said so.
|
||||
EpochLost = "lost"
|
||||
// EpochExpired is a holder the next one found gone: it stopped renewing without saying anything.
|
||||
EpochExpired = "expired"
|
||||
)
|
||||
|
||||
// HighestEpoch is the highest epoch the mesh has issued, zero before the first: the floor no new one
|
||||
// may be at or under.
|
||||
func (i *Inventory) HighestEpoch(ctx context.Context) (uint64, error) {
|
||||
var highest int64
|
||||
if err := i.store.Pool().QueryRow(ctx, `select coalesce(max(epoch), 0) from controller_epoch`).Scan(&highest); err != nil {
|
||||
return 0, fmt.Errorf("reading the highest epoch issued: %w", err)
|
||||
}
|
||||
return uint64(highest), nil
|
||||
}
|
||||
|
||||
// TookEpoch records an epoch taken, and ends every earlier one still open as found expired: the lease
|
||||
// was free to take, so whoever held it last neither holds it nor said it let go. Answers the epochs it
|
||||
// ended that way, newest first.
|
||||
func (i *Inventory) TookEpoch(ctx context.Context, e Epoch) ([]Epoch, error) {
|
||||
tx, err := i.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
rows, err := tx.Query(ctx,
|
||||
`update controller_epoch set ended = now(), how = $2
|
||||
where ended is null and epoch < $1
|
||||
returning epoch, instance, host, build, taken, ended, how`, int64(e.Epoch), EpochExpired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expired, err := scanEpochs(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into controller_epoch (epoch, instance, host, build, taken) values ($1, $2, $3, $4, $5)
|
||||
on conflict (epoch) do nothing`,
|
||||
int64(e.Epoch), e.Instance, e.Host, e.Build, e.Taken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return expired, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// EndEpoch records how an epoch ended. One already ended keeps its first word.
|
||||
func (i *Inventory) EndEpoch(ctx context.Context, epoch uint64, how string) error {
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`update controller_epoch set ended = now(), how = $2 where epoch = $1 and ended is null`, int64(epoch), how)
|
||||
return err
|
||||
}
|
||||
|
||||
// EpochOf is the epoch issued at a number; false when the mesh issued none there.
|
||||
func (i *Inventory) EpochOf(ctx context.Context, epoch uint64) (Epoch, bool, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select epoch, instance, host, build, taken, ended, how from controller_epoch where epoch = $1`, int64(epoch))
|
||||
if err != nil {
|
||||
return Epoch{}, false, err
|
||||
}
|
||||
found, err := scanEpochs(rows)
|
||||
if err != nil || len(found) == 0 {
|
||||
return Epoch{}, false, err
|
||||
}
|
||||
return found[0], true, nil
|
||||
}
|
||||
|
||||
// EpochsSince is every epoch taken or ended since a moment, newest first.
|
||||
func (i *Inventory) EpochsSince(ctx context.Context, since time.Time) ([]Epoch, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select epoch, instance, host, build, taken, ended, how from controller_epoch
|
||||
where taken >= $1 or ended >= $1 or ended is null order by epoch desc`, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanEpochs(rows)
|
||||
}
|
||||
|
||||
func scanEpochs(rows pgx.Rows) ([]Epoch, error) {
|
||||
defer rows.Close()
|
||||
var out []Epoch
|
||||
for rows.Next() {
|
||||
var e Epoch
|
||||
var epoch int64
|
||||
if err := rows.Scan(&epoch, &e.Instance, &e.Host, &e.Build, &e.Taken, &e.Ended, &e.How); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Epoch = uint64(epoch)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SentEpoch is the epoch a machine was last sent, by its id; zero for one sent without.
|
||||
func (i *Inventory) SentEpoch(ctx context.Context, id string) (uint64, error) {
|
||||
var epoch *int64
|
||||
if err := i.store.Pool().QueryRow(ctx, `select sent_epoch from node where id = $1`, id).Scan(&epoch); err != nil {
|
||||
return 0, fmt.Errorf("reading the epoch %s was last sent: %w", id, err)
|
||||
}
|
||||
if epoch == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return uint64(*epoch), nil
|
||||
}
|
||||
|
||||
// ReadsEpoch says a machine's node-engine said it reads an epoch in a declaration, by its id.
|
||||
func (i *Inventory) ReadsEpoch(ctx context.Context, id string) (bool, error) {
|
||||
var reads bool
|
||||
if err := i.store.Pool().QueryRow(ctx, `select reads_epoch from node where id = $1`, id).Scan(&reads); err != nil {
|
||||
return false, fmt.Errorf("reading whether %s reads an epoch: %w", id, err)
|
||||
}
|
||||
return reads, nil
|
||||
}
|
||||
|
||||
// RecordReadsEpoch keeps what a machine's latest report said of reading an epoch. Every report of a
|
||||
// node-engine that orders its reports says it, so a node-engine rolled back says it no longer does.
|
||||
func (i *Inventory) RecordReadsEpoch(ctx context.Context, id string, reads bool) error {
|
||||
_, err := i.store.Pool().Exec(ctx, `update node set reads_epoch = $2 where id = $1`, id, reads)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReportOrder is the order of an account: the declaration's epoch and sequence it is about, and the
|
||||
// node-engine's own report sequence. Zero in any claims none.
|
||||
type ReportOrder struct {
|
||||
Epoch int64
|
||||
Sequence int64
|
||||
ReportSequence int64
|
||||
}
|
||||
|
||||
// ErrOlderAccount is an account refused because the one kept is newer.
|
||||
var ErrOlderAccount = errors.New("an older account than the one kept")
|
||||
|
||||
// KeptOrder is the order of the account kept for a machine, by its id; zero when it carried none.
|
||||
func (i *Inventory) KeptOrder(ctx context.Context, id string) (ReportOrder, error) {
|
||||
o, err := keptOrder(ctx, i.store.Pool(), id, "")
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ReportOrder{}, nil
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
|
||||
func keptOrder(ctx context.Context, q queries, id, lock string) (ReportOrder, error) {
|
||||
var epoch, seq, rseq *int64
|
||||
err := q.QueryRow(ctx,
|
||||
`select reported_epoch, reported_sequence, report_sequence from node_report where node = $1`+lock, id).
|
||||
Scan(&epoch, &seq, &rseq)
|
||||
if err != nil {
|
||||
return ReportOrder{}, err
|
||||
}
|
||||
var o ReportOrder
|
||||
for _, f := range []struct {
|
||||
from *int64
|
||||
to *int64
|
||||
}{{epoch, &o.Epoch}, {seq, &o.Sequence}, {rseq, &o.ReportSequence}} {
|
||||
if f.from != nil {
|
||||
*f.to = *f.from
|
||||
}
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// RecordOrderedDoing is RecordDoing for an account that carries its order (novox/hq to-be 45 §6):
|
||||
// written only when it is not older than the account kept, as olderThan judges — the rule is the
|
||||
// link's (link.Account), stated once — and in one transaction with the row locked, so two accounts
|
||||
// arriving together cannot both win. ErrOlderAccount when it is older; nothing is written then.
|
||||
func (i *Inventory) RecordOrderedDoing(ctx context.Context, id string, d Doing, o ReportOrder,
|
||||
olderThan func(kept ReportOrder) bool) (news bool, err error) {
|
||||
tx, err := i.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
kept, err := keptOrder(ctx, tx, id, " for update")
|
||||
switch {
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
case err != nil:
|
||||
return false, err
|
||||
case olderThan(kept):
|
||||
return false, ErrOlderAccount
|
||||
}
|
||||
news, err = recordDoing(ctx, tx, id, d)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`update node_report set reported_epoch = $2, reported_sequence = $3, report_sequence = $4 where node = $1`,
|
||||
id, nullIfZero(o.Epoch), nullIfZero(o.Sequence), nullIfZero(o.ReportSequence)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return news, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// nullIfZero is a claimed order or none.
|
||||
func nullIfZero(n int64) *int64 {
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Order and one writer, as the store keeps them (novox/hq to-be 45 §6).
|
||||
|
||||
// **A plan is written by compare-and-set on its revision, carrying the epoch**: a write against a plan
|
||||
// another writer moved since is refused, and nothing is written by a process that may not act.
|
||||
func TestAPlanIsWrittenByCompareAndSetUnderTheLease(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
epoch := uint64(57)
|
||||
inv.ActsUnder(func(context.Context) (uint64, error) { return epoch, nil })
|
||||
|
||||
p := Plan{ID: "plan-cas", Repository: "novox/app", Commit: "c0ffee00", Created: time.Now(), State: PlanBuilding,
|
||||
Tiers: [][]string{{"app"}}, Modules: map[string]*PlanModule{"app": {}}}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Revision != 1 || p.Epoch != 57 {
|
||||
t.Fatalf("a new plan was written at revision %d, epoch %d", p.Revision, p.Epoch)
|
||||
}
|
||||
// Two readers of revision 1: the first write wins, the second is refused and writes nothing.
|
||||
first, err := inv.PlanByID(ctx, "plan-cas")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := first
|
||||
first.Note = "the newer word"
|
||||
if err := inv.SavePlan(ctx, &first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second.State = PlanFailed
|
||||
if err := inv.SavePlan(ctx, &second); !errors.Is(err, ErrPlanMoved) {
|
||||
t.Fatalf("a write against a plan moved since it was read was not refused: %v", err)
|
||||
}
|
||||
kept, _ := inv.PlanByID(ctx, "plan-cas")
|
||||
if kept.State != PlanBuilding || kept.Note != "the newer word" || kept.Revision != 2 {
|
||||
t.Fatalf("the refused write reached the plan: %+v", kept)
|
||||
}
|
||||
// The writer saves its own plan again without reading it: its revision moved with its write.
|
||||
first.Tier = 0
|
||||
if err := inv.SavePlan(ctx, &first); err != nil {
|
||||
t.Fatalf("a writer could not save its own plan twice: %v", err)
|
||||
}
|
||||
// A new plan under an id already written is refused, not laid over it.
|
||||
again := Plan{ID: "plan-cas", Repository: "novox/app", Commit: "deadbeef", Created: time.Now(), State: PlanBuilding}
|
||||
if err := inv.SavePlan(ctx, &again); !errors.Is(err, ErrPlanMoved) {
|
||||
t.Fatalf("a second plan under one id was written: %v", err)
|
||||
}
|
||||
// And a process that does not hold the lease writes nothing.
|
||||
inv.ActsUnder(func(context.Context) (uint64, error) { return 0, errors.New("this controller lost the lease") })
|
||||
first.Note = "from a controller that lost the lease"
|
||||
if err := inv.SavePlan(ctx, &first); err == nil {
|
||||
t.Fatal("a plan was written by a controller that does not hold the lease")
|
||||
}
|
||||
if kept, _ := inv.PlanByID(ctx, "plan-cas"); kept.Note != "the newer word" || kept.Epoch != 57 {
|
||||
t.Fatalf("a controller without the lease wrote the plan: %+v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
// The epochs the mesh issued: the highest is the floor; taking one ends every earlier one nobody gave
|
||||
// back as found expired, and says which; one given back says so and is not said again.
|
||||
func TestTheEpochsIssuedAreKept(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
if highest, err := inv.HighestEpoch(ctx); err != nil || highest != 0 {
|
||||
t.Fatalf("a mesh that issued none has %d (%v)", highest, err)
|
||||
}
|
||||
if _, err := inv.TookEpoch(ctx, Epoch{Epoch: 41, Instance: "a", Taken: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// a stopped renewing and said nothing; b takes the lease.
|
||||
expired, err := inv.TookEpoch(ctx, Epoch{Epoch: 57, Instance: "b", Taken: time.Now()})
|
||||
if err != nil || len(expired) != 1 || expired[0].Epoch != 41 || expired[0].How != EpochExpired {
|
||||
t.Fatalf("taking 57 ended %+v (%v), want 41 found expired", expired, err)
|
||||
}
|
||||
if err := inv.EndEpoch(ctx, 57, EpochReleased); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expired, err = inv.TookEpoch(ctx, Epoch{Epoch: 60, Instance: "c", Taken: time.Now()})
|
||||
if err != nil || len(expired) != 0 {
|
||||
t.Fatalf("a lease given back was found expired: %+v (%v)", expired, err)
|
||||
}
|
||||
if highest, _ := inv.HighestEpoch(ctx); highest != 60 {
|
||||
t.Fatalf("the highest epoch issued is %d, want 60", highest)
|
||||
}
|
||||
e, found, err := inv.EpochOf(ctx, 57)
|
||||
if err != nil || !found || e.Instance != "b" || e.How != EpochReleased || e.Ended == nil {
|
||||
t.Fatalf("epoch 57 reads %+v (%v, %v)", e, found, err)
|
||||
}
|
||||
recent, err := inv.EpochsSince(ctx, time.Now().Add(-time.Hour))
|
||||
if err != nil || len(recent) != 3 || recent[0].Epoch != 60 || recent[0].Ended != nil {
|
||||
t.Fatalf("the epochs of the last hour read %+v (%v)", recent, err)
|
||||
}
|
||||
}
|
||||
|
||||
// What a machine was sent under, and whether it reads an epoch.
|
||||
func TestTheEpochAMachineWasSentIsKept(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
node, err := inv.AddNode(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSentUnder(ctx, node.ID, "d1", nil, 57); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, err := inv.SentEpoch(ctx, node.ID); err != nil || e != 57 {
|
||||
t.Fatalf("sent under %d (%v)", e, err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, node.ID, "d2", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, _ := inv.SentEpoch(ctx, node.ID); e != 0 {
|
||||
t.Fatalf("a declaration sent without an epoch left %d kept", e)
|
||||
}
|
||||
if reads, _ := inv.ReadsEpoch(ctx, node.ID); reads {
|
||||
t.Fatal("a machine that never said so reads an epoch")
|
||||
}
|
||||
}
|
||||
+58
-10
@@ -32,8 +32,17 @@ type Plan struct {
|
||||
// never written from here: a save measures the tier it leaves and stamps the next. What the
|
||||
// watchdog of a plan's progress (S3) reads.
|
||||
TierEntered time.Time `json:"tier_entered,omitempty"`
|
||||
// Revision is the plan's as it was read, and the one a save must find (novox/hq to-be 45 §6): a
|
||||
// plan is written by compare-and-set, so a write against a plan another writer moved since is
|
||||
// refused rather than laid over it. Zero is a plan never saved. SavePlan moves it.
|
||||
Revision int64 `json:"revision"`
|
||||
// Epoch is the controller lease epoch that wrote it last; zero for a write that claimed none.
|
||||
Epoch uint64 `json:"epoch,omitempty"`
|
||||
}
|
||||
|
||||
// ErrPlanMoved is a save against a plan written by somebody else since it was read.
|
||||
var ErrPlanMoved = errors.New("the plan was written by somebody else since it was read")
|
||||
|
||||
// PlanModule is one module's state within a plan.
|
||||
type PlanModule struct {
|
||||
// State: asked, built, failed; empty for a module whose tier has not been asked yet.
|
||||
@@ -75,7 +84,16 @@ const (
|
||||
func (p Plan) Open() bool { return p.State == PlanBuilding || p.State == PlanRolling }
|
||||
|
||||
// SavePlan writes a plan, new or changed, whole: the plan is small and read as one thing.
|
||||
func (i *Inventory) SavePlan(ctx context.Context, p Plan) error {
|
||||
//
|
||||
// **By compare-and-set on its revision, carrying the epoch** (novox/hq to-be 45 §6): written only if
|
||||
// the plan is still at the revision it was read at — a new one only if it does not exist — and refused
|
||||
// with ErrPlanMoved otherwise; and only by a process that may act (ActsUnder), whose epoch it records.
|
||||
// On success p's revision and epoch are the ones written, so the caller may save it again.
|
||||
func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
|
||||
epoch, err := i.actingEpoch(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the plan for %s %s is not written: %w", p.Repository, p.Commit, err)
|
||||
}
|
||||
tiers, err := json.Marshal(p.Tiers)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -92,21 +110,49 @@ func (i *Inventory) SavePlan(ctx context.Context, p Plan) error {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
entered, err := planTierLeft(ctx, tx, p, time.Now())
|
||||
entered, err := planTierLeft(ctx, tx, *p, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx,
|
||||
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch, tier_entered)
|
||||
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11)
|
||||
var revision int64
|
||||
err = tx.QueryRow(ctx,
|
||||
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note,
|
||||
branch, tier_entered, revision, epoch)
|
||||
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13)
|
||||
on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier,
|
||||
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note, branch = excluded.branch,
|
||||
tier_entered = excluded.tier_entered`,
|
||||
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note, p.Branch, entered)
|
||||
tier_entered = excluded.tier_entered, revision = release_plan.revision + 1, epoch = excluded.epoch
|
||||
where release_plan.revision = $12
|
||||
returning revision`,
|
||||
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note, p.Branch, entered,
|
||||
p.Revision, epoch).Scan(&revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// The row is there and at another revision — moved since this was read, or there already
|
||||
// when this one is new: either way not this writer's to overwrite. (A plan saved before plans
|
||||
// had revisions is at zero, and its first save here is from a read at zero.)
|
||||
return fmt.Errorf("the plan for %s %s (%s) is not written: %w", p.Repository, short(p.Commit), p.ID, ErrPlanMoved)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Revision, p.TierEntered = revision, entered
|
||||
if epoch != nil {
|
||||
p.Epoch = uint64(*epoch)
|
||||
} else {
|
||||
p.Epoch = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// short is a commit as a person reads it.
|
||||
func short(commit string) string {
|
||||
if len(commit) > 8 {
|
||||
return commit[:8]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
|
||||
// OpenPlans is every plan still being worked, oldest first.
|
||||
@@ -134,7 +180,7 @@ func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
|
||||
func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch,
|
||||
coalesce(tier_entered, created)
|
||||
coalesce(tier_entered, created), revision, coalesce(epoch, 0)
|
||||
from release_plan `+tail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -144,10 +190,12 @@ func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
|
||||
for rows.Next() {
|
||||
var p Plan
|
||||
var tiers, modules []byte
|
||||
var epoch int64
|
||||
if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State,
|
||||
&p.Tier, &tiers, &modules, &p.Note, &p.Branch, &p.TierEntered); err != nil {
|
||||
&p.Tier, &tiers, &modules, &p.Note, &p.Branch, &p.TierEntered, &p.Revision, &epoch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Epoch = uint64(epoch)
|
||||
if err := json.Unmarshal(tiers, &p.Tiers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestAPlanIsKeptAdvancedAndResumedFromTheStore(t *testing.T) {
|
||||
p := Plan{ID: "plan-1", Repository: "novox/mesh-tools", Commit: "abc", Created: time.Now().UTC(),
|
||||
State: PlanBuilding, Tiers: [][]string{{"mesh-tools"}, {"builder"}, {"shop"}},
|
||||
Modules: map[string]*PlanModule{"mesh-tools": {}, "builder": {}, "shop": {}}}
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open, err := inv.OpenPlans(ctx)
|
||||
@@ -28,7 +28,7 @@ func TestAPlanIsKeptAdvancedAndResumedFromTheStore(t *testing.T) {
|
||||
resumed.Modules["mesh-tools"].BuiltAt = &now
|
||||
resumed.State = PlanRolling
|
||||
resumed.Note = "tier 0 built; waiting for builder on anchor to be applied"
|
||||
if err := inv.SavePlan(ctx, resumed); err != nil {
|
||||
if err := inv.SavePlan(ctx, &resumed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := inv.PlanByID(ctx, "plan-1")
|
||||
@@ -36,7 +36,7 @@ func TestAPlanIsKeptAdvancedAndResumedFromTheStore(t *testing.T) {
|
||||
t.Fatalf("the advanced plan did not come back as left: %v %+v", err, again)
|
||||
}
|
||||
again.State = PlanDone
|
||||
if err := inv.SavePlan(ctx, again); err != nil {
|
||||
if err := inv.SavePlan(ctx, &again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if open, _ = inv.OpenPlans(ctx); len(open) != 0 {
|
||||
@@ -54,7 +54,7 @@ func TestASupersededPlanIsNotOpen(t *testing.T) {
|
||||
p := Plan{ID: "plan-1", Repository: "novox/mesh-catalog", Branch: "main", Commit: "abc",
|
||||
Created: time.Now().UTC(), State: PlanBuilding, Tiers: [][]string{{"gitea"}},
|
||||
Modules: map[string]*PlanModule{"gitea": {}}}
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept, err := inv.PlanByID(ctx, "plan-1")
|
||||
@@ -63,7 +63,7 @@ func TestASupersededPlanIsNotOpen(t *testing.T) {
|
||||
}
|
||||
kept.State = PlanSuperseded
|
||||
kept.Note = "superseded at tier 0 by plan-2"
|
||||
if err := inv.SavePlan(ctx, kept); err != nil {
|
||||
if err := inv.SavePlan(ctx, &kept); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if open, err := inv.OpenPlans(ctx); err != nil || len(open) != 0 {
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
// Package lease is the controller's lease (novox/hq to-be 45 §6, ADR 0227 rule 1): the one key that
|
||||
// says which controller instance may act, and the epoch every act of it carries.
|
||||
//
|
||||
// **A controller instance acts only while it holds the key `holder`** in its lease bucket — sends a
|
||||
// declaration, writes a plan, a condition or a call. The key is written by compare-and-set, lives a
|
||||
// bucket's age (fifteen seconds) unless renewed, and is renewed every five. The **epoch** is the
|
||||
// bucket's revision at which the instance took it: a later holder's is higher, so a machine that has
|
||||
// heard from epoch 57 can refuse anything still arriving from 41 (issue 204).
|
||||
//
|
||||
// controller A (epoch 41) ──renew──renew──╳ (renewal refused)──► stops acting, exits
|
||||
// controller B ──wait──────────────take (epoch 57)──► acts
|
||||
//
|
||||
// **Three ways an instance stops acting, all at once.** A renewal refused — somebody else wrote the key
|
||||
// — is a loss, said and final: Lost closes and the process exits, so its service manager restarts it as
|
||||
// a candidate. A renewal that cannot be made in time is the same, because past its time the key may be
|
||||
// somebody else's. And Epoch, the gate every act passes, answers an error from the moment the last
|
||||
// renewal is older than the key's age less a margin — **before** another instance could have taken
|
||||
// it, whatever the renewing goroutine is doing — so a send in flight at the moment of loss is refused
|
||||
// by the clock, not by a goroutine that may be stuck.
|
||||
package lease
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Key is the one key of the lease bucket.
|
||||
const Key = "holder"
|
||||
|
||||
// Holder is who holds the lease, as the key says it.
|
||||
type Holder struct {
|
||||
// Instance names one controller process: its machine, its process and when it started. Two
|
||||
// instances on one machine — the handover of issue 213 — are two names.
|
||||
Instance string `json:"instance"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Build string `json:"build,omitempty"`
|
||||
// Epoch is the revision the key was taken at. Zero in the value written to take it, because the
|
||||
// revision is known only once written; every renewal says it, and Current reads the revision of a
|
||||
// key never renewed.
|
||||
Epoch uint64 `json:"epoch,omitempty"`
|
||||
Taken time.Time `json:"taken"`
|
||||
Renewed time.Time `json:"renewed"`
|
||||
}
|
||||
|
||||
// Defaults, as to-be 45 §6 sets them. The age is the bucket's, read from it (Open), so the bucket
|
||||
// and the lease cannot disagree about it.
|
||||
const (
|
||||
RenewEvery = 5 * time.Second
|
||||
// Margin is how long before the key could expire this instance stops acting on it: the gap
|
||||
// between its own clock and the bus's, and a send already on its way.
|
||||
Margin = 3 * time.Second
|
||||
// Poll is how often a candidate looks again at a key somebody else holds.
|
||||
Poll = time.Second
|
||||
)
|
||||
|
||||
// ErrNotHeld is an act asked of an instance that does not hold the lease.
|
||||
var ErrNotHeld = errors.New("this controller does not hold the lease")
|
||||
|
||||
// ErrUnwritable is a lease nobody holds that the bus would not let this instance write: the one case a
|
||||
// caller may serve without it, since nothing else holds it either. Every other failure to take it —
|
||||
// the holder unreadable, the floor unreadable — says nothing about whether another instance acts.
|
||||
var ErrUnwritable = errors.New("nobody holds the lease, and the bus would not let this controller write it")
|
||||
|
||||
// Options are what a Lease is made with.
|
||||
type Options struct {
|
||||
Holder Holder
|
||||
// RenewEvery, Margin and Poll default to the package's.
|
||||
RenewEvery, Margin, Poll time.Duration
|
||||
// Floor is the highest epoch this mesh has issued, from the controller's own store. **An epoch is
|
||||
// never issued twice**: a lease bucket raised again from nothing — a bus whose data was replaced —
|
||||
// starts its revisions over, and every machine that heard epoch 57 would refuse the next controller
|
||||
// for ever. Below the floor, the bucket's revisions are moved past it before the key is taken.
|
||||
// Nil is no floor.
|
||||
Floor func(ctx context.Context) (uint64, error)
|
||||
// Say is where what the lease does is said: waiting, taking, losing.
|
||||
Say func(format string, args ...any)
|
||||
// Moved is told when the bucket's revisions were moved past the floor: the bucket was raised again
|
||||
// from nothing, which the caller says as a condition. Nil tells nobody.
|
||||
Moved func(was, floor uint64)
|
||||
// Now is the clock; nil is time.Now.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Lease is one instance's hold, or its wait for one.
|
||||
type Lease struct {
|
||||
kv jetstream.KeyValue
|
||||
stream jetstream.Stream
|
||||
ttl time.Duration
|
||||
o Options
|
||||
|
||||
mu sync.Mutex
|
||||
// held is whether this instance holds the key; epoch the revision it took it at; revision the
|
||||
// revision of its last write, which the next renewal must find.
|
||||
held bool
|
||||
epoch uint64
|
||||
revision uint64
|
||||
// validUntil is when this instance stops acting unless it renews: the moment before its last
|
||||
// renewal was sent, plus the key's age, less the margin.
|
||||
validUntil time.Time
|
||||
// renewed is when this instance last took or renewed the key.
|
||||
renewed time.Time
|
||||
lostWhy error
|
||||
lost chan struct{}
|
||||
taken time.Time
|
||||
}
|
||||
|
||||
// Open is a lease over the bucket, read for its age. The bucket is the caller's to assert.
|
||||
func Open(ctx context.Context, js jetstream.JetStream, bucket string, o Options) (*Lease, error) {
|
||||
kv, err := js.KeyValue(ctx, bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the lease bucket %s is not on the bus: %w", bucket, err)
|
||||
}
|
||||
status, err := kv.Status(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the lease bucket %s cannot be read: %w", bucket, err)
|
||||
}
|
||||
if status.TTL() <= 0 {
|
||||
// A key that never expires is a lease a dead controller holds for ever.
|
||||
return nil, fmt.Errorf("the lease bucket %s keeps its keys for ever, so a controller that died "+
|
||||
"holding it would hold it for ever: it must have an age", bucket)
|
||||
}
|
||||
stream, err := js.Stream(ctx, "KV_"+bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the stream under the lease bucket %s cannot be read: %w", bucket, err)
|
||||
}
|
||||
if o.RenewEvery <= 0 {
|
||||
o.RenewEvery = RenewEvery
|
||||
}
|
||||
if o.Margin <= 0 {
|
||||
o.Margin = Margin
|
||||
}
|
||||
if o.Poll <= 0 {
|
||||
o.Poll = Poll
|
||||
}
|
||||
if o.Say == nil {
|
||||
o.Say = func(string, ...any) {}
|
||||
}
|
||||
if o.Now == nil {
|
||||
o.Now = time.Now
|
||||
}
|
||||
if o.RenewEvery+o.Margin >= status.TTL() {
|
||||
return nil, fmt.Errorf("a lease renewed every %s with a margin of %s does not fit a key that lives %s",
|
||||
o.RenewEvery, o.Margin, status.TTL())
|
||||
}
|
||||
return &Lease{kv: kv, stream: stream, ttl: status.TTL(), o: o, lost: make(chan struct{})}, nil
|
||||
}
|
||||
|
||||
// TTL is how long the key lives unrenewed.
|
||||
func (l *Lease) TTL() time.Duration { return l.ttl }
|
||||
|
||||
// Current is who holds the lease now, and false when nobody does. An error is an error: never "nobody".
|
||||
func Current(ctx context.Context, kv jetstream.KeyValue) (Holder, bool, error) {
|
||||
entry, err := kv.Get(ctx, Key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return Holder{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Holder{}, false, err
|
||||
}
|
||||
var h Holder
|
||||
if err := json.Unmarshal(entry.Value(), &h); err != nil {
|
||||
return Holder{}, false, fmt.Errorf("the lease's key holds something that is not a holder: %w", err)
|
||||
}
|
||||
if h.Epoch == 0 {
|
||||
// Taken and not yet renewed: the revision it was taken at is the one it has.
|
||||
h.Epoch = entry.Revision()
|
||||
}
|
||||
return h, true, nil
|
||||
}
|
||||
|
||||
// Current is who holds this lease now.
|
||||
func (l *Lease) Current(ctx context.Context) (Holder, bool, error) { return Current(ctx, l.kv) }
|
||||
|
||||
// ErrTaken is a take that found the key held by somebody else; Take waits on it, TryTake answers it.
|
||||
var ErrTaken = errors.New("another controller holds the lease")
|
||||
|
||||
// Take waits until the key is absent or expired and takes it, answering the epoch. A key somebody
|
||||
// else holds is waited on and said once; an error reading or writing the bucket is answered, because
|
||||
// a candidate that cannot see the key cannot tell whether it may act.
|
||||
func (l *Lease) Take(ctx context.Context) (uint64, error) {
|
||||
said := ""
|
||||
for {
|
||||
epoch, err := l.TryTake(ctx)
|
||||
if err == nil {
|
||||
return epoch, nil
|
||||
}
|
||||
var held *heldBy
|
||||
if !errors.As(err, &held) {
|
||||
return 0, err
|
||||
}
|
||||
if held.h.Instance != said {
|
||||
l.o.Say("another controller holds the lease (%s, epoch %d, taken %s); waiting until it lets go "+
|
||||
"or stops renewing", held.h.Instance, held.h.Epoch, held.h.Taken.UTC().Format(time.RFC3339))
|
||||
said = held.h.Instance
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
case <-time.After(l.o.Poll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// heldBy is ErrTaken naming the holder.
|
||||
type heldBy struct{ h Holder }
|
||||
|
||||
func (e *heldBy) Error() string {
|
||||
return fmt.Sprintf("%v: %s, epoch %d", ErrTaken, e.h.Instance, e.h.Epoch)
|
||||
}
|
||||
func (e *heldBy) Unwrap() error { return ErrTaken }
|
||||
|
||||
// TryTake takes the key if nobody holds it, once, without waiting: the epoch, or ErrTaken naming the
|
||||
// holder, or what the bus said.
|
||||
func (l *Lease) TryTake(ctx context.Context) (uint64, error) {
|
||||
l.mu.Lock()
|
||||
switch {
|
||||
case l.lostWhy != nil:
|
||||
// A lease lost is not taken again by the instance that lost it: that instance exits, and its
|
||||
// successor is a new candidate with a new name.
|
||||
defer l.mu.Unlock()
|
||||
return 0, fmt.Errorf("%w: it lost it — %v", ErrNotHeld, l.lostWhy)
|
||||
case l.held:
|
||||
defer l.mu.Unlock()
|
||||
return l.epoch, nil
|
||||
}
|
||||
l.mu.Unlock()
|
||||
if h, found, err := l.Current(ctx); err != nil {
|
||||
return 0, fmt.Errorf("who holds the lease cannot be read: %w", err)
|
||||
} else if found {
|
||||
return 0, &heldBy{h}
|
||||
}
|
||||
if err := l.aboveTheFloor(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := l.o.Now()
|
||||
h := l.o.Holder
|
||||
h.Taken, h.Renewed, h.Epoch = now, now, 0
|
||||
value, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
anchor := l.o.Now()
|
||||
revision, err := l.kv.Create(ctx, Key, value)
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
// Somebody took it between the read and the write: theirs.
|
||||
if h, found, rerr := l.Current(ctx); rerr == nil && found {
|
||||
return 0, &heldBy{h}
|
||||
}
|
||||
return 0, ErrTaken
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: %w", ErrUnwritable, err)
|
||||
}
|
||||
if floor, err := l.floor(ctx); err != nil {
|
||||
_ = l.kv.Delete(ctx, Key, jetstream.LastRevision(revision))
|
||||
return 0, err
|
||||
} else if revision <= floor {
|
||||
// The bucket moved under the floor between the check and the write (another candidate raising
|
||||
// it again): never an epoch already issued. Given back, and taken again on the next try.
|
||||
_ = l.kv.Delete(ctx, Key, jetstream.LastRevision(revision))
|
||||
return 0, fmt.Errorf("the lease was taken at revision %d, not above the highest epoch issued (%d); given "+
|
||||
"back to be taken again", revision, floor)
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.held, l.epoch, l.revision, l.taken, l.renewed = true, revision, revision, now, anchor
|
||||
l.validUntil = anchor.Add(l.ttl - l.o.Margin)
|
||||
l.mu.Unlock()
|
||||
l.o.Say("took the controller lease at epoch %d (%s)", revision, h.Instance)
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
// floor is the highest epoch issued, zero without a floor.
|
||||
func (l *Lease) floor(ctx context.Context) (uint64, error) {
|
||||
if l.o.Floor == nil {
|
||||
return 0, nil
|
||||
}
|
||||
floor, err := l.o.Floor(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("the highest epoch this mesh has issued cannot be read, so no epoch can be "+
|
||||
"issued that is surely higher: %w", err)
|
||||
}
|
||||
return floor, nil
|
||||
}
|
||||
|
||||
// aboveTheFloor moves the bucket's revisions past the highest epoch issued, when a bucket raised again
|
||||
// from nothing is behind it. Only while nobody holds the key: the stream is compacted to the floor, which
|
||||
// sets the next revision above it.
|
||||
func (l *Lease) aboveTheFloor(ctx context.Context) error {
|
||||
floor, err := l.floor(ctx)
|
||||
if err != nil || floor == 0 {
|
||||
return err
|
||||
}
|
||||
info, err := l.stream.Info(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the lease bucket's revisions cannot be read: %w", err)
|
||||
}
|
||||
if info.State.LastSeq > floor {
|
||||
return nil
|
||||
}
|
||||
l.o.Say("the lease bucket is at revision %d and this mesh has issued epoch %d: the bucket was raised "+
|
||||
"again from nothing, so its revisions are moved past %d before the lease is taken", info.State.LastSeq,
|
||||
floor, floor)
|
||||
if err := l.stream.Purge(ctx, jetstream.WithPurgeSequence(floor+1)); err != nil {
|
||||
return fmt.Errorf("the lease bucket's revisions could not be moved past epoch %d: %w", floor, err)
|
||||
}
|
||||
if l.o.Moved != nil {
|
||||
l.o.Moved(info.State.LastSeq, floor)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Epoch is the gate every act passes: this instance's epoch while it holds the lease and its last
|
||||
// renewal is in time, and an error otherwise — not held, lost, or a renewal overdue.
|
||||
func (l *Lease) Epoch() (uint64, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
switch {
|
||||
case l.lostWhy != nil:
|
||||
return 0, fmt.Errorf("%w: it lost it — %v", ErrNotHeld, l.lostWhy)
|
||||
case !l.held:
|
||||
return 0, ErrNotHeld
|
||||
case l.o.Now().After(l.validUntil):
|
||||
return 0, fmt.Errorf("%w in time: its last renewal was due by %s", ErrNotHeld,
|
||||
l.validUntil.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return l.epoch, nil
|
||||
}
|
||||
|
||||
// Renewed is when this instance last took or renewed the key; zero before it took it.
|
||||
func (l *Lease) Renewed() time.Time {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.renewed
|
||||
}
|
||||
|
||||
// Held says this instance holds the lease now, as Epoch's gate does.
|
||||
func (l *Lease) Held() bool {
|
||||
_, err := l.Epoch()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Lost is closed when this instance loses the lease it held. A caller exits on it.
|
||||
func (l *Lease) Lost() <-chan struct{} { return l.lost }
|
||||
|
||||
// LostWhy is why it was lost, nil while it was not.
|
||||
func (l *Lease) LostWhy() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.lostWhy
|
||||
}
|
||||
|
||||
// Keep renews the lease until ctx ends or a renewal fails; on a failure it is lost.
|
||||
func (l *Lease) Keep(ctx context.Context) {
|
||||
tick := time.NewTicker(l.o.RenewEvery)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
if err := l.Renew(ctx); err != nil {
|
||||
return // lost, said by lose; or stopping
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renew writes the key once more at the revision this instance last wrote. A refusal or a failure is
|
||||
// the lease lost: past this renewal's time the key may be somebody else's, and an instance that is not
|
||||
// sure it holds the lease does not act on it.
|
||||
func (l *Lease) Renew(ctx context.Context) error {
|
||||
l.mu.Lock()
|
||||
if !l.held || l.lostWhy != nil {
|
||||
defer l.mu.Unlock()
|
||||
if l.lostWhy != nil {
|
||||
return l.lostWhy
|
||||
}
|
||||
return ErrNotHeld
|
||||
}
|
||||
revision, epoch, taken := l.revision, l.epoch, l.taken
|
||||
l.mu.Unlock()
|
||||
|
||||
h := l.o.Holder
|
||||
h.Epoch, h.Taken = epoch, taken
|
||||
anchor := l.o.Now()
|
||||
h.Renewed = anchor
|
||||
value, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return l.lose(err)
|
||||
}
|
||||
renewing, cancel := context.WithTimeout(ctx, l.o.RenewEvery)
|
||||
defer cancel()
|
||||
next, err := l.kv.Update(renewing, Key, value, revision)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
// Stopping, not losing: Release gives it back.
|
||||
return ctx.Err()
|
||||
}
|
||||
why := fmt.Errorf("the renewal at revision %d was refused: %w", revision, err)
|
||||
if !errors.Is(err, jetstream.ErrKeyExists) && !isWrongLast(err) {
|
||||
why = fmt.Errorf("the renewal could not be made: %w", err)
|
||||
}
|
||||
return l.lose(why)
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.revision, l.renewed = next, anchor
|
||||
l.validUntil = anchor.Add(l.ttl - l.o.Margin)
|
||||
l.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// isWrongLast is the bus refusing a compare-and-set because the key moved.
|
||||
func isWrongLast(err error) bool {
|
||||
var apiErr *jetstream.APIError
|
||||
return errors.As(err, &apiErr) && apiErr.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence
|
||||
}
|
||||
|
||||
// lose marks the lease lost, once, and says why.
|
||||
func (l *Lease) lose(why error) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.lostWhy == nil {
|
||||
l.lostWhy = why
|
||||
l.held = false
|
||||
close(l.lost)
|
||||
l.o.Say("LOST the controller lease (epoch %d): %v — this controller stops acting at once", l.epoch, why)
|
||||
}
|
||||
return l.lostWhy
|
||||
}
|
||||
|
||||
// Release gives the lease back, so the next candidate takes it at once rather than after its age.
|
||||
// Only the key this instance last wrote is deleted: one that moved is somebody else's.
|
||||
func (l *Lease) Release(ctx context.Context) {
|
||||
l.mu.Lock()
|
||||
held, revision, epoch := l.held && l.lostWhy == nil, l.revision, l.epoch
|
||||
l.held = false
|
||||
if l.lostWhy == nil {
|
||||
l.lostWhy = errors.New("given back")
|
||||
close(l.lost)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
if !held {
|
||||
return
|
||||
}
|
||||
releasing, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
err := l.kv.Delete(releasing, Key, jetstream.LastRevision(revision))
|
||||
if err != nil && isWrongLast(err) {
|
||||
// A renewal was in flight as this began and landed first: the key moved, and is still this
|
||||
// instance's if it still names it at this epoch. Deleted at the revision it has now.
|
||||
if entry, gerr := l.kv.Get(releasing, Key); gerr == nil {
|
||||
var h Holder
|
||||
if json.Unmarshal(entry.Value(), &h) == nil && h.Instance == l.o.Holder.Instance &&
|
||||
(h.Epoch == epoch || h.Epoch == 0 && entry.Revision() == epoch) {
|
||||
err = l.kv.Delete(releasing, Key, jetstream.LastRevision(entry.Revision()))
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
l.o.Say("the controller lease (epoch %d) could not be given back, so the next controller waits for "+
|
||||
"it to expire: %v", epoch, err)
|
||||
return
|
||||
}
|
||||
l.o.Say("gave the controller lease back (epoch %d)", epoch)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package lease
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Two controllers at once, on a real bus (novox/hq to-be 45 §6, replay R1's half that lives here):
|
||||
// one takes the lease and the other waits; a handover gives the second a higher epoch; a holder that
|
||||
// stops renewing is taken over once its key expires, and has stopped acting before then; a renewal
|
||||
// refused is a loss at once; and a bucket raised again from nothing never issues an epoch twice.
|
||||
//
|
||||
// MESH_TEST_NATS=nats://127.0.0.1:14222 go test ./internal/lease/
|
||||
|
||||
// The bounds in these tests are the design's divided by five, so the cases run in seconds.
|
||||
const (
|
||||
testTTL = 3 * time.Second
|
||||
testRenew = time.Second
|
||||
testPoll = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// aBucket is a fresh lease bucket on the test bus, with the test's age.
|
||||
func aBucket(t *testing.T) (jetstream.JetStream, string) {
|
||||
t.Helper()
|
||||
url := os.Getenv("MESH_TEST_NATS")
|
||||
if url == "" {
|
||||
t.Skip("MESH_TEST_NATS unset")
|
||||
}
|
||||
conn, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(conn.Close)
|
||||
js, err := jetstream.New(conn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := fmt.Sprintf("lease-test-%d", time.Now().UnixNano())
|
||||
if _, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: bucket, History: 1,
|
||||
TTL: testTTL, Storage: jetstream.FileStorage}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = js.DeleteKeyValue(context.Background(), bucket) })
|
||||
return js, bucket
|
||||
}
|
||||
|
||||
// aController is one controller instance's lease over the bucket, on a connection of its own.
|
||||
func aController(t *testing.T, bucket, name string, floor func(context.Context) (uint64, error)) *Lease {
|
||||
t.Helper()
|
||||
conn, err := nats.Connect(os.Getenv("MESH_TEST_NATS"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(conn.Close)
|
||||
js, err := jetstream.New(conn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var said sync.Mutex
|
||||
l, err := Open(t.Context(), js, bucket, Options{Holder: Holder{Instance: name, Host: name},
|
||||
RenewEvery: testRenew, Margin: testRenew / 2, Poll: testPoll, Floor: floor,
|
||||
Say: func(format string, args ...any) {
|
||||
said.Lock()
|
||||
defer said.Unlock()
|
||||
t.Logf(name+": "+format, args...)
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// takeWithin takes the lease in a goroutine and answers when it did, or fails past the bound.
|
||||
func takeWithin(t *testing.T, l *Lease) <-chan uint64 {
|
||||
t.Helper()
|
||||
took := make(chan uint64, 1)
|
||||
go func() {
|
||||
epoch, err := l.Take(t.Context())
|
||||
if err != nil {
|
||||
t.Errorf("taking: %v", err)
|
||||
close(took)
|
||||
return
|
||||
}
|
||||
took <- epoch
|
||||
}()
|
||||
return took
|
||||
}
|
||||
|
||||
func TestTheSecondControllerWaitsAndTakesAHigherEpochOnHandover(t *testing.T) {
|
||||
_, bucket := aBucket(t)
|
||||
a, b := aController(t, bucket, "a", nil), aController(t, bucket, "b", nil)
|
||||
|
||||
epochA, err := a.Take(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keeping, stop := context.WithCancel(t.Context())
|
||||
defer stop()
|
||||
go a.Keep(keeping)
|
||||
|
||||
// b waits while a renews — across more than one age of the key, so it is the renewals holding it.
|
||||
took := takeWithin(t, b)
|
||||
select {
|
||||
case epoch := <-took:
|
||||
t.Fatalf("b took the lease (epoch %d) while a held it and renewed", epoch)
|
||||
case <-time.After(testTTL + testRenew):
|
||||
}
|
||||
if _, err := b.Epoch(); !errors.Is(err, ErrNotHeld) {
|
||||
t.Fatalf("b, waiting, may act: %v", err)
|
||||
}
|
||||
if e, err := a.Epoch(); err != nil || e != epochA {
|
||||
t.Fatalf("a, holding, answers %d, %v", e, err)
|
||||
}
|
||||
holder, found, err := b.Current(t.Context())
|
||||
if err != nil || !found || holder.Instance != "a" || holder.Epoch != epochA {
|
||||
t.Fatalf("b reads the holder as %+v (%v, %v), want a at epoch %d", holder, found, err, epochA)
|
||||
}
|
||||
|
||||
// a gives it back: b takes it at once, not after the key's age, at a higher epoch.
|
||||
stop()
|
||||
handedOver := time.Now()
|
||||
a.Release(t.Context())
|
||||
select {
|
||||
case epochB := <-took:
|
||||
if epochB <= epochA {
|
||||
t.Fatalf("b took epoch %d after a's %d: an epoch must only grow", epochB, epochA)
|
||||
}
|
||||
if waited := time.Since(handedOver); waited > testTTL {
|
||||
t.Fatalf("b took the lease %s after a gave it back: it waited out the key's age", waited)
|
||||
}
|
||||
case <-time.After(2 * testTTL):
|
||||
t.Fatal("b never took the lease a gave back")
|
||||
}
|
||||
if _, err := a.Epoch(); !errors.Is(err, ErrNotHeld) {
|
||||
t.Fatalf("a, having given it back, may still act: %v", err)
|
||||
}
|
||||
if _, err := a.TryTake(t.Context()); err == nil {
|
||||
t.Fatal("a took the lease again after giving it back: its successor is a new candidate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHolderThatStopsRenewingStopsActingBeforeItIsTakenOver(t *testing.T) {
|
||||
_, bucket := aBucket(t)
|
||||
a, b := aController(t, bucket, "a", nil), aController(t, bucket, "b", nil)
|
||||
epochA, err := a.Take(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// a is stuck: it never renews (a process stopped, a goroutine wedged). It was not told anything.
|
||||
took := takeWithin(t, b)
|
||||
|
||||
// Its gate closes by its own clock, before the key can expire.
|
||||
closed := time.Now()
|
||||
for a.Held() {
|
||||
if time.Since(closed) > testTTL {
|
||||
t.Fatal("a still may act past its key's age without renewing")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
select {
|
||||
case epoch := <-took:
|
||||
t.Fatalf("b took the lease (epoch %d) before a stopped acting", epoch)
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case epochB := <-took:
|
||||
if epochB <= epochA {
|
||||
t.Fatalf("b took epoch %d after a's %d", epochB, epochA)
|
||||
}
|
||||
// And a, the moment it tries to renew, knows it lost: the key moved.
|
||||
if err := a.Renew(t.Context()); err == nil {
|
||||
t.Fatal("a renewed a lease b holds")
|
||||
}
|
||||
select {
|
||||
case <-a.Lost():
|
||||
default:
|
||||
t.Fatal("a's renewal was refused and a was not told it lost the lease")
|
||||
}
|
||||
if _, err := a.Epoch(); !errors.Is(err, ErrNotHeld) {
|
||||
t.Fatalf("a, having lost the lease, may act: %v", err)
|
||||
}
|
||||
case <-time.After(3 * testTTL):
|
||||
t.Fatal("b never took over a lease nobody renewed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARenewalRefusedIsALossAtOnce(t *testing.T) {
|
||||
js, bucket := aBucket(t)
|
||||
a := aController(t, bucket, "a", nil)
|
||||
if _, err := a.Take(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Somebody else writes the key — a second controller that read it as expired on a skewed clock,
|
||||
// or a person — so a's next renewal finds a revision it did not write.
|
||||
kv, err := js.KeyValue(t.Context(), bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put(t.Context(), Key, []byte(`{"instance":"intruder"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Renew(t.Context()); err == nil {
|
||||
t.Fatal("a renewed over a key somebody else wrote")
|
||||
}
|
||||
select {
|
||||
case <-a.Lost():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("a was not told it lost the lease")
|
||||
}
|
||||
if _, err := a.Epoch(); !errors.Is(err, ErrNotHeld) {
|
||||
t.Fatalf("a acts after its renewal was refused: %v", err)
|
||||
}
|
||||
// And giving back a lease it lost deletes nothing of the one who holds it now.
|
||||
a.Release(t.Context())
|
||||
if h, found, err := Current(t.Context(), kv); err != nil || !found || h.Instance != "intruder" {
|
||||
t.Fatalf("after a gave back what it lost, the key holds %+v (%v, %v)", h, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepingRenewsAcrossManyAges(t *testing.T) {
|
||||
_, bucket := aBucket(t)
|
||||
a := aController(t, bucket, "a", nil)
|
||||
epoch, err := a.Take(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keeping, stop := context.WithCancel(t.Context())
|
||||
defer stop()
|
||||
go a.Keep(keeping)
|
||||
until := time.Now().Add(3 * testTTL)
|
||||
for time.Now().Before(until) {
|
||||
if e, err := a.Epoch(); err != nil || e != epoch {
|
||||
t.Fatalf("a, renewing, answers %d, %v", e, err)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
h, found, err := a.Current(t.Context())
|
||||
if err != nil || !found || h.Epoch != epoch || h.Instance != "a" {
|
||||
t.Fatalf("the key says %+v (%v, %v)", h, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEpochIsNeverIssuedTwiceWhenTheBucketStartsOver(t *testing.T) {
|
||||
_, bucket := aBucket(t)
|
||||
// The mesh has issued epoch 500 before: its store says so. The bucket is new — a bus whose data
|
||||
// was replaced — and its revisions start at one.
|
||||
floor := func(context.Context) (uint64, error) { return 500, nil }
|
||||
a := aController(t, bucket, "a", floor)
|
||||
moved := false
|
||||
a.o.Moved = func(was, floor uint64) { moved = was < floor && floor == 500 }
|
||||
epoch, err := a.Take(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if epoch <= 500 {
|
||||
t.Fatalf("a took epoch %d with 500 already issued: every machine that heard 500 would refuse it", epoch)
|
||||
}
|
||||
if !moved {
|
||||
t.Fatal("the bucket's revisions were moved past the floor and nobody was told")
|
||||
}
|
||||
// A floor that cannot be read takes nothing: an epoch that may not be higher is not issued.
|
||||
_, other := aBucket(t)
|
||||
b := aController(t, other, "b", func(context.Context) (uint64, error) { return 0, errors.New("store away") })
|
||||
if _, err := b.TryTake(t.Context()); err == nil {
|
||||
t.Fatal("b took a lease without knowing the highest epoch issued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestALeaseBucketWithoutAnAgeIsRefused(t *testing.T) {
|
||||
url := os.Getenv("MESH_TEST_NATS")
|
||||
if url == "" {
|
||||
t.Skip("MESH_TEST_NATS unset")
|
||||
}
|
||||
conn, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
js, _ := jetstream.New(conn)
|
||||
bucket := fmt.Sprintf("lease-ageless-%d", time.Now().UnixNano())
|
||||
if _, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: bucket, History: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = js.DeleteKeyValue(context.Background(), bucket) }()
|
||||
if _, err := Open(t.Context(), js, bucket, Options{Holder: Holder{Instance: "a"}}); err == nil {
|
||||
t.Fatal("a lease over keys that never expire was opened: a controller that died holding it would hold it for ever")
|
||||
}
|
||||
}
|
||||
+32
-3
@@ -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:]
|
||||
|
||||
@@ -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"}},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Package lint holds the checks this repository runs over its own source (novox/hq to-be 45 Phase 2).
|
||||
//
|
||||
// **Empty on error** (ADR 0227 rule 4, "nothing is dropped silently"): a reader that cannot read and
|
||||
// answers an empty collection is a reader whose caller cannot tell *nothing is there* from *I could not
|
||||
// tell* — issue 241's unreadable contributions file read as "nobody asks", and seven databases were
|
||||
// dropped. EmptyOnError finds every error branch that answers an empty collection with no error; the
|
||||
// test beside it fails the build on each one that does not say, where it does it, why the empty answer
|
||||
// is the truth (a `// empty-on-error: <why>` comment on the return or the line above).
|
||||
package lint
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Allow is the comment that says, at the return, why an empty answer to an error is the truth.
|
||||
const Allow = "empty-on-error:"
|
||||
|
||||
// Finding is one error branch answering an empty collection.
|
||||
type Finding struct {
|
||||
File string
|
||||
Line int
|
||||
Func string
|
||||
// Allowed is the reason given where it was allowed; empty for a finding that is a failure.
|
||||
Allowed string
|
||||
}
|
||||
|
||||
func (f Finding) String() string { return fmt.Sprintf("%s:%d (%s)", f.File, f.Line, f.Func) }
|
||||
|
||||
// EmptyOnError walks every Go file under root but tests, vendor/ and testdata/, and answers every
|
||||
// return inside an `if <err> != nil` branch that answers an empty collection — nil, or a literal with no
|
||||
// elements, where the function answers a slice or a map — and no error: a nil error, or none declared.
|
||||
func EmptyOnError(root string) ([]Finding, error) {
|
||||
var out []Finding
|
||||
fset := token.NewFileSet()
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
switch d.Name() {
|
||||
case "vendor", "testdata", ".git", "node_modules":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s cannot be read: %w", path, err)
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
out = append(out, inFile(fset, file, rel)...)
|
||||
return nil
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
return out[i].Line < out[j].Line
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// inFile is every finding in one file.
|
||||
func inFile(fset *token.FileSet, file *ast.File, rel string) []Finding {
|
||||
// Every comment's text by the line it ends on, so an allowance is read on the return's line or the
|
||||
// line above it.
|
||||
comments := map[int]string{}
|
||||
for _, group := range file.Comments {
|
||||
for _, c := range group.List {
|
||||
comments[fset.Position(c.End()).Line] += c.Text
|
||||
}
|
||||
}
|
||||
var out []Finding
|
||||
var walk func(name string, results *ast.FieldList, body *ast.BlockStmt)
|
||||
walk = func(name string, results *ast.FieldList, body *ast.BlockStmt) {
|
||||
if body == nil {
|
||||
return
|
||||
}
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
switch n := n.(type) {
|
||||
case *ast.FuncLit:
|
||||
// Its returns are its own, judged against its own results.
|
||||
walk(name+" (a function inside it)", n.Type.Results, n.Body)
|
||||
return false
|
||||
case *ast.IfStmt:
|
||||
if !errNotNil(n.Cond) {
|
||||
return true
|
||||
}
|
||||
for _, ret := range returnsIn(n.Body) {
|
||||
if !emptyOnError(results, ret) {
|
||||
continue
|
||||
}
|
||||
line := fset.Position(ret.Pos()).Line
|
||||
f := Finding{File: rel, Line: line, Func: name}
|
||||
for _, l := range []int{line, line - 1} {
|
||||
if text, ok := comments[l]; ok {
|
||||
if _, why, found := strings.Cut(text, Allow); found && strings.TrimSpace(why) != "" {
|
||||
f.Allowed = strings.TrimSpace(why)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
for _, decl := range file.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := fn.Name.Name
|
||||
if fn.Recv != nil && len(fn.Recv.List) > 0 {
|
||||
name = "(" + exprString(fn.Recv.List[0].Type) + ")." + name
|
||||
}
|
||||
walk(name, fn.Type.Results, fn.Body)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// errNotNil is a condition `<x> != nil` where x is an error by its name: err, or one ending in Err.
|
||||
func errNotNil(cond ast.Expr) bool {
|
||||
found := false
|
||||
ast.Inspect(cond, func(n ast.Node) bool {
|
||||
b, ok := n.(*ast.BinaryExpr)
|
||||
if !ok || b.Op != token.NEQ {
|
||||
return true
|
||||
}
|
||||
x, xok := b.X.(*ast.Ident)
|
||||
y, yok := b.Y.(*ast.Ident)
|
||||
if xok && yok && y.Name == "nil" && (x.Name == "err" || strings.HasSuffix(x.Name, "Err")) {
|
||||
found = true
|
||||
}
|
||||
return !found
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// returnsIn is the returns of a branch, not those of a function literal inside it.
|
||||
func returnsIn(body *ast.BlockStmt) []*ast.ReturnStmt {
|
||||
var out []*ast.ReturnStmt
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
switch n := n.(type) {
|
||||
case *ast.FuncLit:
|
||||
return false
|
||||
case *ast.ReturnStmt:
|
||||
out = append(out, n)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// emptyOnError says a return answers an empty collection and no error, for a function's results.
|
||||
func emptyOnError(results *ast.FieldList, ret *ast.ReturnStmt) bool {
|
||||
if results == nil {
|
||||
return false
|
||||
}
|
||||
var types []ast.Expr
|
||||
for _, f := range results.List {
|
||||
n := len(f.Names)
|
||||
if n == 0 {
|
||||
n = 1
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
types = append(types, f.Type)
|
||||
}
|
||||
}
|
||||
if len(ret.Results) != len(types) {
|
||||
return false // a bare return of named results, or a call answering them: not judged here
|
||||
}
|
||||
empty := false
|
||||
for i, t := range types {
|
||||
value := ret.Results[i]
|
||||
if isError(t) {
|
||||
if !isNil(value) {
|
||||
return false // an error is answered: said, not dropped
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isCollection(t) && isEmpty(value) {
|
||||
empty = true
|
||||
}
|
||||
}
|
||||
return empty
|
||||
}
|
||||
|
||||
func isError(t ast.Expr) bool {
|
||||
id, ok := t.(*ast.Ident)
|
||||
return ok && id.Name == "error"
|
||||
}
|
||||
|
||||
func isCollection(t ast.Expr) bool {
|
||||
switch t := t.(type) {
|
||||
case *ast.ArrayType:
|
||||
return t.Len == nil
|
||||
case *ast.MapType:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isNil(e ast.Expr) bool {
|
||||
id, ok := e.(*ast.Ident)
|
||||
return ok && id.Name == "nil"
|
||||
}
|
||||
|
||||
func isEmpty(e ast.Expr) bool {
|
||||
if isNil(e) {
|
||||
return true
|
||||
}
|
||||
lit, ok := e.(*ast.CompositeLit)
|
||||
return ok && len(lit.Elts) == 0
|
||||
}
|
||||
|
||||
func exprString(e ast.Expr) string {
|
||||
switch e := e.(type) {
|
||||
case *ast.StarExpr:
|
||||
return "*" + exprString(e.X)
|
||||
case *ast.Ident:
|
||||
return e.Name
|
||||
case *ast.IndexExpr:
|
||||
return exprString(e.X)
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package lint
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The lint, over this repository (novox/hq to-be 45 Phase 2, ADR 0227 rule 4 "how it is checked"): every
|
||||
// error branch that answers an empty collection says why that is the truth, or the build fails naming it.
|
||||
func TestNoReaderAnswersEmptyForAnError(t *testing.T) {
|
||||
found, err := EmptyOnError("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed := 0
|
||||
for _, f := range found {
|
||||
if f.Allowed != "" {
|
||||
allowed++
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s answers an empty collection and no error when it could not read: refuse by name "+
|
||||
"(return the error), or say at the return why empty is the truth (// %s <why>)", f, Allow)
|
||||
}
|
||||
t.Logf("%d error branch(es) answer empty, each saying why", allowed)
|
||||
}
|
||||
|
||||
// The lint itself: what it finds, and what it does not.
|
||||
func TestTheLintFindsEmptyOnError(t *testing.T) {
|
||||
const src = `package x
|
||||
|
||||
func readContributions() []string {
|
||||
raw, err := read()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parse(raw)
|
||||
}
|
||||
|
||||
func consumers() ([]string, error) {
|
||||
if err := load(); err != nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func byName() (map[string]int, error) {
|
||||
if err := load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
func allowed() ([]string, error) {
|
||||
if err := load(); err != nil {
|
||||
// empty-on-error: a store not yet seeded holds no seats, and the caller keeps its defaults
|
||||
return nil, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func notFound() ([]string, error) {
|
||||
if errors.Is(err, ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func inside() {
|
||||
f := func() []int {
|
||||
if readErr != nil {
|
||||
return nil
|
||||
}
|
||||
return []int{1}
|
||||
}
|
||||
_ = f
|
||||
}
|
||||
`
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "x.go", src, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var failing, allowedFns []string
|
||||
for _, f := range inFile(fset, file, "x.go") {
|
||||
if f.Allowed != "" {
|
||||
allowedFns = append(allowedFns, f.Func)
|
||||
continue
|
||||
}
|
||||
failing = append(failing, f.Func)
|
||||
}
|
||||
want := "readContributions consumers inside (a function inside it)"
|
||||
if strings.Join(failing, " ") != want {
|
||||
t.Fatalf("the lint found %q, want %q", failing, want)
|
||||
}
|
||||
if strings.Join(allowedFns, " ") != "allowed" {
|
||||
t.Fatalf("the allowance was not read: %q", allowedFns)
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -1297,6 +1297,16 @@ type Declaration struct {
|
||||
// superseded.
|
||||
Sequence int64
|
||||
|
||||
// Epoch is the controller's lease epoch this declaration was sent under (novox/hq to-be 45 §6):
|
||||
// the revision at which the sending controller took the lease. A controller that lost its lease
|
||||
// and goes on sending sends an older epoch than the holder's, and the node-engine refuses what
|
||||
// is older than what it applied. Zero is a declaration from a controller without a lease — every
|
||||
// one sent before the lease existed — and carries no claim.
|
||||
//
|
||||
// Inside what is signed, beside the sequence, so a message cannot be given a newer epoch than
|
||||
// the controller gave it.
|
||||
Epoch int64
|
||||
|
||||
// LeftOut names the modules of this machine's set the mesh left out of this declaration,
|
||||
// because a setting stored for one cannot compose with its definition (novox/hq ADR 0163,
|
||||
// rule 6). A machine is told everything or nothing about what it IS told; this is what it is
|
||||
@@ -1462,6 +1472,10 @@ type envelope struct {
|
||||
// Sequence is optional on the wire, so a controller that does not send one is still
|
||||
// understood: absent reads as zero, which is "no ordering claimed" rather than "first".
|
||||
Sequence int64 `json:"sequence,omitempty"`
|
||||
// Epoch is optional on the wire as the sequence is: absent is a controller without a lease.
|
||||
// **An older host refuses this key**, decoding strictly; a controller sends it only to a host
|
||||
// whose reports carry a report sequence, which a host that reads it does.
|
||||
Epoch int64 `json:"epoch,omitempty"`
|
||||
// LeftOut is optional on the wire too, and absent when nothing was left out (ADR 0163).
|
||||
LeftOut []string `json:"left_out,omitempty"`
|
||||
}
|
||||
@@ -1481,8 +1495,14 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
}
|
||||
|
||||
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
|
||||
LeftOut: env.LeftOut}
|
||||
Epoch: env.Epoch, LeftOut: env.LeftOut}
|
||||
var problems []string
|
||||
if env.Sequence < 0 || env.Epoch < 0 {
|
||||
// Below zero is no order any controller assigns, and read as "none claimed" it would let the
|
||||
// declaration past every refusal of what is older.
|
||||
problems = append(problems, fmt.Sprintf("an order below zero (epoch %d, sequence %d) is not "+
|
||||
"one the mesh assigns", env.Epoch, env.Sequence))
|
||||
}
|
||||
if len(env.LeftOut) > 0 && allowActions {
|
||||
// The bundle is carried with the binary and leaves nothing out: which module a setting
|
||||
// stopped composing for is the mesh's record (ADR 0163).
|
||||
|
||||
Vendored
+2
-2
@@ -41,7 +41,7 @@ github.com/nats-io/nkeys
|
||||
# github.com/nats-io/nuid v1.0.1
|
||||
## explicit
|
||||
github.com/nats-io/nuid
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261006081854-6953b5bafdb2
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e
|
||||
## explicit; go 1.26.0
|
||||
github.com/novox/mesh-host/internal/declaration
|
||||
github.com/novox/mesh-host/validate
|
||||
@@ -83,4 +83,4 @@ golang.org/x/text/transform
|
||||
golang.org/x/text/unicode/bidi
|
||||
golang.org/x/text/unicode/norm
|
||||
golang.org/x/text/width
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261006081854-6953b5bafdb2
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e
|
||||
|
||||
Reference in New Issue
Block a user