Delivery parts: a bucket the bus refuses at start stops a part only when it cannot work without it (issue 1945)

Met twice on 2026-10-25: the builder of #48 exited on a refused read of
the gate's source and crash-looped, and its new reasks bucket would have
done the same. A grant reaches the bus with a delivery, so a refusal or a
timeout at start is often only a grant not there yet; exiting for it
stopped the whole part until a hand step on the bus.

Every startup open in the parts is now essential or optional. Essential
ones (a part's own state and the input it exists for) still stop the
start, with an error naming the bucket and its grant. Optional ones are
said and left: watches the bus refuses too. The builder builds trunk keys
without the gate's source, which it opens again at the next candidate
that asks a trunk head; toolchains and previews open again at their next
event, as a missing bucket did. builder_ask_again says that asking a
running step again builds it twice.
This commit is contained in:
2026-10-25 22:29:31 +01:00
parent d4a054bfed
commit 20027e53ff
29 changed files with 681 additions and 208 deletions
@@ -119,12 +119,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return nil
}
if err != nil {
return fmt.Errorf("the bucket %s: %w", delivery.AsksBucket, err)
// Essential: the gate's asks are what the teller tells (issue 1945: the error names the grant).
return essentialErr(delivery.AsksBucket, "tell any of the gate's asks", err)
}
// The answers it writes and what it asked: its own work, so each stops the start, naming its grant.
var kvs [2]jetstream.KeyValue
for i, name := range []string{delivery.AnswersBucket, askedBucket} {
if kvs[i], err = js.KeyValue(ctx, name); err != nil {
return fmt.Errorf("the bucket %s: %w", name, err)
if kvs[i], err = essentialBucket(ctx, js, name, "write an answer or remember what it asked"); err != nil {
return err
}
}
state := kvStore{ctx: ctx, kv: kvs[1]}
@@ -140,7 +142,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// The view's answers to the assigner's asks (issue 1297 B10): both buckets are the controller's to create, and a bucket not
// there is said and leaves the teller without that path rather than without the gate's asks (a wait for it needs a timer).
failed := make(chan error, 2)
assignerKV, viewKV := optionalBucket(ctx, js, delivery.AssignerAsksBucket, logger), optionalBucket(ctx, js, delivery.ViewAnswersBucket, logger)
assignerKV, viewKV := optionalBucket(ctx, js, delivery.AssignerAsksBucket, teller, logger.Printf), optionalBucket(ctx, js, delivery.ViewAnswersBucket, teller, logger.Printf)
// The assigner's asks are raised on the operator channel whether or not the view's answers can be read (issue 1906).
if viewKV != nil {
t.View = kvGate{ctx: ctx, kv: viewKV}
@@ -172,15 +174,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
}
// optionalBucket is a bucket the teller reads when it exists, and nil, said once, when it does not.
func optionalBucket(ctx context.Context, js jetstream.JetStream, name string, logger *log.Logger) jetstream.KeyValue {
kv, err := js.KeyValue(ctx, name)
if err != nil {
logger.Printf("the bucket %s cannot be read (%v): the asks and answers it holds are not carried until the teller is started again with it", name, err)
return nil
}
return kv
}
// teller is what an optional bucket of the teller leaves out.
const teller = "the asks and answers it holds are not carried until the teller is started again with it"
// askedBucket is the teller's state bucket asked, `<module>_<name>`: what it asked of the router and which condition it
// raised (manifest state entry asked, novox/hq ADR 0437 §4).
@@ -0,0 +1,46 @@
package main
import (
"context"
"fmt"
"github.com/nats-io/nats.go/jetstream"
)
// **A part does not stop for a bucket it can work without** (novox/hq issue 1945, met twice on 2026-10-25: the builder of
// mesh-delivery #48 exited on a refused read of the gate's source, and its new reasks bucket would have done the same). A
// grant reaches the bus with a delivery, so a bucket the bus refuses, or times out on, at start is often only not granted
// yet: exiting for it stops the part until a person's hand. A bucket the teller cannot do its job at all without still stops
// the start, naming the grant to look at; every other is said and left. (The same two functions are in each delivery part;
// they belong in mesh-sdk.)
// bucketOpener is the part of the bus a start opens buckets with.
type bucketOpener interface {
KeyValue(ctx context.Context, bucket string) (jetstream.KeyValue, error)
}
// essentialBucket opens a bucket the teller cannot work without; its error names the bucket and the grant.
func essentialBucket(ctx context.Context, js bucketOpener, name, does string) (jetstream.KeyValue, error) {
kv, err := js.KeyValue(ctx, name)
if err != nil {
return nil, essentialErr(name, does, err)
}
return kv, nil
}
// essentialErr is the error of a bucket the part cannot work without: it names the bucket and the grant.
func essentialErr(name, does string, err error) error {
return fmt.Errorf("the bucket %s cannot be read (%v): the teller cannot %s without it, so it stops; where the bus "+
"refused it or did not answer, this module's grant of %s (its manifest's reads or state, issued with its credential) "+
"has not reached the bus yet", name, err, does, name)
}
// optionalBucket opens a bucket the teller works without: any error is said with what is not heard, and nil answered.
func optionalBucket(ctx context.Context, js bucketOpener, name, without string, logf func(string, ...any)) jetstream.KeyValue {
kv, err := js.KeyValue(ctx, name)
if err != nil {
logf("the bucket %s cannot be read (%v): %s", name, err, without)
return nil
}
return kv
}
@@ -1,8 +1,11 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/nats-io/nats.go/jetstream"
"strings"
"testing"
"time"
@@ -236,6 +239,16 @@ func (b *bed) askedOf(subject string) []published {
}
func TestAnAsksKeyBecomesOneRouterAskAndDeletingItCancelsIt(t *testing.T) { // 1343/AC1
t.Run("1945: a refused bucket is said, or stops the start naming its grant", func(t *testing.T) {
refusal := errors.New("nats: permissions violation for publish to $JS.API.STREAM.INFO.KV_x")
var said []string
if kv := optionalBucket(context.Background(), refusingBus{refusal}, "x", "x is not heard", func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }); kv != nil || len(said) != 1 || !strings.Contains(said[0], "permissions violation") {
t.Errorf("an optional bucket refused: %v, said %v", kv, said)
}
if _, err := essentialBucket(context.Background(), refusingBus{refusal}, "x", "work"); err == nil || !strings.Contains(err.Error(), "grant of x") {
t.Errorf("an essential bucket refused: %v, want its grant named", err)
}
})
b := newBed(t)
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
b.gate[key] = value
@@ -518,3 +531,8 @@ func TestAnAnswerToAnEarlierAskingDoesNotBlockTheNextAsking(t *testing.T) { // R
t.Errorf("writes %d asks %d dropped %v", b.answers.writes, len(b.askedOf(asks.AskSubject(delivery.TellerModule))), b.dropped)
}
}
// refusingBus refuses every bucket with err (issue 1945).
type refusingBus struct{ err error }
func (r refusingBus) KeyValue(context.Context, string) (jetstream.KeyValue, error) { return nil, r.err }