Files
mesh-delivery/modules/mesh-delivery-sdk/steprun/pull_integration_test.go
T
jschoubben d29e1426d0 Asker rename R2: every asker of a build seat asks under its seat's own name, and its step is keyed so
A-163: the asker in `accept.<queue>.<asker>` is the asking seat's name (mesh-pipeline-runner,
mesh-catalog, mesh-wants, mesh-builds), never a nickname: the builder asks as mesh-builds (was
"builder"), the indexer's validation as mesh-catalog (was "indexer"), the assigner's runs as
mesh-wants (was "assigner"), the runner's steps on `accept.check.mesh-pipeline-runner` and
`accept.delivery.mesh-pipeline-runner` (were `accept.check.step` and `accept.delivery.step`, where
the holder read `step` as the asker). An asker naming none (the checker) asks on main's subject
(A-184). A build seat's queues are delivery, check and recheck alone (A-170, A-184): buildstep's
AskKinds holds no step, a holder takes a step from check or delivery, and the grants part makes a
worker per queue, never per kind. A holder keys a named ask's step `<asker>.<node>.<id>` and main's
`<node>.<id>` (§481, §483); readers watch their own name's keys. The composition step's step-progress
names the one caller mesh-wants' definition lists for step, read from the seat list (A-294).

Builds: design 47 G §481, §483, §543, §600, A-163, A-170, A-184, A-248, A-294

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 18:37:29 +01:00

311 lines
12 KiB
Go

