Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
003eee8aa7 |
@@ -13,7 +13,6 @@ import (
|
||||
"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"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
@@ -73,14 +72,6 @@ type actor struct {
|
||||
// lease over in a second rather than in a renewal of five (novox/hq issue 591). A renewal is given
|
||||
// the lease's own RenewEvery to answer however often it is made, so a slow bus does not lose it.
|
||||
renewEvery, poll time.Duration
|
||||
// releasing is held for the whole of a release, so a second caller — the stop signal's and serve's own
|
||||
// deferred one — waits until the lease is given back and its epoch recorded, never past it to the store's
|
||||
// close (novox/hq issue 906).
|
||||
releasing sync.Mutex
|
||||
// keeper and inv are where a takeover is said (sayTakeover): set once the serving controller's condition
|
||||
// store opens; nil before, and in a command.
|
||||
keeper *conditions.Keeper
|
||||
inv *inventory.Inventory
|
||||
}
|
||||
|
||||
// theLease is this process's standing to the lease.
|
||||
@@ -183,11 +174,8 @@ func (a *actor) forACommand(ctx context.Context) (uint64, error) {
|
||||
return holder.Epoch, nil
|
||||
}
|
||||
|
||||
// release gives back what this process holds, at its end — or at the stop signal, before the rest of the
|
||||
// process is taken down (serveUnderTheLease). A second call waits for the first to finish.
|
||||
// release gives back what this process holds, at its end.
|
||||
func (a *actor) release() {
|
||||
a.releasing.Lock()
|
||||
defer a.releasing.Unlock()
|
||||
a.mu.Lock()
|
||||
closing := a.close
|
||||
a.close = nil
|
||||
@@ -278,40 +266,20 @@ func (a *actor) serveUnderTheLease(ctx context.Context, inv *inventory.Inventory
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.close = func() {
|
||||
// Given back first, then recorded: an epoch is recorded released only when its key is gone, and
|
||||
// whatever the gate says by then — a shutdown longer than half the key's age closed it, and the epoch
|
||||
// was left open for the next holder to find "expired" (novox/hq issue 906, 2026-10-26).
|
||||
if given := l.Release(context.Background()); given != 0 {
|
||||
if held, err := l.Epoch(); err == nil {
|
||||
ending, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
if err := inv.EndEpoch(ending, given, inventory.EpochReleased); err != nil {
|
||||
say("how epoch %d ended could not be recorded: %v", given, err)
|
||||
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()
|
||||
// **The stop signal gives the lease back first** (novox/hq issue 906): the moment ctx ends, before the rest
|
||||
// of the process is taken down, the gate closes and the key goes, so the next controller takes the lease
|
||||
// at once and the epoch is recorded released. Waiting for the process's own end left the key unrenewed for
|
||||
// as long as the shutdown took — ten seconds on 2026-10-26, past the gate's half-age.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
if a.holds(l) {
|
||||
say("stopping: the controller lease is given back first")
|
||||
}
|
||||
a.release()
|
||||
}()
|
||||
return gone, nil
|
||||
}
|
||||
|
||||
// holds is whether this process holds l now.
|
||||
func (a *actor) holds(l *lease.Lease) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.held == l
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -320,19 +288,15 @@ func (a *actor) took(ctx context.Context, l *lease.Lease, inv *inventory.Invento
|
||||
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()}, l.GivenBack())
|
||||
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)
|
||||
}
|
||||
if given := l.GivenBack(); given != 0 {
|
||||
fmt.Printf("the lease was given back by the controller of epoch %d: taken at once, at epoch %d\n", given, epoch)
|
||||
}
|
||||
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)
|
||||
}
|
||||
a.sayTakeover(ctx, epoch)
|
||||
go l.Keep(ctx)
|
||||
go func() {
|
||||
<-l.Lost()
|
||||
|
||||
@@ -78,8 +78,6 @@ func TestTheRegistryTrustAndEveryImageFollowThePortTheNodeGaveTheStore(t *testin
|
||||
|
||||
func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
// Issue 1126: the placement export over this fresh mesh, before anything below changes it.
|
||||
placementExportIsEachNodesPlacementFromTheStore(t, open)
|
||||
ctx := t.Context()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
@@ -113,11 +111,6 @@ func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T)
|
||||
Kind: inventory.BusController, Node: "anchor", Module: "mesh-controller"}, "bootstrap"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seeded with the credential the controller already uses, so it is sealed: an account counts as issued only once
|
||||
// sealed (ADR 0543 §2, PR #467), and genesis's seed is the credential in use.
|
||||
if err := open.inventory.MarkBusAccountSealed(ctx, "anchor.mesh-controller"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "mesh-controller"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -217,11 +217,6 @@ func TestTheAdaptersInputCarriesTheOwnSecretsTheBoxesTheLicencesAndTheBus(t *tes
|
||||
Module: "objects"}, "a-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Minted and sealed to its node: an account counts as issued, and is named among the minted, only once sealed
|
||||
// (ADR 0543 §2, PR #467).
|
||||
if err := inv.MarkBusAccountSealed(ctx, "anchor.objects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.PutBusMembership(ctx, "laptop", "sealed-membership"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/builderkeys"
|
||||
"github.com/novox/mesh-controller/internal/identity"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
@@ -77,18 +76,13 @@ func aComposingMesh(t *testing.T) *composingMesh {
|
||||
return m
|
||||
}
|
||||
|
||||
// trunkKeys are a done key and the indexer's trunk value naming it. The indexer holds that value at the trunk's commit
|
||||
// alone, as the real one does: answering it for any commit made a module registered at an older commit look already
|
||||
// registered at the trunk's fingerprint, so the take-in only moved its commit and recorded no build (issue 1945).
|
||||
// trunkKeys are a done key and the indexer's trunk value naming it.
|
||||
type trunkKeys struct {
|
||||
keyBuckets
|
||||
trunk sdkdelivery.Module
|
||||
}
|
||||
|
||||
func (k trunkKeys) ModuleAt(_ context.Context, _ string, commit string) (sdkdelivery.Module, error) {
|
||||
if commit != "" && !sameCommitID(commit, k.trunk.Commit) {
|
||||
return sdkdelivery.Module{}, builderkeys.ErrNoValue
|
||||
}
|
||||
func (k trunkKeys) ModuleAt(context.Context, string, string) (sdkdelivery.Module, error) {
|
||||
return k.trunk, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ func composeModulesByNode(ctx context.Context, open *stores, nodes []string) (ma
|
||||
}
|
||||
return u.Policy(), u.From, u.Why
|
||||
}
|
||||
// The memberships of each node's modules are issued by its composition (memberships.go): read once for the pass.
|
||||
issuing, issuingErr := membershipsFor(ctx, open, link.OverNATS{Conn: bus.Conn(), JS: bus.Context()})
|
||||
sort.Strings(nodes)
|
||||
out := map[string][]string{}
|
||||
errsOf := map[string][]error{}
|
||||
@@ -115,6 +117,13 @@ func composeModulesByNode(ctx context.Context, open *stores, nodes []string) (ma
|
||||
errsOf[node] = append(errsOf[node], fmt.Errorf("%s: %w", node, err))
|
||||
return
|
||||
}
|
||||
// Its modules' memberships, before its compositions (issue 249): a failure is the node's, and its compositions
|
||||
// are still written; the take-in that asked tries the node again.
|
||||
if issuingErr != nil {
|
||||
errsOf[node] = append(errsOf[node], fmt.Errorf("%s: %w", node, issuingErr))
|
||||
} else if err := issuing.issue(ctx, node, declared); err != nil {
|
||||
errsOf[node] = append(errsOf[node], fmt.Errorf("%s: %w", node, err))
|
||||
}
|
||||
// The provisioner's plans of the modules the node's composition hands to it, for the assignments mirror.
|
||||
if err := open.inventory.RecordComposedPlans(ctx, node, plansKept(declared)); err != nil {
|
||||
errsOf[node] = append(errsOf[node], fmt.Errorf("%s: its provisioner's plans: %w", node, err))
|
||||
|
||||
@@ -89,28 +89,15 @@ func TestTwoControllersOneActs(t *testing.T) {
|
||||
t.Fatalf("B, waiting, may act: %v", err)
|
||||
}
|
||||
|
||||
// A is stopped, as its service manager stops a controller being replaced (novox/hq issue 906): the stop
|
||||
// alone gives the lease back, before anything else of A is taken down, and B takes it within a second, at
|
||||
// a higher epoch — no wait for the key's age, and no condition said.
|
||||
// A hands over, as a controller being replaced does: B takes the lease at once, at a higher epoch.
|
||||
stopA()
|
||||
stopped := time.Now()
|
||||
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")
|
||||
}
|
||||
if waited := time.Since(stopped); waited > time.Second {
|
||||
t.Fatalf("B took the lease %s after A was stopped: the stop did not give it back first", waited)
|
||||
}
|
||||
a.release() // what serve's own end does after: waits for the release under way, gives nothing twice
|
||||
conditionsOfB := conditions.NewInMemory()
|
||||
keeperOfB := conditions.NewKeeper(ctx, conditions.Options{Store: conditionsOfB, History: conditionsOfB})
|
||||
b.watchTakeovers(ctx, inv, keeperOfB)
|
||||
keeperOfB.Close(ctx)
|
||||
if said, err := conditionsOfB.Since(ctx, stopped.Add(-time.Minute)); err != nil || len(said) != 0 {
|
||||
t.Fatalf("a lease given back is said as a condition: %+v, %v", said, err)
|
||||
}
|
||||
select {
|
||||
case <-lostA:
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// **A node's composition issues its modules' memberships** (novox/hq ADR 0160, ADR 0201; issue 249 met again under
|
||||
// issue 1945).
|
||||
//
|
||||
// A membership is what an assignment serves and reaches: its subjects, its seats, and the state, logs, streams and waits
|
||||
// its manifest declares, published last-per-subject on the AssignmentsStream, where the tool runner and every bus process
|
||||
// read and follow their own. A push issued them before it sent a declaration; release 6 removed the push and nothing
|
||||
// issued them after, so a module taken in with a new state, read or stream was granted it on the bus and refused it by its
|
||||
// runtime, which keeps a module to the membership last issued (2026-10-25: mesh-delivery-gate's `conditions`, on novox
|
||||
// from 20:33). Composing a node is now where its memberships are issued: from the same records the bus's grants are
|
||||
// composed from (so what a runtime serves and what its account may are one composition), with the private network's
|
||||
// addresses and what each module receives from the node's composition, before its compositions are written (the code
|
||||
// arriving first was refused its own state, issue 249).
|
||||
//
|
||||
// Only a membership that differs from the one last issued is published, and only over that one, so a pass that changes
|
||||
// nothing re-serves nothing and a newer one issued meanwhile is not overwritten (issues 930, 934).
|
||||
|
||||
// membershipPass is what one composer pass issues every node's memberships from, read once for the pass.
|
||||
type membershipPass struct {
|
||||
records broker.Records
|
||||
where broker.Placements
|
||||
bus link.OverNATS
|
||||
}
|
||||
|
||||
// membershipsFor reads the records, asserts the memberships' stream and every declared bucket and log (a membership
|
||||
// names them, and a module whose state does not exist fails its first read), and judges which machines are root-free
|
||||
// (ADR 0259 §8: a channel's verified sender is composed only from one) when a seat promises one, as a push did.
|
||||
func membershipsFor(ctx context.Context, open *stores, bus link.OverNATS) (*membershipPass, error) {
|
||||
records, err := open.inventory.BusRecords(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the memberships are not issued: %w", err)
|
||||
}
|
||||
raiser := broker.OnConn(bus.Conn)
|
||||
for _, s := range broker.MeshStreams() {
|
||||
if s.Name != broker.AssignmentsStream {
|
||||
continue
|
||||
}
|
||||
if err := raiser.EnsureStream(s); err != nil {
|
||||
return nil, fmt.Errorf("the memberships are not issued: their stream: %w", err)
|
||||
}
|
||||
}
|
||||
buckets, err := open.inventory.DeclaredBuckets(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the memberships are not issued: the modules' state: %w", err)
|
||||
}
|
||||
if _, err := broker.RaiseBuckets(raiser, buckets); err != nil {
|
||||
return nil, fmt.Errorf("the memberships are not issued: the modules' state could not be asserted: %w", err)
|
||||
}
|
||||
logs, err := open.inventory.DeclaredLogs(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the memberships are not issued: the modules' logs: %w", err)
|
||||
}
|
||||
if _, err := broker.RaiseLogs(raiser, logs); err != nil {
|
||||
return nil, fmt.Errorf("the memberships are not issued: the modules' logs could not be asserted: %w", err)
|
||||
}
|
||||
var whys map[string]string
|
||||
if promisesVerifiedSender(records) {
|
||||
records.RootFree, whys = rootVerdictsOf(judgeRootFree(ctx, newRootReader(ctx, open.inventory, bus.Conn),
|
||||
records.Nodes, time.Now()))
|
||||
}
|
||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||
for _, line := range droppedVerifiedSenders(records, where, whys) {
|
||||
fmt.Println(line)
|
||||
}
|
||||
return &membershipPass{records: records, where: where, bus: bus}, nil
|
||||
}
|
||||
|
||||
// promisesVerifiedSender says a seat some module holds promises a verified sender: only then is the root-free judgement,
|
||||
// a discovery on the bus, worth its seconds. Pure.
|
||||
func promisesVerifiedSender(records broker.Records) bool {
|
||||
for _, declared := range records.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, s := range d.Holds {
|
||||
if slices.Contains(s.Capabilities, "verified-sender") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// issue issues node's memberships on the bus, from its composition's addresses and received contributions.
|
||||
func (p *membershipPass) issue(ctx context.Context, node string, declared sendable) error {
|
||||
issued, err := issueComposedMemberships(node, p.records, p.where, declared.Mesh, declared.Received,
|
||||
func(node, module string) ([]byte, uint64, error) {
|
||||
m, err := p.bus.JS.GetLastMsg(broker.AssignmentsStream, broker.MembershipSubject(node, module), nats.Context(ctx))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return m.Data, m.Sequence, nil
|
||||
},
|
||||
func(node, module string, body []byte, seq uint64) error {
|
||||
return p.bus.PublishMembershipAfter(ctx, node, module, body, seq)
|
||||
})
|
||||
if len(issued) > 0 {
|
||||
fmt.Printf("memberships: %s: issued %s\n", node, strings.Join(issued, ", "))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// issueComposedMemberships composes the membership of every module assigned to node and publishes each one that differs
|
||||
// from the one last issued, over that one (seq 0 when none was: only if none was issued meanwhile). It answers the
|
||||
// modules it issued; every failure is tried past and joined in the error.
|
||||
func issueComposedMemberships(node string, records broker.Records, where broker.Placements, mesh []string,
|
||||
received map[string]map[string][]catalogue.Contribution,
|
||||
last func(node, module string) ([]byte, uint64, error),
|
||||
publish func(node, module string, body []byte, seq uint64) error) ([]string, error) {
|
||||
var issued []string
|
||||
var errs []error
|
||||
for _, d := range records.Assigned[node] {
|
||||
body, err := composedMembership(node, d, where, mesh, received[d.Module])
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s on %s: its membership: %w", d.Module, node, err))
|
||||
continue
|
||||
}
|
||||
was, seq, err := last(node, d.Module)
|
||||
switch {
|
||||
case errors.Is(err, nats.ErrMsgNotFound):
|
||||
was, seq = nil, 0
|
||||
case err != nil:
|
||||
errs = append(errs, fmt.Errorf("%s on %s: its membership as last issued could not be read: %w", d.Module, node, err))
|
||||
continue
|
||||
}
|
||||
if sameMembership(was, body) {
|
||||
continue
|
||||
}
|
||||
if err := publish(node, d.Module, body, seq); err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s on %s: its membership could not be issued: %w", d.Module, node, err))
|
||||
continue
|
||||
}
|
||||
issued = append(issued, d.Module)
|
||||
}
|
||||
sort.Strings(issued)
|
||||
return issued, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// composedMembership is one assignment's membership as a node's composition issues it: MembershipFor, with the private
|
||||
// network's addresses and what the module receives, by requirement (ADR 0167).
|
||||
func composedMembership(node string, d broker.Declared, where broker.Placements, mesh []string,
|
||||
received map[string][]catalogue.Contribution) ([]byte, error) {
|
||||
m := broker.MembershipFor(node, d, where)
|
||||
m.Mesh = mesh
|
||||
for requirement, given := range received {
|
||||
raw, err := json.Marshal(given)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Receives == nil {
|
||||
m.Receives = map[string]json.RawMessage{}
|
||||
}
|
||||
m.Receives[requirement] = raw
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// sameMembership says two memberships say the same, whatever the order of their fields. Pure.
|
||||
func sameMembership(was, now []byte) bool {
|
||||
if was == nil {
|
||||
return false
|
||||
}
|
||||
var a, b any
|
||||
if json.Unmarshal(was, &a) != nil || json.Unmarshal(now, &b) != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
@@ -133,14 +133,9 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
// refuse (novox/hq issue 358). Read-only on the node and here, so a verb may run it too.
|
||||
return nodeJudgeDirectories(ctx, open, args[1:])
|
||||
|
||||
case "placement-export":
|
||||
// Each node's placement as the catalogue's placement file says it (novox/hq ADR 0555 §6): read-only, so a verb
|
||||
// may run it.
|
||||
return placementExport(ctx, open, args[1:])
|
||||
|
||||
default:
|
||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account, hand-over, "+
|
||||
"setuid-search, judge-directories and placement-export", args[0])
|
||||
"setuid-search and judge-directories", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// placementExport prints each node's placement as the catalogue's placement file says it (novox/hq ADR 0555 §6, issue
|
||||
// 1126): what a person decided of the node and where its modules run, read by this controller's own code from its own
|
||||
// store, as JSON keyed by node. The one verb the controller gains for its exit (the operator, 2026-10-26 19:06:40, ADR
|
||||
// 0547 §1's one exception besides §5's grant): read once by the seed of the placement files, whose pull request merges
|
||||
// only when its composition check composes every node equal to what it runs. It reads the same Input every composition
|
||||
// of this controller reads (readMeshAndNodes, readSettings) and writes nothing.
|
||||
//
|
||||
// It moves a piece of the research "The controller, NATS-native": the controller's last node data (node rows, placement,
|
||||
// ports, pins, bindings, config placement) leaves it for the catalogue, before its exit at ADR 0547's third milestone.
|
||||
func placementExport(ctx context.Context, open *stores, args []string) error {
|
||||
if len(args) > 1 {
|
||||
return errors.New("node placement-export [<name>]")
|
||||
}
|
||||
in, _, err := readMeshAndNodes(ctx, open)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := readSettings(ctx, open, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
var names []string
|
||||
if len(args) == 1 {
|
||||
names = []string{args[0]}
|
||||
} else {
|
||||
for _, n := range in.Nodes {
|
||||
names = append(names, n.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
}
|
||||
out := make(map[string]delivery.Placement, len(names))
|
||||
for _, name := range names {
|
||||
p, err := frombus.Export(in, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[name] = p
|
||||
}
|
||||
return printJSON(out)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// Issue 1126 (novox/hq ADR 0555 §6, the operator's one export verb of 2026-10-26 19:06:40): `node placement-export`
|
||||
// answers, from the real store, each node's placement as the catalogue's placement file says it: what a person decided
|
||||
// of the node (its endpoint, site, hub and address on the private network) and its modules in the order the composition
|
||||
// reads them, exactly frombus.Export of the input this controller composes from; one node when named; a node the store
|
||||
// lacks is refused. Checked within TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker, over the same fresh
|
||||
// mesh, so the export adds no integration test of its own (the test budget).
|
||||
func placementExportIsEachNodesPlacementFromTheStore(t *testing.T, open *stores) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
in, _, err := readMeshAndNodes(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readSettings(ctx, open, &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var all map[string]sdkdelivery.Placement
|
||||
if err := json.Unmarshal([]byte(stdoutOf(t, func() error { return placementExport(ctx, open, nil) })), &all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("exported %d nodes, want anchor and laptop", len(all))
|
||||
}
|
||||
for i, name := range []string{"anchor", "laptop"} {
|
||||
got := all[name]
|
||||
d := got.Decided
|
||||
if d.Endpoint != name+".example:51820" || d.Site != "here" || d.Hub != (name == "anchor") || d.Address != []string{"10.77.0.1", "10.77.0.2"}[i] {
|
||||
t.Errorf("%s's decided facts are %+v", name, d)
|
||||
}
|
||||
if a := got.Assigned(); len(a) == 0 || !strings.Contains(strings.Join(a, ","), overlay.Name) {
|
||||
t.Errorf("%s's modules are %v, want the private network among them", name, a)
|
||||
}
|
||||
// What it prints is the file the seed writes: at the placement file's version, read back by its reader.
|
||||
if raw, _ := json.Marshal(got); got.V != sdkdelivery.Version {
|
||||
t.Errorf("%s is exported at version %d: %s", name, got.V, raw)
|
||||
} else if _, err := sdkdelivery.DecodePlacement(raw); err != nil {
|
||||
t.Errorf("%s's export does not read as a placement file: %v", name, err)
|
||||
}
|
||||
want, err := frombus.Export(in, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sdkdelivery.SamePlacement(got, want) {
|
||||
t.Errorf("%s's export differs from frombus.Export of the controller's own input:\n%+v\n%+v", name, got, want)
|
||||
}
|
||||
}
|
||||
var one map[string]sdkdelivery.Placement
|
||||
if err := json.Unmarshal([]byte(stdoutOf(t, func() error { return placementExport(ctx, open, []string{"laptop"}) })), &one); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := one["laptop"]; !ok || len(one) != 1 {
|
||||
t.Errorf("one node named exported %v", one)
|
||||
}
|
||||
if err := placementExport(ctx, open, []string{"stranger"}); err == nil {
|
||||
t.Error("a node the store lacks was exported")
|
||||
}
|
||||
}
|
||||
@@ -180,9 +180,6 @@ func serve(ctx context.Context) (err error) {
|
||||
if keeper != nil {
|
||||
defer closeConditions(keeper)
|
||||
defer stopRunning() // on an error return, the goroutines end before the keeper closes
|
||||
// The takeover of the lease said as it happened, raised and cleared (novox/hq issue 906).
|
||||
theLease.watchTakeovers(ctx, inv, keeper)
|
||||
defer theLease.watchTakeovers(ctx, nil, nil) // the keeper closes after this: nothing said into it then
|
||||
}
|
||||
|
||||
// The bus's own objects, asserted on every start. **Not created once at genesis**: a stream
|
||||
|
||||
@@ -108,14 +108,83 @@ func TestADroppedVerifiedSenderIsSaidWithItsReason(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMembershipNeverIssuedIsLeftToItsOwnSend(t *testing.T) {
|
||||
records, _ := verifiedSenderRecords(map[string]bool{"novox": true})
|
||||
bus := &fakeBus{last: map[string][]byte{}, published: map[string][]byte{}, seq: map[string]uint64{}}
|
||||
var said []string
|
||||
n := reissueVerifiedSenders(records, broker.PlacementsOf(records, nil), bus.get, bus.put,
|
||||
func(f string, a ...any) { said = append(said, f) })
|
||||
if n != 0 || len(said) != 0 {
|
||||
t.Fatalf("issued %d, said %v", n, said)
|
||||
// **A node's composition issues every membership that changed** (issue 249 met again under issue 1945): release 6 removed
|
||||
// the push that issued memberships, and mesh-delivery-gate taken in with a new state `conditions` was granted its bucket and
|
||||
// refused it by the tool runner, which reads the membership last issued (2026-10-25, novox). The self-check's reissue
|
||||
// leaves one never issued alone: the composition issues it.
|
||||
func TestAComposedNodeIssuesEveryMembershipThatChanged(t *testing.T) {
|
||||
gate := func(states ...string) broker.Declared {
|
||||
d := broker.Declared{Module: "mesh-delivery-gate"}
|
||||
for _, s := range states {
|
||||
d.State = append(d.State, broker.Bucket{Module: "mesh-delivery-gate", Name: s})
|
||||
}
|
||||
return d
|
||||
}
|
||||
records := func(d broker.Declared) broker.Records {
|
||||
return broker.Records{Nodes: []string{"novox"}, Assigned: map[string][]broker.Declared{"novox": {d}}}
|
||||
}
|
||||
issuedAs := func(d broker.Declared, mesh []string) []byte {
|
||||
body, err := composedMembership("novox", d, broker.PlacementsOf(records(d), nil), mesh, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
newer := []byte(`{"node":"novox","module":"mesh-delivery-gate","newer":true}`)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
before []byte // the membership last issued, nil for none
|
||||
between bool // another issued meanwhile, after the read
|
||||
issued []string
|
||||
states []string // what the bus holds after, by state name; nil when it must hold newer
|
||||
err string
|
||||
}{
|
||||
{name: "never issued is issued", issued: []string{"mesh-delivery-gate"}, states: []string{"asks", "conditions"}},
|
||||
{name: "a new state is issued", before: issuedAs(gate("asks"), []string{"10.0.0.1"}),
|
||||
issued: []string{"mesh-delivery-gate"}, states: []string{"asks", "conditions"}},
|
||||
{name: "unchanged is not issued again", before: issuedAs(gate("asks", "conditions"), []string{"10.0.0.1"}),
|
||||
states: []string{"asks", "conditions"}},
|
||||
{name: "a newer one meanwhile is not overwritten", before: issuedAs(gate("asks"), []string{"10.0.0.1"}),
|
||||
between: true, err: "wrong last sequence"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := records(gate("asks", "conditions"))
|
||||
where := broker.PlacementsOf(r, nil)
|
||||
bus := &fakeBus{last: map[string][]byte{}, published: map[string][]byte{}, seq: map[string]uint64{}}
|
||||
if c.before != nil {
|
||||
bus.last["novox/mesh-delivery-gate"], bus.seq["novox/mesh-delivery-gate"] = c.before, 1
|
||||
}
|
||||
if c.between {
|
||||
bus.between = func() { bus.last["novox/mesh-delivery-gate"], bus.seq["novox/mesh-delivery-gate"] = newer, 2 }
|
||||
}
|
||||
if n := reissueVerifiedSenders(r, where, bus.get, bus.put, func(string, ...any) {}); n != 0 {
|
||||
t.Fatalf("the self-check's reissue issued %d", n)
|
||||
}
|
||||
issued, err := issueComposedMemberships("novox", r, where, []string{"10.0.0.1"}, nil, bus.get, bus.put)
|
||||
if c.err == "" && err != nil || c.err != "" && (err == nil || !strings.Contains(err.Error(), c.err)) {
|
||||
t.Fatalf("error %v, want %q", err, c.err)
|
||||
}
|
||||
if strings.Join(issued, ",") != strings.Join(c.issued, ",") {
|
||||
t.Fatalf("issued %v, want %v", issued, c.issued)
|
||||
}
|
||||
if c.states == nil {
|
||||
if string(bus.last["novox/mesh-delivery-gate"]) != string(newer) {
|
||||
t.Fatalf("the newer membership was overwritten: %s", bus.last["novox/mesh-delivery-gate"])
|
||||
}
|
||||
return
|
||||
}
|
||||
var m broker.Membership
|
||||
if err := json.Unmarshal(bus.last["novox/mesh-delivery-gate"], &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range m.State {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if strings.Join(names, ",") != strings.Join(c.states, ",") || strings.Join(m.Mesh, ",") != "10.0.0.1" {
|
||||
t.Fatalf("the bus holds states %v and mesh %v, want %v", names, m.Mesh, c.states)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -349,11 +349,6 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
return []string{"node", "judge-directories", str("node")}, nil
|
||||
case "placement-export":
|
||||
if node := str("node"); node != "" {
|
||||
return []string{"node", "placement-export", node}, nil
|
||||
}
|
||||
return []string{"node", "placement-export"}, nil
|
||||
case "modules":
|
||||
return []string{"module", "list", "--json"}, nil
|
||||
case "seats":
|
||||
@@ -1405,9 +1400,7 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
|
||||
// nodeReads are the `node` subcommands a verb may run: the ones that only read.
|
||||
var nodeReads = map[string]bool{"list": true, "show": true,
|
||||
// Read-only on the node too: the engine judges its directories and changes nothing (novox/hq issue 358).
|
||||
"judge-directories": true,
|
||||
// Each node's placement as the catalogue's placement file says it, read from the store (novox/hq ADR 0555 §6).
|
||||
"placement-export": true}
|
||||
"judge-directories": true}
|
||||
|
||||
// flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command
|
||||
// with no subcommands every word is a flag, its value or a name it reads.
|
||||
|
||||
+15
-113
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -148,10 +147,9 @@ 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 (the key's age); a holder that lost the lease or stopped renewing it is said once by the " +
|
||||
"controller that took it over, raised and cleared as it takes it, and for an hour after while none " +
|
||||
"holds it; a lease given back is never said; a lease bucket found raised again from nothing is said " +
|
||||
"for an hour after; a controller serving without the lease, for as long as it does",
|
||||
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 }},
|
||||
@@ -646,29 +644,10 @@ func watchLease(f *signalFacts) []conditions.Observation {
|
||||
"does not know it lost it",
|
||||
Said: l.resetSaid})
|
||||
}
|
||||
// An epoch below the one this controller holds was taken over by it: said once by the takeover itself,
|
||||
// raised and cleared (sayTakeover), not observed for an hour after (novox/hq issue 906). One that ended
|
||||
// while this controller does not hold the lease — serving without it — is still said for the hour.
|
||||
var ended []inventory.Epoch
|
||||
for _, e := range l.ended {
|
||||
if l.held && e.Epoch < l.epoch {
|
||||
continue
|
||||
}
|
||||
ended = append(ended, e)
|
||||
}
|
||||
if o, ok := lostObservation(ended, l.epoch, f.now); ok {
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lostObservation is the lease-lost condition of the epochs that lost the lease or let it expire within the
|
||||
// hour, named by the newest; false when none did. An epoch given back is no loss.
|
||||
func lostObservation(ended []inventory.Epoch, acting uint64, now time.Time) (conditions.Observation, bool) {
|
||||
var lost []string
|
||||
newest := inventory.Epoch{}
|
||||
for _, e := range ended {
|
||||
if e.Ended == nil || e.How == inventory.EpochReleased || now.Sub(*e.Ended) > advisoryQuiet {
|
||||
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,
|
||||
@@ -677,95 +656,18 @@ func lostObservation(ended []inventory.Epoch, acting uint64, now time.Time) (con
|
||||
newest = e
|
||||
}
|
||||
}
|
||||
if len(lost) == 0 {
|
||||
return conditions.Observation{}, false
|
||||
}
|
||||
how := "lost it: another holder wrote its key, or no renewal was made within the key's age"
|
||||
if newest.How == inventory.EpochExpired {
|
||||
how = "stopped renewing it without giving it back, and was taken over"
|
||||
}
|
||||
return conditions.Observation{Scope: conditions.ScopeCore, ID: "controller.lease", Token: "lost",
|
||||
Kind: "lease-lost", Machine: newest.Host, Severity: conditions.Urgent, Source: "S12",
|
||||
Summary: fmt.Sprintf("the controller of epoch %d (%s) %s; the controller of epoch %d acts now", newest.Epoch,
|
||||
newest.Instance, how, acting),
|
||||
Said: strings.Join(lost, "; ")}, true
|
||||
}
|
||||
|
||||
// takeover is the condition a takeover says: the epoch just before the one taken, when it lost the lease or
|
||||
// let it expire; false when it gave it back, or none is recorded.
|
||||
func takeover(ended []inventory.Epoch, taken uint64, now time.Time) (conditions.Observation, bool) {
|
||||
var before *inventory.Epoch
|
||||
for i, e := range ended {
|
||||
if e.Epoch < taken && (before == nil || e.Epoch > before.Epoch) {
|
||||
before = &ended[i]
|
||||
if len(lost) > 0 {
|
||||
how := "lost it: another holder wrote its key, or no renewal was made within the key's age"
|
||||
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, "; ")})
|
||||
}
|
||||
if before == nil {
|
||||
return conditions.Observation{}, false
|
||||
}
|
||||
return lostObservation([]inventory.Epoch{*before}, taken, now)
|
||||
}
|
||||
|
||||
// sayTakeover is the takeover said as an event (novox/hq issue 906): when the controller before this one lost
|
||||
// the lease or let it expire, the lease-lost condition is raised and cleared at once by this controller, which
|
||||
// holds the lease — the operator reads that it happened and that it is over, not an urgent alarm for an hour.
|
||||
// A lease given back says nothing. Said once the condition store is open; a take before that is said then.
|
||||
func (a *actor) sayTakeover(ctx context.Context, epoch uint64) {
|
||||
a.mu.Lock()
|
||||
keeper, inv := a.keeper, a.inv
|
||||
a.mu.Unlock()
|
||||
if keeper == nil || inv == nil || epoch == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
reading, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
ended, err := inv.EpochsSince(reading, now.Add(-advisoryQuiet))
|
||||
if err != nil {
|
||||
// Failure is loud: S12 no longer observes an epoch below the one held, so an unread takeover is said as
|
||||
// one that was not a handover — raised, and left for S12 to clear (ADR 0558 §3).
|
||||
fmt.Printf("how the epoch before %d ended cannot be read, so its takeover is said as an expiry: %v\n", epoch, err)
|
||||
o := conditions.Observation{Scope: conditions.ScopeCore, ID: "controller.lease", Token: "lost",
|
||||
Kind: "lease-lost", Severity: conditions.Urgent, Source: "S12",
|
||||
Summary: fmt.Sprintf("the controller of epoch %d took the lease, and how the controller before it ended "+
|
||||
"cannot be read: it is said as one that stopped renewing without giving it back", epoch),
|
||||
Said: err.Error()}
|
||||
if _, err := keeper.Observe(reading, o); err != nil {
|
||||
fmt.Printf("the takeover at epoch %d could not be said: %v\n", epoch, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
sayTakeoverOf(reading, keeper, ended, epoch, now)
|
||||
}
|
||||
|
||||
// sayTakeoverOf raises and clears the takeover's condition over the epochs read.
|
||||
func sayTakeoverOf(ctx context.Context, keeper *conditions.Keeper, ended []inventory.Epoch, epoch uint64, now time.Time) {
|
||||
o, ok := takeover(ended, epoch, now)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := keeper.Observe(ctx, o); err != nil {
|
||||
fmt.Printf("the takeover at epoch %d could not be said: %v\n", epoch, err)
|
||||
return
|
||||
}
|
||||
if _, err := keeper.Clear(ctx, o.Key(), fmt.Sprintf("taken over: the controller of epoch %d holds the lease", epoch)); err != nil {
|
||||
fmt.Printf("the takeover at epoch %d was said and could not be cleared: %v\n", epoch, err)
|
||||
}
|
||||
}
|
||||
|
||||
// watchTakeovers gives the serving controller's condition store to the lease, and says the takeover of the
|
||||
// epoch it holds, if it took one over before the store opened.
|
||||
func (a *actor) watchTakeovers(ctx context.Context, inv *inventory.Inventory, keeper *conditions.Keeper) {
|
||||
a.mu.Lock()
|
||||
a.keeper, a.inv = keeper, inv
|
||||
held := a.held
|
||||
a.mu.Unlock()
|
||||
if held == nil {
|
||||
return
|
||||
}
|
||||
if epoch, err := held.Epoch(); err == nil {
|
||||
a.sayTakeover(ctx, epoch)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handActsWithin is how far back a repeated cause counts (S15).
|
||||
|
||||
@@ -491,41 +491,13 @@ func TestAStaleWriterIsNamedByItsEpoch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **The lease lost is said for an hour while nobody took it over, a takeover is said once as it happens —
|
||||
// raised and cleared by the controller that took it — a lease given back is never said, and serving without
|
||||
// it for as long as it lasts** (S12, novox/hq issue 906).
|
||||
// **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}}
|
||||
// Held at epoch 57, above it: the takeover said it, and no hour of observation follows.
|
||||
if got := watchLease(f); len(got) != 0 {
|
||||
t.Fatalf("an epoch this controller took over is observed after the takeover: %+v", got)
|
||||
}
|
||||
store := conditions.NewInMemory()
|
||||
k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store, Now: func() time.Time { return now }})
|
||||
sayTakeoverOf(t.Context(), k, f.lease.ended, 57, now)
|
||||
if open, err := k.Open(t.Context()); err != nil || len(open) != 0 {
|
||||
t.Fatalf("the takeover is left open: %+v, %v", open, err)
|
||||
}
|
||||
k.Close(t.Context()) // its transitions written to the history
|
||||
history, err := store.Since(t.Context(), now.Add(-time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var changes []string
|
||||
for _, e := range history {
|
||||
if e.Key == "core.controller.lease.lost" {
|
||||
changes = append(changes, e.Change)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(changes, []string{"raised", "cleared"}) {
|
||||
t.Fatalf("the takeover of an expired lease is not raised and cleared: %v", changes)
|
||||
}
|
||||
// Not holding the lease, the loss is said for the hour.
|
||||
f.lease.held = false
|
||||
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 {
|
||||
@@ -540,9 +512,6 @@ func TestALeaseLostOrMissingIsSaid(t *testing.T) {
|
||||
if got := watchLease(f); len(got) != 0 {
|
||||
t.Fatalf("a lease given back is said as lost: %+v", got)
|
||||
}
|
||||
if _, said := takeover(f.lease.ended, 57, now); said {
|
||||
t.Fatal("the takeover of a lease given back is said")
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYgA9TCv+4K8JgaURg3zQppvUgJLaDMP4=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc h1:Wdjl2gomooHswJ5qpkwZPq/ureZgXVw6FMCsgzPJhPo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde h1:eKXSllHCS31GgMazfvZ7BeiLoXIUibnUmzxWVkj+Vcw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f h1:lUEyNPPF3w4Q0LIgcUUD6ofkKVhCF0n7dVjMoixQmXo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -46,8 +46,9 @@ func TestAConsumerMadeFromNowNeverReplaysTheStreamsHistory(t *testing.T) {
|
||||
t.Fatalf("a consumer made from now holds %d of the stream's past (policy %v)", info.NumPending, info.Config.DeliverPolicy)
|
||||
}
|
||||
_, _ = js.js.Publish("history.merged", []byte("new"))
|
||||
if got := pendingOf(js, stream, "fresh", 1); got != 1 {
|
||||
t.Fatalf("a new announcement is not pending: %d", got)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if info, _ = js.js.ConsumerInfo(stream, "fresh"); info.NumPending != 1 {
|
||||
t.Fatalf("a new announcement is not pending: %d", info.NumPending)
|
||||
}
|
||||
// Asserted again, it keeps where it is.
|
||||
if err := js.EnsureConsumer(fresh); err != nil {
|
||||
@@ -60,8 +61,8 @@ func TestAConsumerMadeFromNowNeverReplaysTheStreamsHistory(t *testing.T) {
|
||||
if err := js.EnsureConsumer(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pendingOf(js, stream, "old", 6); got != 6 {
|
||||
t.Fatalf("the default should replay all six: %d", got)
|
||||
if info, _ = js.js.ConsumerInfo(stream, "old"); info.NumPending != 6 {
|
||||
t.Fatalf("the default should replay all six: %d", info.NumPending)
|
||||
}
|
||||
before, after, err := js.ResetConsumer(stream, "old")
|
||||
if err != nil {
|
||||
@@ -88,13 +89,10 @@ func TestAConsumerMadeFromNowNeverReplaysTheStreamsHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// pendingOf polls a consumer's pending count until it is want or ten seconds have passed, and returns what it saw
|
||||
// last. The server counts a consumer's pending after it acknowledges the publish, not before, so a test that reads
|
||||
// the count at once fails on a loaded build node (a try of main on ace, 2026-10-26): every test that expects a
|
||||
// count after a publish reads it through here.
|
||||
// pendingOf polls a consumer's pending count until it is want or a second has passed, and returns what it saw last.
|
||||
func pendingOf(js *JetStream, stream, name string, want uint64) uint64 {
|
||||
var got uint64
|
||||
for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(20 * time.Millisecond) {
|
||||
for deadline := time.Now().Add(time.Second); ; time.Sleep(20 * time.Millisecond) {
|
||||
if info, err := js.js.ConsumerInfo(stream, name); err == nil {
|
||||
got = info.NumPending
|
||||
}
|
||||
|
||||
@@ -73,15 +73,15 @@ func TestAResetOfTheEventsConsumerLeavesTheMergesAndTheirConsumerAlone(t *testin
|
||||
if _, err := js.Context().Publish(ControllerFollows[0], []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pendingOf(js, EventsStream, MergesConsumerName, 1); got != 1 {
|
||||
t.Fatalf("the merges consumer holds %d pending; want the merge", got)
|
||||
merges, err := js.Context().ConsumerInfo(EventsStream, MergesConsumerName)
|
||||
if err != nil || merges.NumPending != 1 {
|
||||
t.Fatalf("the merges consumer holds %v (%v); want the merge", merges, err)
|
||||
}
|
||||
if _, _, err := js.ResetConsumer(EventsStream, ControllerName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending := pendingOf(js, EventsStream, MergesConsumerName, 1)
|
||||
merges, err := js.Context().ConsumerInfo(EventsStream, MergesConsumerName)
|
||||
if err != nil || pending != 1 || merges.Delivered.Stream != 0 {
|
||||
merges, err = js.Context().ConsumerInfo(EventsStream, MergesConsumerName)
|
||||
if err != nil || merges.NumPending != 1 || merges.Delivered.Stream != 0 {
|
||||
t.Fatalf("after the reset the merges consumer holds %+v (%v); want its merge left alone", merges, err)
|
||||
}
|
||||
}
|
||||
@@ -107,8 +107,9 @@ func TestAMergeTheOldConsumerHadNotAcknowledgedIsTheMergesConsumers(t *testing.T
|
||||
if err := AssertMeshConsumers(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pendingOf(js, EventsStream, MergesConsumerName, 1); got != 1 {
|
||||
t.Fatalf("the merges consumer holds %d pending; want the merge the old consumer never acknowledged", got)
|
||||
merges, err := js.Context().ConsumerInfo(EventsStream, MergesConsumerName)
|
||||
if err != nil || merges.NumPending != 1 {
|
||||
t.Fatalf("the merges consumer holds %v (%v); want the merge the old consumer never acknowledged", merges, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package broker
|
||||
|
||||
import "testing"
|
||||
|
||||
// Defends issue 1790 and novox/hq ADR 0547 §5 (the operator, 2026-10-26 08:54): the grants part may create the stream
|
||||
// of each bucket, log and stream a placed module's manifest declares, by its own name, and read it back; nothing
|
||||
// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy. And ADR 0547
|
||||
// clause 7 (issue 1945, the operator's second exception): the grants part writes its own object bucket of sealed user
|
||||
// lists, and the node-engine of the node holding mesh-broker alone reads its one key and an object of it by name.
|
||||
func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
records := Records{
|
||||
Nodes: []string{"novox", "ace"},
|
||||
Assigned: map[string][]Declared{"ace": {{Module: "plex"}}, "novox": {
|
||||
{Module: "nats", SnapshotsTheBus: true},
|
||||
{Module: "mesh-delivery-builder",
|
||||
State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}},
|
||||
Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}},
|
||||
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}},
|
||||
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}},
|
||||
}},
|
||||
}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms := map[string]Permissions{}
|
||||
for _, u := range users {
|
||||
if u.Kind == KindNode {
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", u.Username(), err)
|
||||
}
|
||||
perms[u.Username()] = p
|
||||
continue
|
||||
}
|
||||
if u.Kind != KindModule {
|
||||
continue
|
||||
}
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", u.Username(), err)
|
||||
}
|
||||
perms[u.Module] = p
|
||||
}
|
||||
for _, row := range []struct {
|
||||
who, subject string
|
||||
may bool
|
||||
}{
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", true},
|
||||
{GrantsModule, "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps", true},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds", true},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.BUILDASKS", true},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-grants_assertions", true},
|
||||
{GrantsModule, "$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps", false},
|
||||
{GrantsModule, "$JS.API.STREAM.DELETE.KV_mesh-delivery-builder_steps", false},
|
||||
{GrantsModule, "$JS.API.STREAM.PURGE.KV_mesh-delivery-builder_steps", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_undeclared", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.EVENTS", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false},
|
||||
{GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false},
|
||||
{"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false},
|
||||
// The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other.
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true},
|
||||
{GrantsModule, "$O.mesh-grants-user-list.C.abc", true},
|
||||
{GrantsModule, "$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list", true},
|
||||
{GrantsModule, "$O.declaration-files-novox.C.abc", false},
|
||||
{GrantsModule, "$JS.API.STREAM.DELETE.OBJ_mesh-grants-user-list", false},
|
||||
{"mesh-delivery-builder", "$O.mesh-grants-user-list.C.abc", false},
|
||||
// The bus node's engine reads the one key and an object by name; nothing of the bucket's list, no write.
|
||||
{"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", true},
|
||||
{"node.novox", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_user-list.abc.$KV.mesh-grants_user-list.sealed", true},
|
||||
{"node.novox", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", true},
|
||||
{"node.novox", "$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.abc.$O.mesh-grants-user-list.C.abc", true},
|
||||
{"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.list", false},
|
||||
{"node.novox", "$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list", false},
|
||||
{"node.novox", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", false},
|
||||
{"node.novox", "$O.mesh-grants-user-list.C.abc", false},
|
||||
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", false},
|
||||
{"node.ace", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", false},
|
||||
} {
|
||||
if got := MayPublish(perms[row.who], row.subject); got != row.may {
|
||||
t.Errorf("%s may publish %s: %v, want %v", row.who, row.subject, got, row.may)
|
||||
}
|
||||
}
|
||||
// A name that is no plain token is never granted, and a module other than the grants part is never granted one.
|
||||
if got := CreateStreamGrants([]string{"*", ">", "KV_a.b", "KV_a_b"}); len(got) != 2 {
|
||||
t.Errorf("grants for wildcard names: %v", got)
|
||||
}
|
||||
other, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "shop", CreatesStreams: []string{"KV_shop_x"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if MayPublish(other, "$JS.API.STREAM.CREATE.KV_shop_x") {
|
||||
t.Error("a module other than the grants part was granted a create")
|
||||
}
|
||||
// The writers table refuses the grants part an update, and a create of a stream it was not given.
|
||||
g := Principal{Kind: KindModule, Node: "novox", Module: GrantsModule, CreatesStreams: []string{"KV_a_b"}}
|
||||
if err := CheckWriters(g, []string{"$JS.API.STREAM.CREATE.KV_a_b"}); err != nil {
|
||||
t.Errorf("the declared create refused: %v", err)
|
||||
}
|
||||
for _, refused := range []string{"$JS.API.STREAM.UPDATE.KV_a_b", "$JS.API.STREAM.CREATE.KV_a_c", "$JS.API.STREAM.CREATE.*"} {
|
||||
if CheckWriters(g, []string{refused}) == nil {
|
||||
t.Errorf("%s was not refused", refused)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,11 +213,8 @@ func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
||||
// Only the writers table names it, as a subject no one but the controller may publish.
|
||||
// And the operator's release 6 hand path, whose object store purges a replaced object's chunks in a node's
|
||||
// object bucket (novox/hq ADR 0539 clause 10): a grant, never a call of this repository.
|
||||
// And the grants part's own object bucket of sealed user lists, whose put purges a replaced object's chunks
|
||||
// (novox/hq ADR 0547 clause 7): a grant, never a call of this repository.
|
||||
if !strings.HasSuffix(filepath.ToSlash(path), "internal/broker/writers.go") &&
|
||||
!strings.HasSuffix(filepath.ToSlash(path), "internal/broker/composed.go") &&
|
||||
!strings.HasSuffix(filepath.ToSlash(path), "internal/broker/userlist_switch.go") {
|
||||
!strings.HasSuffix(filepath.ToSlash(path), "internal/broker/composed.go") {
|
||||
found = append(found, path+": "+lit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,17 +72,6 @@ func TestAnAccountMayReadItsOwnMembershipAndNoOthers(t *testing.T) {
|
||||
has(t, perms.Subscribe, "mesh.assignment.anchor.postgres")
|
||||
has(t, perms.Publish, "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.postgres")
|
||||
hasNot(t, perms.Subscribe, "mesh.assignment.>")
|
||||
hasNot(t, perms.Publish, "mesh.assignment.*.*")
|
||||
// The grants part alone publishes memberships (ADR 0547 §5), and defines no stream.
|
||||
grants, err := PermissionsFor(Principal{Kind: KindModule, Node: "anchor", Module: GrantsModule, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, grants.Publish, "mesh.assignment.*.*")
|
||||
has(t, grants.Publish, "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*")
|
||||
hasNot(t, perms.Publish, "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*")
|
||||
hasNot(t, grants.Publish, "$JS.API.STREAM.CREATE.>")
|
||||
hasNot(t, grants.Publish, "mesh.assignment.>")
|
||||
}
|
||||
|
||||
// The runtime arriving on a machine changes nothing about what each module is issued (to-be 38 WP2):
|
||||
|
||||
@@ -52,8 +52,9 @@ func TestAModuleStreamOnARealBus(t *testing.T) {
|
||||
t.Fatalf("publishing %s's mail: %v", account, err)
|
||||
}
|
||||
}
|
||||
if got := pendingOf(js, "MAIL", c.Name, 1); got != 1 {
|
||||
t.Fatalf("the reader granted `mesh` has %d pending, want 1", got)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.NumPending != 1 {
|
||||
t.Fatalf("the reader granted `mesh` has %d pending, want 1", info.NumPending)
|
||||
}
|
||||
narrowed := c
|
||||
narrowed.Filters = []string{"mesh.stream.mesh-mail.event.mail.mesh.message.changed"}
|
||||
|
||||
@@ -214,17 +214,9 @@ type Principal struct {
|
||||
// StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name
|
||||
// (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new.
|
||||
StatesItsKeyToTheVault bool
|
||||
// FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants
|
||||
// part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows).
|
||||
FollowsGrantsUserList bool
|
||||
// BoundBuckets are the providers' buckets of entries, `<module>_bound`, a provisioner client reads its own
|
||||
// machine's keys of (mesh-sdk go/provider, issue 679): every bucket named bound of a module the mesh places.
|
||||
BoundBuckets []string
|
||||
// CreatesStreams are the streams the grants part's principal alone may create (novox/hq ADR 0547 §5; the operator,
|
||||
// 2026-10-26 08:54): the stream of every bucket, log and stream a manifest of a module placed anywhere declares,
|
||||
// by name. Created only, never updated or deleted: a new module state is on the bus when the grants part hears its
|
||||
// manifest, not at the controller's next start (issue 1790).
|
||||
CreatesStreams []string
|
||||
|
||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||
@@ -916,11 +908,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if p.StatesItsKeyToTheVault {
|
||||
pub = append(pub, "mesh.seat."+VaultNodeKey.Seat+"."+CallKind+"."+VaultNodeKey.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
// And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR
|
||||
// 0547 clause 7): one key, and an object by its name.
|
||||
if p.FollowsGrantsUserList {
|
||||
pub = append(pub, GrantsUserListFollows()...)
|
||||
}
|
||||
|
||||
case KindModule:
|
||||
// 0. The watchman alone hears who connects and disconnects, and asks who is connected (ADR 0431 §2).
|
||||
@@ -928,17 +915,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
sub = append(sub, BusConnectSubject, BusDisconnectSubject)
|
||||
pub = append(pub, BusConnzSubject)
|
||||
}
|
||||
// The grants part alone publishes the modules' memberships into the assignments stream (novox/hq ADR 0547 §5;
|
||||
// the operator, 2026-10-25 22:31): one per subject, `mesh.assignment.<node>.<module>`, reading the last one of each
|
||||
// first so it publishes only what changed, and no stream it defines.
|
||||
if p.Module == GrantsModule {
|
||||
pub = append(pub, "mesh.assignment.*.*", "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment.*.*")
|
||||
// And creating each stream a manifest declares, by its own name (the operator, 2026-10-26 08:54; issue
|
||||
// 1790): the create and the read of that one stream, never an update, a delete or a stream not declared.
|
||||
pub = append(pub, CreateStreamGrants(p.CreatesStreams)...)
|
||||
// And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7).
|
||||
pub = append(pub, GrantsUserListWrites()...)
|
||||
}
|
||||
// The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a).
|
||||
if p.Module == ViewModule {
|
||||
pub = append(pub, BuildBacklogReads()...)
|
||||
@@ -1605,24 +1581,6 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
||||
// from them. No other principal is granted the subjects below, and nothing is a wildcard over them.
|
||||
const WatchmanModule = "mesh-delivery-watchman"
|
||||
|
||||
// CreateStreamGrants is what creating each named stream needs, and nothing wider (novox/hq ADR 0547 §5; the operator,
|
||||
// 2026-10-26 08:54): its create, and its information to read back what it holds. Each by its own name: a name that
|
||||
// is not one plain subject token is left out, since a wildcard there would be every stream.
|
||||
func CreateStreamGrants(streams []string) []string {
|
||||
var out []string
|
||||
for _, s := range streams {
|
||||
if !safeSubject.MatchString(s) {
|
||||
continue
|
||||
}
|
||||
out = append(out, "$JS.API.STREAM.CREATE."+s, "$JS.API.STREAM.INFO."+s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants
|
||||
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
|
||||
const GrantsModule = "mesh-grants"
|
||||
|
||||
// The bus's own events, imported into MESH from the default system account `$SYS` under subjects of MESH's own
|
||||
// (novox/hq ADR 0431 §1, issue 1147): the server says an account's connections opening and closing only to the system
|
||||
// account, and the list of an account's connections only to a request made there. `$SYS` holds no user, so nobody
|
||||
|
||||
@@ -24,9 +24,8 @@ import (
|
||||
// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded
|
||||
// holder asks with its own kind; the modules that watch the seat answer.
|
||||
//
|
||||
// And two reads: **a holder's records**, a bucket the seat names, read by each user under its own name only
|
||||
// (`$KV.<bucket>.<module>.>`), so an asker reads the state of its own asks and no other asker's; and on a seat pulled by
|
||||
// kind, **what its workers gave up on**, the copies DEAD_LETTERS keeps of the seat's stream (DeadLetterReads, issue 734).
|
||||
// And one read: **a holder's records**, a bucket the seat names, read by each user under its own name only
|
||||
// (`$KV.<bucket>.<module>.>`), so an asker reads the state of its own asks and no other asker's.
|
||||
|
||||
// BuildAskMaxDeliver bounds the deliveries of a build seat's ask (issue 1907): mesh-sdk delivery.AskMaxDeliver, written
|
||||
// here as the broker mirrors the catalogue rather than importing it.
|
||||
@@ -130,9 +129,6 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
|
||||
}
|
||||
t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>")
|
||||
}
|
||||
if s.PulledByKind && len(s.Accepts) > 0 && safeSubject.MatchString(s.Name) {
|
||||
t.Records = append(t.Records, DeadLetterReads(seatStreamName(s.Name))...)
|
||||
}
|
||||
}
|
||||
for _, w := range watches {
|
||||
if w.Kinded {
|
||||
@@ -155,20 +151,6 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
|
||||
return t
|
||||
}
|
||||
|
||||
// DeadLetterReads is what a user of a seat pulled by kind is granted to hear which of the asks it put the seat's workers
|
||||
// gave up on (novox/hq issue 734): an ordered consumer on DEAD_LETTERS filtered to the copies the controller keeps of that
|
||||
// seat's stream (DeadLetterSubject(stream, *)), pulled, and its deletion. A copy carries the ask's own message id
|
||||
// (Mesh-Dead-Msg-Id), so the asker ends the step it waits for instead of waiting for ever; it reads the seat's dead
|
||||
// letters and no other stream's, and changes none.
|
||||
func DeadLetterReads(stream string) []string {
|
||||
return []string{
|
||||
"$JS.API.STREAM.INFO." + DeadLettersStream,
|
||||
"$JS.API.CONSUMER.CREATE." + DeadLettersStream + ".*." + DeadLetterSubject(stream, ">"),
|
||||
"$JS.API.CONSUMER.MSG.NEXT." + DeadLettersStream + ".*",
|
||||
"$JS.API.CONSUMER.DELETE." + DeadLettersStream + ".*",
|
||||
}
|
||||
}
|
||||
|
||||
// seatTrafficGrants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a
|
||||
// worker is pulled and acknowledged through and a record is read through.
|
||||
func seatTrafficGrants(t SeatTraffic) (pub, sub []string) {
|
||||
|
||||
@@ -403,19 +403,6 @@ func TestTheBuilderMayAskABuildSeatAndASessionMayNot(t *testing.T) {
|
||||
t.Errorf("the builder may not publish %s", s)
|
||||
}
|
||||
}
|
||||
// Issue 734: the asker hears which of its asks the seat's workers gave up on, from DEAD_LETTERS, and no other stream's.
|
||||
for subject, want := range map[string]bool{
|
||||
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.>": true,
|
||||
"$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1": true,
|
||||
"$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTERS.x1": true,
|
||||
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.EVENTS.>": false,
|
||||
"$JS.API.STREAM.PURGE.DEAD_LETTERS": false,
|
||||
"$JS.FC.DEAD_LETTERS.x1": false,
|
||||
} {
|
||||
if got := allowed(builder.Publish, subject); got != want {
|
||||
t.Errorf("the builder may publish %s: %v, want %v", subject, got, want)
|
||||
}
|
||||
}
|
||||
session := perms(t, Principal{Kind: KindPerson, Module: "jochen", Invokes: []string{"mesh-controller.status"}})
|
||||
if allowed(session.Publish, "mesh.seat.node-build-go.accept.delivery") {
|
||||
t.Error("a session's account may put an ask")
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package broker
|
||||
|
||||
// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945): the operator's
|
||||
// second exception to the frozen controller (the first, memberships, 2026-10-25 22:31). The controller's copy of the bus
|
||||
// composer grants what mesh-sdk's copy grants, byte for byte, and nothing else: the node-engine of the node holding
|
||||
// mesh-broker reads the grants part's one key `user-list.sealed`, watched, and an object of its bucket by name; the
|
||||
// grants part writes that bucket. Without it the bus node could never read the first list the grants part writes, since
|
||||
// until take-over the list the bus loads is this one (mesh-sdk go/buscompose/userlist_switch.go keeps the other copy).
|
||||
|
||||
// The grants part's user list on the bus (mesh-sdk go/delivery userlist.go names them).
|
||||
const (
|
||||
GrantsUserListBucket = "mesh-grants_user-list"
|
||||
GrantsUserListKey = "sealed"
|
||||
GrantsUserListObjects = "mesh-grants-user-list"
|
||||
GrantsUserListObjectStream = "OBJ_" + GrantsUserListObjects
|
||||
)
|
||||
|
||||
// GrantsUserListFollows is what the node-engine of the node holding mesh-broker may publish to follow the grants part's
|
||||
// list: the one key read and watched, and an object of the bucket by its name (as ADR 0486 §2 reads a declaration's
|
||||
// files: never the object stream's info, which lists every name, never a message by sequence).
|
||||
func GrantsUserListFollows() []string {
|
||||
kv := "KV_" + GrantsUserListBucket
|
||||
key := "$KV." + GrantsUserListBucket + "." + GrantsUserListKey
|
||||
obj, s := GrantsUserListObjects, GrantsUserListObjectStream
|
||||
return []string{
|
||||
"$JS.API.DIRECT.GET." + kv + "." + key,
|
||||
"$JS.API.CONSUMER.CREATE." + kv + ".*." + key,
|
||||
"$JS.API.CONSUMER.DELETE." + kv + ".*",
|
||||
"$JS.API.STREAM.INFO." + kv,
|
||||
"$JS.FC." + kv + ".>",
|
||||
"$JS.API.DIRECT.GET." + s + ".$O." + obj + ".M.*",
|
||||
"$JS.API.CONSUMER.CREATE." + s + ".*.$O." + obj + ".C.*",
|
||||
"$JS.API.CONSUMER.DELETE." + s + ".*",
|
||||
"$JS.FC." + s + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// GrantsUserListWrites is what the grants part may publish to keep its object bucket.
|
||||
func GrantsUserListWrites() []string {
|
||||
obj, s := GrantsUserListObjects, GrantsUserListObjectStream
|
||||
return []string{
|
||||
"$JS.API.STREAM.CREATE." + s,
|
||||
"$JS.API.STREAM.INFO." + s,
|
||||
"$JS.API.STREAM.PURGE." + s,
|
||||
"$JS.API.DIRECT.GET." + s + ".>",
|
||||
"$JS.API.STREAM.MSG.GET." + s,
|
||||
"$O." + obj + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// grantsOwnObjects is the grants part writing its own object bucket, and nothing of another.
|
||||
func grantsOwnObjects(p Principal, pattern string) bool {
|
||||
return p.Kind == KindModule && p.Module == GrantsModule &&
|
||||
(pattern == "$O."+GrantsUserListObjects+".>" || pattern == "$JS.API.STREAM.CREATE."+GrantsUserListObjectStream)
|
||||
}
|
||||
@@ -147,19 +147,14 @@ func Users(r Records) ([]Principal, error) {
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
var checks []string
|
||||
bus := false
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.Module == controllerModule {
|
||||
witness = true
|
||||
}
|
||||
if d.SnapshotsTheBus {
|
||||
bus = true
|
||||
}
|
||||
checks = append(checks, d.Checks...)
|
||||
}
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
|
||||
FollowsGrantsUserList: bus})
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey)})
|
||||
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
|
||||
// per machine that runs it, delivered to the module running it.
|
||||
for _, d := range r.Assigned[node] {
|
||||
@@ -212,11 +207,6 @@ func Users(r Records) ([]Principal, error) {
|
||||
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
|
||||
})
|
||||
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
|
||||
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
|
||||
if d.Module == GrantsModule {
|
||||
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
|
||||
}
|
||||
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
|
||||
// account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it.
|
||||
if holdsSeat(d, VaultSeat) {
|
||||
@@ -428,29 +418,3 @@ func boundBucketsOf(r Records) []string {
|
||||
func runnerServes(d Declared) bool {
|
||||
return d.Module == RuntimeModule || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0
|
||||
}
|
||||
|
||||
// declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name
|
||||
// order (novox/hq ADR 0547 §5, issue 1790): `KV_<module>_<state>`, `LOG_<module>_<log>` and each module stream's own
|
||||
// name. What the grants part may create, and nothing beside.
|
||||
func declaredStreamsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, mods := range r.Assigned {
|
||||
for _, d := range mods {
|
||||
for _, b := range d.State {
|
||||
seen["KV_"+b.Bucket()] = true
|
||||
}
|
||||
for _, l := range d.Logs {
|
||||
seen[l.Stream()] = true
|
||||
}
|
||||
for _, s := range d.Streams {
|
||||
seen[s.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for s := range seen {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -37,20 +37,6 @@ type WriterRow struct {
|
||||
// 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 }
|
||||
|
||||
// createsDeclared is the controller, or the grants part creating one stream a manifest declares, by its own name: the
|
||||
// create alone, never an update or a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54).
|
||||
func createsDeclared(p Principal, pattern string) bool {
|
||||
if isController(p, pattern) {
|
||||
return true
|
||||
}
|
||||
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
|
||||
if ok && p.Kind == KindModule && p.Module == GrantsModule && name == GrantsUserListObjectStream {
|
||||
return true
|
||||
}
|
||||
return ok && p.Kind == KindModule && p.Module == GrantsModule && slices.Contains(p.CreatesStreams, name) &&
|
||||
safeSubject.MatchString(name)
|
||||
}
|
||||
|
||||
// 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, ".")
|
||||
@@ -184,8 +170,7 @@ var WritersTable = []WriterRow{
|
||||
// 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: createsDeclared,
|
||||
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54)"},
|
||||
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"},
|
||||
@@ -252,10 +237,7 @@ var WritersTable = []WriterRow{
|
||||
KeptIn: "the object bucket " + DeclarationFilesBucket + "-<node> of each node, an object removed a day after the " +
|
||||
"node's latest declaration no longer names it",
|
||||
Others: "a node-engine reads an object of its own bucket by its name and may neither list the bucket nor write it",
|
||||
Subjects: []string{"$O.*.>"}, Writes: func(p Principal, pattern string) bool {
|
||||
return isControllerOrTheHandPath(p, pattern) || grantsOwnObjects(p, pattern)
|
||||
},
|
||||
Shared: "the grants part writes its own object bucket " + GrantsUserListObjects + ", the user list sealed to the bus node, and no other (novox/hq ADR 0547 clause 7)"},
|
||||
Subjects: []string{"$O.*.>"}, Writes: isControllerOrTheHandPath},
|
||||
{State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig,
|
||||
Subjects: kvOf(DeclaredConfigBucket), Writes: isController},
|
||||
|
||||
@@ -67,23 +67,14 @@ func (i *Inventory) HighestEpoch(ctx context.Context) (uint64, error) {
|
||||
}
|
||||
|
||||
// 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. givenBack is the epoch the
|
||||
// bus says gave the key back (the lease's own delete marker names it, novox/hq issue 906, ADR 0558 §3): that
|
||||
// epoch, if still open, is ended as released — its holder gave the key back and had not yet recorded it — and
|
||||
// every other as expired; zero is none. Answers the epochs it ended as expired, newest first.
|
||||
func (i *Inventory) TookEpoch(ctx context.Context, e Epoch, givenBack uint64) ([]Epoch, error) {
|
||||
// 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) }()
|
||||
if givenBack != 0 && givenBack < e.Epoch {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`update controller_epoch set ended = now(), how = $2 where epoch = $1 and ended is null`,
|
||||
int64(givenBack), EpochReleased); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rows, err := tx.Query(ctx,
|
||||
`update controller_epoch set ended = now(), how = $2
|
||||
where ended is null and epoch < $1
|
||||
|
||||
+9
-71
@@ -34,11 +34,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -117,7 +115,6 @@ type Options struct {
|
||||
// Lease is one instance's hold, or its wait for one.
|
||||
type Lease struct {
|
||||
kv jetstream.KeyValue
|
||||
js jetstream.JetStream
|
||||
stream jetstream.Stream
|
||||
ttl time.Duration
|
||||
o Options
|
||||
@@ -142,9 +139,6 @@ type Lease struct {
|
||||
lostWhy error
|
||||
lost chan struct{}
|
||||
taken time.Time
|
||||
// givenBack is the epoch whose holder gave back the key this instance took — the epoch its delete marker
|
||||
// names on the bus — and zero when the key was left to expire, or freed by anything else (novox/hq issue 906).
|
||||
givenBack uint64
|
||||
// update writes the key at a revision: the bucket's own, or a test's bus that does not answer.
|
||||
update func(ctx context.Context, key string, value []byte, last uint64) (uint64, error)
|
||||
}
|
||||
@@ -190,7 +184,7 @@ func Open(ctx context.Context, js jetstream.JetStream, bucket string, o Options)
|
||||
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, js: js, stream: stream, ttl: status.TTL(), o: o, lost: make(chan struct{}), update: kv.Update}, nil
|
||||
return &Lease{kv: kv, stream: stream, ttl: status.TTL(), o: o, lost: make(chan struct{}), update: kv.Update}, nil
|
||||
}
|
||||
|
||||
// TTL is how long the key lives unrenewed.
|
||||
@@ -277,7 +271,6 @@ func (l *Lease) TryTake(ctx context.Context) (uint64, error) {
|
||||
} else if found {
|
||||
return 0, &heldBy{h}
|
||||
}
|
||||
givenBack := l.wasGivenBack(ctx)
|
||||
if err := l.aboveTheFloor(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -314,51 +307,13 @@ func (l *Lease) TryTake(ctx context.Context) (uint64, error) {
|
||||
"back to be taken again", revision, floor)
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.held, l.epoch, l.revision, l.taken, l.givenBack = true, revision, revision, now, givenBack
|
||||
l.held, l.epoch, l.revision, l.taken = true, revision, revision, now
|
||||
l.renewedAt(anchor)
|
||||
l.mu.Unlock()
|
||||
l.o.Say("took the controller lease at epoch %d (%s)", revision, h.Instance)
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
// ReleasedHeader is the header of the delete marker a holder's Release writes, naming the epoch it gave back:
|
||||
// the one mark that says a handover. A delete marker without it — a command's own lease given back, a take
|
||||
// given back over the floor — names no epoch, so it says nothing about the holder before (novox/hq issue 906).
|
||||
const ReleasedHeader = "Mesh-Lease-Released"
|
||||
|
||||
// wasGivenBack is the epoch the free key was given back by: the epoch its last message on the bus — the delete
|
||||
// marker a holder's Release writes — names. Zero when the key expired (the bucket's age removed every message),
|
||||
// when the marker names no epoch, and when it cannot be read: a takeover nobody can prove was a handover is
|
||||
// said as one that was not (novox/hq issue 906, ADR 0558 §3).
|
||||
func (l *Lease) wasGivenBack(ctx context.Context) uint64 {
|
||||
if l.stream == nil {
|
||||
return 0
|
||||
}
|
||||
last, err := l.stream.GetLastMsgForSubject(ctx, l.subject())
|
||||
if err != nil || last == nil {
|
||||
return 0
|
||||
}
|
||||
if op := last.Header.Get("KV-Operation"); op != "DEL" && op != "PURGE" {
|
||||
return 0
|
||||
}
|
||||
epoch, err := strconv.ParseUint(last.Header.Get(ReleasedHeader), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return epoch
|
||||
}
|
||||
|
||||
// subject is the lease key's subject in its bucket's stream.
|
||||
func (l *Lease) subject() string { return "$KV." + l.kv.Bucket() + "." + Key }
|
||||
|
||||
// GivenBack is the epoch whose holder gave back the key this instance took, zero when it was not given back:
|
||||
// what the takeover was, read from the bus (novox/hq issue 906).
|
||||
func (l *Lease) GivenBack() uint64 {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.givenBack
|
||||
}
|
||||
|
||||
// floor is the highest epoch issued, zero without a floor.
|
||||
func (l *Lease) floor(ctx context.Context) (uint64, error) {
|
||||
if l.o.Floor == nil {
|
||||
@@ -633,11 +588,9 @@ func (l *Lease) lose(why error) error {
|
||||
return l.lostWhy
|
||||
}
|
||||
|
||||
// Release gives the lease back, so the next candidate takes it at once rather than after its age, and answers
|
||||
// the epoch it gave back — zero when it gave nothing back: it held none, had lost it, or the bus would not
|
||||
// delete the key. Only the key this instance last wrote is deleted: one that moved is somebody else's. The
|
||||
// gate closes before the key goes, so nothing this instance starts after overlaps its successor.
|
||||
func (l *Lease) Release(ctx context.Context) uint64 {
|
||||
// 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
|
||||
@@ -647,11 +600,11 @@ func (l *Lease) Release(ctx context.Context) uint64 {
|
||||
}
|
||||
l.mu.Unlock()
|
||||
if !held {
|
||||
return 0
|
||||
return
|
||||
}
|
||||
releasing, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
err := l.giveBack(releasing, epoch, revision)
|
||||
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.
|
||||
@@ -659,29 +612,14 @@ func (l *Lease) Release(ctx context.Context) uint64 {
|
||||
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.giveBack(releasing, epoch, entry.Revision())
|
||||
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 0
|
||||
return
|
||||
}
|
||||
l.o.Say("gave the controller lease back (epoch %d)", epoch)
|
||||
return epoch
|
||||
}
|
||||
|
||||
// giveBack deletes the key at the revision this instance last wrote, as the bucket's own delete does — a
|
||||
// marker with KV-Operation DEL, compare-and-set on the key's last revision — and names the epoch given back in
|
||||
// the marker, so the next holder can tell this handover from any other delete (ReleasedHeader).
|
||||
func (l *Lease) giveBack(ctx context.Context, epoch, revision uint64) error {
|
||||
if l.js == nil {
|
||||
return l.kv.Delete(ctx, Key, jetstream.LastRevision(revision))
|
||||
}
|
||||
msg := nats.NewMsg(l.subject())
|
||||
msg.Header.Set("KV-Operation", "DEL")
|
||||
msg.Header.Set(ReleasedHeader, strconv.FormatUint(epoch, 10))
|
||||
_, err := l.js.PublishMsg(ctx, msg, jetstream.WithExpectLastSequencePerSubject(revision))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -133,12 +133,7 @@ func TestTheSecondControllerWaitsAndTakesAHigherEpochOnHandover(t *testing.T) {
|
||||
// a gives it back: b takes it at once, not after the key's age, at a higher epoch.
|
||||
stop()
|
||||
handedOver := time.Now()
|
||||
if given := a.Release(t.Context()); given != epochA {
|
||||
t.Fatalf("a's release answers epoch %d given back, want %d", given, epochA)
|
||||
}
|
||||
if again := a.Release(t.Context()); again != 0 {
|
||||
t.Fatalf("a second release gives epoch %d back again", again)
|
||||
}
|
||||
a.Release(t.Context())
|
||||
select {
|
||||
case epochB := <-took:
|
||||
if epochB <= epochA {
|
||||
@@ -147,9 +142,6 @@ func TestTheSecondControllerWaitsAndTakesAHigherEpochOnHandover(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if given := b.GivenBack(); given != epochA {
|
||||
t.Fatalf("b reads from the bus that epoch %d gave the key back, want a's %d (novox/hq issue 906)", given, epochA)
|
||||
}
|
||||
case <-time.After(2 * testTTL):
|
||||
t.Fatal("b never took the lease a gave back")
|
||||
}
|
||||
@@ -190,9 +182,6 @@ func TestAHolderThatStopsRenewingStopsActingBeforeItIsTakenOver(t *testing.T) {
|
||||
if epochB <= epochA {
|
||||
t.Fatalf("b took epoch %d after a's %d", epochB, epochA)
|
||||
}
|
||||
if given := b.GivenBack(); given != 0 {
|
||||
t.Fatalf("b reads a key that expired as given back by epoch %d (novox/hq issue 906)", given)
|
||||
}
|
||||
// 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")
|
||||
@@ -237,11 +226,8 @@ func TestARenewalRefusedIsALossAtOnce(t *testing.T) {
|
||||
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, and answers no epoch given
|
||||
// back, so none is recorded released (novox/hq issue 906).
|
||||
if given := a.Release(t.Context()); given != 0 {
|
||||
t.Fatalf("a says it gave back epoch %d of a lease it lost", given)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
"dead-letters",
|
||||
"root-free",
|
||||
"judge-directories",
|
||||
"placement-export",
|
||||
"data",
|
||||
"build",
|
||||
"artifacts",
|
||||
|
||||
Vendored
+1
-1
@@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself.
|
||||
|
||||
mesh-catalog 3a1017f5594217092a654dd1e2d72226766bc16a modules/*/module.json, modules/*/files, modules/*/templates, predefined.json
|
||||
mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock
|
||||
mesh-sdk c2ec8b6b2fdece8dd76a9d2d1f29d3ae4579f174 conformance/events/module-event.json
|
||||
mesh-sdk 10b6e202320f38301c67a6822b0734e0b870ea30 conformance/events/module-event.json
|
||||
|
||||
To move them, from this repository's root, with the two repositories checked out beside it:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user