Send the bus its user list before a starting controller writes (issue 1800)

A new controller's grants reached the bus only after its gate, so the
buckets it newly writes were refused until then. At start it now sends
the bus's machine the user list alone, every build there kept.
This commit is contained in:
2026-10-22 20:51:08 +02:00
parent a0b2d98002
commit d5d53893a4
3 changed files with 145 additions and 1 deletions
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"context"
"fmt"
)
// A new controller's own grants reach the bus before it writes (novox/hq issue 1800).
//
// A controller's grants are composed by the controller binary itself (broker.PermissionsFor), so the controller that
// walks a delivery cannot put its successor's grants in the bus's user list: the list that carries them was sent only
// once the new controller passed its gate on its first machine (carryUserList). Until then the new controller wrote
// buckets its account was not granted, and the bus refused them: plans-building on 2026-10-21, declared-packages on
// 2026-10-22, and R4e's states-announced would be the third.
//
// So a controller that starts, holding the lease and before it starts anything that writes its buckets, sends the
// machine holding the bus its user list when the list it composes is not the one that machine was last sent: the grants
// step's send (release.go grantsStep), every build there kept at the one it runs, refused unsent when the declaration
// would change more than the user list (errNotGrantsOnly). No build of a running plan reaches that machine before the
// plan's own tier sends it (ADR 0503 §8). A send that cannot be made is said once, and the controller serves on.
//
// It does not ask heldMachines, as carryUserList does: every reason that gives is a build that would move with the
// list (a policy, a plan not yet sent there, no gate passed), and here none can. The running plan that brought this
// controller holds the bus's machine until its tier sends it; asking would leave the list behind exactly when 1800
// meets it. The refusal unsent (errNotGrantsOnly) is the guard instead.
// ownGrantsCarrier is what the start's send reads and does: whether the bus's list is behind and on which machine, and
// the grants-only send to it. Variables so a test needs no store and no bus.
type ownGrantsCarrier struct {
behind func(ctx context.Context) (holder string, behind bool, err error)
send func(ctx context.Context, holder string) error
}
// carry sends the machine holding the bus the controller's own user list when it is behind, and says what it did.
func (c ownGrantsCarrier) carry(ctx context.Context) string {
holder, behind, err := c.behind(ctx)
switch {
case err != nil:
return fmt.Sprintf("whether the bus's user list carries this controller's grants cannot be read, so it is not sent "+
"at start; a bucket it newly writes may be refused until the gate carries it: %v", err)
case holder == "" || !behind:
return ""
}
if err := c.send(ctx, holder); err != nil {
return fmt.Sprintf("this controller's grants could not be sent to %s, which holds the bus, at its start; a bucket "+
"it newly writes may be refused until the gate carries them: %v", holder, err)
}
return fmt.Sprintf("sent %s, which holds the bus, this controller's user list alone, every build there kept, "+
"before it writes", holder)
}
// startWithOwnGrants carries the controller's own grants to the bus and only then starts what writes (then).
func startWithOwnGrants(ctx context.Context, c ownGrantsCarrier, then func()) {
if said := c.carry(ctx); said != "" {
fmt.Println(said)
}
then()
}
// ownGrantsOf is the carrier of the serving controller: the grants step's reading and its grants-only send.
func ownGrantsOf(open *stores) ownGrantsCarrier {
return ownGrantsCarrier{
behind: func(ctx context.Context) (string, bool, error) { return brokerBehindOf(ctx, open, nil) },
send: func(ctx context.Context, holder string) error {
_, err := sendRollout(keepingEveryBuild(withScope(ctx, sendScope{}), holder), open, []string{holder})
return err
},
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"errors"
"strings"
"testing"
)
// 1800 (1): a controller starting with the bus's list behind its own sends the bus's machine once, before anything
// that writes its buckets starts.
func TestAStartingControllerSendsItsGrantsBeforeItWrites(t *testing.T) {
var order []string
c := ownGrantsCarrier{
behind: func(context.Context) (string, bool, error) { return "novox", true, nil },
send: func(_ context.Context, holder string) error { order = append(order, "send "+holder); return nil },
}
startWithOwnGrants(t.Context(), c, func() { order = append(order, "writers") })
if strings.Join(order, ", ") != "send novox, writers" {
t.Fatalf("the start did %v", order)
}
}
// 1800 (3), (4): no send when the list is current or there is no bus with a list; one that cannot be read or sent is
// said once, and the controller goes on.
func TestAStartingControllerSendsNothingWhenTheListIsCurrentAndGoesOnWhenRefused(t *testing.T) {
sends := 0
send := func(context.Context, string) error { sends++; return nil }
for _, c := range []ownGrantsCarrier{
{behind: func(context.Context) (string, bool, error) { return "novox", false, nil }, send: send},
{behind: func(context.Context) (string, bool, error) { return "", true, nil }, send: send},
} {
if said := c.carry(t.Context()); said != "" || sends != 0 {
t.Fatalf("a current list sent %d and said %q", sends, said)
}
}
refused := ownGrantsCarrier{behind: func(context.Context) (string, bool, error) { return "novox", true, nil },
send: func(context.Context, string) error { return errNotGrantsOnly }}
went := false
startWithOwnGrants(t.Context(), refused, func() { went = true })
if !went {
t.Fatal("a refused send stopped the controller")
}
if said := refused.carry(t.Context()); !strings.Contains(said, "could not be sent to novox") ||
!strings.Contains(said, errNotGrantsOnly.Error()) {
t.Fatalf("a refused send is said as %q", said)
}
unread := ownGrantsCarrier{behind: func(context.Context) (string, bool, error) { return "", false, errors.New("no store") },
send: send}
if said := unread.carry(t.Context()); !strings.Contains(said, "cannot be read") || sends != 0 {
t.Fatalf("an unread list: sent %d, said %q", sends, said)
}
}
// 1800 (2): the start's send is the grants step's: composed with every build of the bus's machine kept at the one it
// runs, so the declaration differs from its last only by the user list (and is refused unsent otherwise,
// errNotGrantsOnly, release.go), whatever a running plan registered since.
func TestAStartingControllersSendKeepsEveryBuildOfTheBusMachine(t *testing.T) {
was := sendRollout
t.Cleanup(func() { sendRollout = was })
var kept bool
var named []string
sendRollout = func(ctx context.Context, _ *stores, names []string) ([]string, error) {
kept, named = everyBuildKept(ctx, "novox"), names
return names, nil
}
if err := ownGrantsOf(nil).send(t.Context(), "novox"); err != nil {
t.Fatal(err)
}
if !kept || len(named) != 1 || named[0] != "novox" {
t.Fatalf("the start's send keeps every build: %v, names %v", kept, named)
}
}
+3 -1
View File
@@ -189,7 +189,9 @@ func serve(ctx context.Context) (err error) {
// Open plans move on events (novox/hq ADR 0406 §5, issue 974): a tier is asked on the `built` and the send of the
// tier before it, a walk ends on its machines' reports, a silent machine is a key that expired, and a plan left
// by a replaced controller resumes from the first look, which this asks for. No timer looks every so often.
startRolloutEvents(ctx, open, server.JetStream())
// **Its own grants on the bus before it writes** (novox/hq issue 1800): a bucket this controller newly writes is
// granted before the first write, not after its gate.
startWithOwnGrants(ctx, ownGrantsOf(open), func() { startRolloutEvents(ctx, open, server.JetStream()) })
// And the merge window (novox/hq ADR 0276, ADR 0407): a batch is cut into its walk when the window closes,
// which no merge or outcome says. The window is two keys on the bus that expire and a watcher that hears it;
// nothing polls (issue 973).