//go:build integration
package steprun
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
sdkstep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/kvlimit"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
func askBus(t *testing.T, ackWait time.Duration) jetstream.JetStream {
t.Helper()
nc, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
if err := DeclareAskWorkers(context.Background(), js, sdkstep.AskGo, ackWait); err != nil {
t.Fatal(err)
}
return js
}
func publishAsk(t *testing.T, js jetstream.JetStream, a sdkstep.StepAsk) {
t.Helper()
raw, err := sdkstep.EncodeStepAsk(a)
if err != nil {
t.Fatal(err)
}
// A build rides main's subject; any other kind a named asker's (design 47 G §543: main takes only a build and a run).
subject, _ := sdkstep.AskSubject(a.Language, a.Kind)
if a.Kind != sdkstep.AskDelivery {
subject, _ = sdkstep.AskSubjectBy(a.Language, a.Kind, "mesh-delivery-checker")
}
if _, err := js.Publish(context.Background(), subject, raw); err != nil {
t.Fatal(err)
}
}
// askKey is the key of a's step on node as publishAsk put it: under its asker, main's form for a build (design 47 G §481).
func askKey(a sdkstep.StepAsk, node string) (string, error) {
if a.Kind == sdkstep.AskDelivery {
return sdkstep.AskStepKey("", node, a.ID)
}
return sdkstep.AskStepKey("mesh-delivery-checker", node, a.ID)
}
// 1887/A2a, 1887/AC-order, 1887/AC-pull on the real server: a check ask published before a delivery ask runs after it;
// with capacity 2 and three asks two run, the third waits in the stream and starts when one ends.
func TestPullOrderAndCapacityOnTheBus(t *testing.T) {
js := askBus(t, 30*time.Second)
check, first, second := testAsk(sdkstep.AskCheck), testAsk(sdkstep.AskDelivery), testAsk(sdkstep.AskDelivery)
publishAsk(t, js, check)
publishAsk(t, js, first)
publishAsk(t, js, second)
release := make(chan struct{})
var mu sync.Mutex
var order []string
st := newSteps()
p := &Puller{Source: &JetStreamAsks{JS: js, Language: sdkstep.AskGo}, Language: sdkstep.AskGo, Node: "shanks", Put: st.put,
Capacity: func() int { return 2 }, PullWait: 200 * time.Millisecond,
Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) {
mu.Lock()
order = append(order, a.ID)
mu.Unlock()
<-release
return nil, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "two in hand", func() bool { return p.InHand() == 2 })
time.Sleep(500 * time.Millisecond)
mu.Lock()
if len(order) != 2 || order[0] != first.ID || order[1] != second.ID {
t.Fatalf("ran %v, want the two delivery asks %s %s first", order, first.ID, second.ID)
}
mu.Unlock()
release <- struct{}{}
waitFor(t, "the check taken", func() bool { mu.Lock(); defer mu.Unlock(); return len(order) == 3 })
close(release)
waitFor(t, "every step ended", func() bool {
for _, a := range []sdkstep.StepAsk{check, first, second} {
key, _ := askKey(a, "shanks")
if s, ok := st.get(key); !ok || s.State != sdkstep.StepPassed {
return false
}
}
return true
})
info, err := js.Stream(ctx, "SEAT_NODE_BUILD_GO")
if err != nil {
t.Fatal(err)
}
waitFor(t, "the stream empty (every ask acknowledged)", func() bool {
si, err := info.Info(ctx)
return err == nil && si.State.Msgs == 0
})
}
// 1887/A2c, ADR 0525 §6: a killed puller's ask is redelivered by the stream's acknowledgement wait to the other node,
// whose step key names the reference.
func TestPullRedeliversAKilledPullersAsk(t *testing.T) {
js := askBus(t, 2*time.Second)
a := testAsk(sdkstep.AskDelivery)
publishAsk(t, js, a)
st := newSteps()
taken := make(chan struct{})
dying := &Puller{Source: &JetStreamAsks{JS: js, Language: sdkstep.AskGo}, Language: sdkstep.AskGo, Node: "ace", Put: st.put,
Capacity: func() int { return 1 }, PullWait: 200 * time.Millisecond, Heartbeat: time.Hour,
Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) {
close(taken)
<-ctx.Done()
return nil, ctx.Err()
}}
kill, killed := context.WithCancel(context.Background())
go dying.Pull(kill)
<-taken
killed()
other := &Puller{Source: &JetStreamAsks{JS: js, Language: sdkstep.AskGo}, Language: sdkstep.AskGo, Node: "shanks", Put: st.put,
Capacity: func() int { return 1 }, PullWait: 200 * time.Millisecond,
Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) {
return sdkstep.BuildAnswer{Artifacts: []sdkstep.AskBuilt{{Artifact: "bundle", Digest: "sha256:00", Reference: "artifact-store://x"}}}, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go other.Pull(ctx)
key, _ := askKey(a, "shanks")
deadline := time.Now().Add(10 * time.Second)
for {
if s, ok := st.get(key); ok && s.State == sdkstep.StepPassed {
if want := `artifact-store://x`; !contains(string(s.Result), want) {
t.Fatalf("the step's result %s names no %s", s.Result, want)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("the killed puller's ask was not taken by shanks within 10 s")
}
time.Sleep(20 * time.Millisecond)
}
}
// ADR 0664 §1, §5 (issue 1748) on the real server: a run that ends on a transport fault is written again and the bus gives the
// ask again after the delay, not before, as the second run (its delivery count one higher), and that run's end is the ask's;
// a step that runs when its ask is dropped ends dropped, naming who dropped it, and the ask is not given again.
func TestAnAskThatMetTheNetworkIsGivenAgainAndADroppedOneIsEnded(t *testing.T) {
js := askBus(t, 30*time.Second)
a, doomed := testAsk(sdkstep.AskDelivery), testAsk(sdkstep.AskDelivery)
publishAsk(t, js, a)
st, kv := newSteps(), &pauseKV{}
const delay = 600 * time.Millisecond
var mu sync.Mutex
var runs []time.Time
hold := make(chan struct{})
p := &Puller{Source: &JetStreamAsks{JS: js, Language: sdkstep.AskGo}, Language: sdkstep.AskGo, Node: "ace", Put: st.put, PausePut: kv.put, Load: kv.load,
ForgetPause: kv.forget, PauseKeys: kv.keys, Capacity: func() int { return 1 }, PullWait: 100 * time.Millisecond,
Delay: func(int) time.Duration { return delay },
Run: func(ctx context.Context, got sdkstep.StepAsk) (any, error) {
if got.ID == doomed.ID {
close(hold)
<-ctx.Done()
return nil, ctx.Err()
}
mu.Lock()
runs = append(runs, time.Now())
n := len(runs)
mu.Unlock()
if n == 1 {
return nil, &sdkstep.Passing{Err: errors.New("lookup git.novox.be: i/o timeout")}
}
return nil, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
key, _ := askKey(a, "ace")
waitFor(t, "the ask's second run ended", func() bool { s, ok := st.get(key); return ok && s.State == sdkstep.StepPassed })
mu.Lock()
gap := runs[1].Sub(runs[0])
mu.Unlock()
if s, _ := st.get(key); len(runs) != 2 || gap < delay || s.Run != 2 {
t.Errorf("runs %d, %s apart (delay %s), the step's run %d: want two runs, the second after the delay, as run 2", len(runs), gap, delay, s.Run)
}
publishAsk(t, js, doomed)
<-hold
if err := p.Drop(doomed.ID, "mesh-delivery-builder", "superseded"); err != nil {
t.Fatal(err)
}
dkey, _ := askKey(doomed, "ace")
waitFor(t, "the dropped step dropped", func() bool { s, ok := st.get(dkey); return ok && s.State == sdkstep.StepDropped })
if s, _ := st.get(dkey); !contains(s.Reason, "dropped by mesh-delivery-builder: superseded") {
t.Errorf("the dropped step says %q", s.Reason)
}
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
// Design 47 G §544: main's worker takes an ask put on main's subject and the named worker one put on the asker's, the two
// filters not overlapping.
func TestTheTwoWorkersTakeTheTwoFormsOfTheAcceptSubject(t *testing.T) {
js := askBus(t, 30*time.Second)
stream, _ := sdkstep.AskStream(sdkstep.AskGo)
worker, _ := sdkstep.AskWorker(sdkstep.AskGo, sdkstep.AskDelivery)
named, _ := sdkstep.AskNamedWorker(sdkstep.AskGo, sdkstep.AskDelivery)
main, _ := sdkstep.AskSubject(sdkstep.AskGo, sdkstep.AskDelivery)
for _, subject := range []string{main, main + ".mesh-builds"} {
if _, err := js.Publish(context.Background(), subject, []byte("{}")); err != nil {
t.Fatal(err)
}
}
for name, want := range map[string]string{worker: main, named: main + ".mesh-builds"} {
c, err := js.Consumer(context.Background(), stream, name)
if err != nil {
t.Fatal(err)
}
batch, err := c.Fetch(2, jetstream.FetchMaxWait(time.Second))
if err != nil {
t.Fatal(err)
}
var got []string
for m := range batch.Messages() {
got = append(got, m.Subject())
_ = m.Ack()
}
if len(got) != 1 || got[0] != want {
t.Errorf("%s took %v, want only %s", name, got, want)
}
}
}
// Design 47 G A-290 on the real server: a check's answer over what the steps state holds per value (a bucket made with
// kvlimit.ModuleStateValueBytes, as every module's is) is kept as the object output.<ask> in the outputs store, the step's key
// names it and ends passed, and the answer reads back whole through the seat's verb output (Step.Whole).
func TestACheckAnswerOverTheStepsStateIsKeptAndReadBackWhole(t *testing.T) {
js := askBus(t, 30*time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "steps-a290", MaxValueSize: kvlimit.ModuleStateValueBytes})
if err != nil {
t.Fatal(err)
}
objects, err := js.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{Bucket: "outputs-a290"})
if err != nil {
t.Fatal(err)
}
put := func(key string, value any) (uint64, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
return kv.Put(ctx, key, raw)
}
answer := map[string]any{"fetch": map[string]any{"workspace": "/w"}, "levels": []any{map[string]any{"level": "unit", "said": strings.Repeat("z", 2*kvlimit.ModuleStateValueBytes)}}}
a := testAsk(sdkstep.AskCheck)
publishAsk(t, js, a)
p := &Puller{Source: &JetStreamAsks{JS: js, Language: sdkstep.AskGo}, Language: sdkstep.AskGo, Node: "ace", Put: put,
Capacity: func() int { return 1 }, PullWait: 100 * time.Millisecond,
KeepOutput: func(object string, body []byte) error { _, err := objects.PutBytes(ctx, object, body); return err },
Run: func(context.Context, sdkstep.StepAsk) (any, error) { return answer, nil }}
go p.Pull(ctx)
key, _ := askKey(a, "ace")
var step sdkstep.Step
waitFor(t, "the check's step ended", func() bool {
e, err := kv.Get(ctx, key)
if err != nil {
return false
}
step, err = sdkstep.DecodeStep(e.Value())
return err == nil && sdkstep.Ended(step)
})
if step.State != sdkstep.StepPassed || step.OutputObject != sdkstep.OutputObjectName(a.ID) || len(step.Result) != 0 {
t.Fatalf("the key ended %s (%s) naming %q with %d bytes of result, want passed, naming the object and no result", step.State, step.Reason, step.OutputObject, len(step.Result))
}
verb := func(k string, body any) (json.RawMessage, error) {
if k != "seat:node-build-go.output@ace" {
return nil, fmt.Errorf("asked %s", k)
}
data, err := objects.GetBytes(ctx, sdkstep.OutputObjectName(body.(map[string]any)["ask"].(string)))
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{"ask": a.ID, "file": base64.StdEncoding.EncodeToString(data)})
}
whole, err := step.Whole(sdkstep.AskGo, false, verb)
if err != nil {
t.Fatal(err)
}
want, _ := json.Marshal(answer)
if string(whole.Result) != string(want) {
t.Fatalf("read back %d bytes, want the whole answer of %d", len(whole.Result), len(want))
}
}