The view says its event delivered once for each source revision whose wants and haves are all there (issue 1906 D9, for issue 1946)

This commit is contained in:
2026-10-24 16:47:09 +02:00
parent 37a5ab8f1c
commit 86fd0bbe96
6 changed files with 243 additions and 23 deletions
@@ -696,3 +696,98 @@ func TestProcessWholeShowsTheBacklogOfBuildAsks(t *testing.T) { // 1887/A8a AC
}
t.Fatalf("the backlog never showed three waiting asks with the oldest's age; last: %+v", last.Backlog)
}
// 1906/D9, whole on the bus: when the wants and the haves make the source revision delivered, the view says it once, as its event
// `delivered` with the revision, the group and the heads, which the issue tracker derives "fix delivered" from.
func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
srv, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
if err != nil {
t.Fatal(err)
}
go srv.Start()
if !srv.ReadyForConnections(10 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(srv.Shutdown)
nc, err := nats.Connect(srv.ClientURL())
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
events, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "VIEWEVENTS", Subjects: []string{"mesh.mod.mesh-delivery-view.event.>"}})
if err != nil {
t.Fatal(err)
}
kvs := map[string]jetstream.KeyValue{}
for _, name := range []string{delivery.SourceBucket, delivery.ModulesBucket, delivery.AssignerWantsBucket, delivery.HaveBucket} {
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: 5})
if err != nil {
t.Fatal(err)
}
kvs[name] = kv
}
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "i")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
broker := filepath.Join(dir, "broker")
cred, _ := json.Marshal(credential{URL: srv.ClientURL(), Node: "n", Module: "mesh-delivery-view", User: "mesh-delivery-view", Password: "x"})
if err := os.WriteFile(broker, cred, 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": filepath.Join(dir, "run", "cli.sock"), "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
fp := "src2:" + strings.Repeat("2", 64)
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: strings.Repeat("a", 64), Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Composed: "sha256:" + strings.Repeat("c", 64)})
failed, _ := delivery.EncodeHave(delivery.Have{FP: fp, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed})
good, _ := delivery.EncodeHave(delivery.Have{FP: fp, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
src, _ := delivery.EncodeSource(delivery.Source{Group: "feat/x", Heads: map[string]string{"novox/web": strings.Repeat("1", 40)}})
for bucket, kv := range map[string][]byte{delivery.ModulesBucket: mod} {
if _, err := kvs[bucket].Put(ctx, "web", kv); err != nil {
t.Fatal(err)
}
}
if _, err := kvs[delivery.AssignerWantsBucket].Put(ctx, "want.ace.web", want); err != nil {
t.Fatal(err)
}
if _, err := kvs[delivery.HaveBucket].Put(ctx, "ace.web", failed); err != nil {
t.Fatal(err)
}
rev, err := kvs[delivery.SourceBucket].Put(ctx, delivery.SourceMainKey, src)
if err != nil {
t.Fatal(err)
}
time.Sleep(time.Second)
if info, err := events.Info(ctx); err != nil || info.State.Msgs != 0 {
t.Fatalf("an event was said before the revision was delivered: %v %+v", err, info.State)
}
if _, err := kvs[delivery.HaveBucket].Put(ctx, "ace.web", good); err != nil {
t.Fatal(err)
}
var msg *jetstream.RawStreamMsg
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline) && msg == nil; time.Sleep(100 * time.Millisecond) {
msg, _ = events.GetLastMsgForSubject(ctx, "mesh.mod.mesh-delivery-view.event."+DeliveredEventType)
}
if msg == nil {
t.Fatal("the delivery of the source revision was never said")
}
var e view.DeliveredEvent
if err := json.Unmarshal(msg.Data, &e); err != nil || e.Revision != rev || e.Group != "feat/x" || e.Heads["novox/web"] != strings.Repeat("1", 40) {
t.Errorf("the event = %+v, %v; want revision %d, group feat/x and the heads", e, err, rev)
}
if msg.Header.Get("x-source") != "mesh-delivery-view" || msg.Header.Get("x-event-id") == "" {
t.Errorf("the headers = %v", msg.Header)
}
}
@@ -138,6 +138,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return err
}
model.SetWriter(kvWriter{ctx: ctx, js: js})
model.SetDeliveredEvent(func(e view.DeliveredEvent) {
if err := emitEvent(ctx, js, cred, DeliveredEventType, e, fmt.Sprintf("delivered.%d", e.Revision)); err != nil {
logger.Printf("the delivery of source revision %d could not be said: %v", e.Revision, err)
}
})
model.SetBacklog(view.BacklogOver(js, 5*time.Second))
for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket, delivery.AsksBucket, delivery.AnswersBucket, delivery.BuilderBuildsBucket, delivery.AssignerWantsBucket, delivery.SourceBucket, delivery.ControllerComposedBucket, delivery.AssignerAsksBucket, delivery.ViewAnswersBucket} {
go follow(ctx, js, bucket, model, logger)
@@ -211,3 +216,28 @@ func watchOnce(ctx context.Context, js jetstream.JetStream, bucket string, model
}
}
}
// DeliveredEventType is the event the view says when a source revision is delivered: mesh.mod.mesh-delivery-view.event.delivered
// (issue 1906 D9, ADR 0539 §17). Its body is view.DeliveredEvent.
const DeliveredEventType = "delivered"
// emitEvent publishes an event of the view's own namespace, mesh.mod.<module>.event.<type>, with the SDK's envelope headers
// (as mesh-sdk's process.Conn.Emit does) and the id as the bus's own dedup id, so the same revision said twice in the stream's
// duplicate window is kept once.
func emitEvent(ctx context.Context, js jetstream.JetStream, cred credential, eventType string, body any, id string) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
msg := nats.NewMsg("mesh.mod." + cred.Module + ".event." + eventType)
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", cred.Module)
msg.Header.Set("x-node", cred.Node)
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = data
pub, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = js.PublishMsg(pub, msg, jetstream.WithMsgID(id))
return err
}
@@ -119,6 +119,8 @@ type SourceRow struct {
Revision uint64 `json:"revision"`
State string `json:"state"`
Waiting []string `json:"waiting,omitempty"`
// Wants is how many wants of modules at the indexer were looked at; a revision with none is delivered of nothing.
Wants int `json:"wants"`
}
// AskRow is an open ask (novox/hq ADR 0437 §10): the gate's ask with no answer under its key in the teller's answers.
@@ -210,24 +212,26 @@ type UnreadableRow struct {
// Model holds the rows' inputs in memory and nothing else: a restart fills it again from the buckets' last values.
type Model struct {
mu sync.Mutex
assigns map[string]assignment
builds map[string]buildEntry // by <module>.build.<id>
groups map[string]groupEntry // the indexer's candidates, by group
modules map[string]moduleEntry // the indexer's modules, by module
keys map[string]keyEntry // the builder's build keys, by <module>.<fingerprint>
asks map[string]askRecord // the gate's asks, by key
wants map[string]wantEntry // the assigner's wants, by <node>.<module>
source *sourceEntry // the gate's source key
composed map[string]composedEntry // the controller's digests and judgements, by <node>.<module>.<hex>
aasks map[string]assignerAskRecord // the assigner's asks (hold, restore), by key
chosen map[string]delivery.ViewAnswer // the answers the view wrote, by ask key
writer Writer
backlog *Backlog
answers map[string]string // by key: the router id of the asking the teller's answer is for ("" when unreadable)
newest map[string]string // module -> id
haves map[string]haveEntry
nodes map[string]nodeEntry
mu sync.Mutex
assigns map[string]assignment
builds map[string]buildEntry // by <module>.build.<id>
groups map[string]groupEntry // the indexer's candidates, by group
modules map[string]moduleEntry // the indexer's modules, by module
keys map[string]keyEntry // the builder's build keys, by <module>.<fingerprint>
asks map[string]askRecord // the gate's asks, by key
wants map[string]wantEntry // the assigner's wants, by <node>.<module>
source *sourceEntry // the gate's source key
deliveredEvent func(DeliveredEvent)
said uint64 // the source revision whose delivery was said
composed map[string]composedEntry // the controller's digests and judgements, by <node>.<module>.<hex>
aasks map[string]assignerAskRecord // the assigner's asks (hold, restore), by key
chosen map[string]delivery.ViewAnswer // the answers the view wrote, by ask key
writer Writer
backlog *Backlog
answers map[string]string // by key: the router id of the asking the teller's answer is for ("" when unreadable)
newest map[string]string // module -> id
haves map[string]haveEntry
nodes map[string]nodeEntry
// the watchman's buckets, by key
presence map[string]presenceEntry
stuck map[string]stuckEntry
@@ -302,6 +306,8 @@ type composedEntry struct {
type sourceEntry struct {
revision uint64
group string
heads map[string]string
err string
}
@@ -339,10 +345,50 @@ func New() *Model {
presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{}}
}
// Apply takes one change of a key. A value it cannot read is kept as an error on its row, never guessed.
// Apply takes one change of a key. A value it cannot read is kept as an error on its row, never guessed. When the change makes
// the source revision delivered, the event of it is said once (SetDeliveredEvent), after the model's lock is released.
func (m *Model) Apply(e Entry) {
m.mu.Lock()
m.apply(e)
event := m.deliveredNow()
say := m.deliveredEvent
m.mu.Unlock()
if event != nil && say != nil {
say(*event)
}
}
// DeliveredEvent is what the view says when the source revision is delivered (event `delivered` of the view, issue 1906 D9, ADR
// 0539 §17): the revision of the gate's source key, the group that moved it and every repository's merged head at it. A
// consumer takes every earlier revision as covered by it, and takes the same revision twice as once (after a restart of the view).
type DeliveredEvent struct {
Revision uint64 `json:"revision"`
Group string `json:"group"`
Heads map[string]string `json:"heads"`
}
// SetDeliveredEvent sets where the event of a delivered source revision goes; nothing is said until it is set.
func (m *Model) SetDeliveredEvent(say func(DeliveredEvent)) {
m.mu.Lock()
defer m.mu.Unlock()
m.deliveredEvent = say
}
// deliveredNow is the event of the source revision when it is delivered now and has not been said, or nil. A revision that went
// bad after it was delivered and recovered is not said again.
func (m *Model) deliveredNow() *DeliveredEvent {
if m.deliveredEvent == nil || m.source == nil || m.source.err != "" || m.said == m.source.revision {
return nil
}
row := m.sourceRow()
if row == nil || row.State != SourceDelivered || row.Wants == 0 {
return nil
}
m.said = m.source.revision
return &DeliveredEvent{Revision: m.source.revision, Group: m.source.group, Heads: m.source.heads}
}
func (m *Model) apply(e Entry) {
switch e.Bucket {
case delivery.HaveBucket:
node, module, isNode, err := delivery.ParseHaveKey(e.Key)
@@ -456,8 +502,8 @@ func (m *Model) Apply(e Entry) {
m.source = nil
return
}
_, err := delivery.DecodeSource(e.Value)
m.source = &sourceEntry{revision: e.Revision, err: errText(err)}
src, err := delivery.DecodeSource(e.Value)
m.source = &sourceEntry{revision: e.Revision, group: src.Group, heads: src.Heads, err: errText(err)}
case delivery.AnswersBucket:
if e.Deleted {
delete(m.answers, e.Key)
@@ -963,12 +1009,14 @@ func (m *Model) sourceRow() *SourceRow {
}
return fp
}
looked := 0
for key, w := range m.wants {
node, module, _ := strings.Cut(key, ".")
mod, ok := m.modules[module]
if w.err != "" || w.value.Removed || !ok || mod.err != "" || mod.value.FP2 == "" {
continue
}
looked++
h := m.haves[key]
switch {
case w.value.FP != mod.value.FP2:
@@ -993,7 +1041,7 @@ func (m *Model) sourceRow() *SourceRow {
}
}
sort.Strings(waiting)
row := &SourceRow{Revision: m.source.revision, State: SourceDelivered}
row := &SourceRow{Revision: m.source.revision, State: SourceDelivered, Wants: looked}
if len(waiting) > 0 {
row.State, row.Waiting = SourceNotDelivered, waiting
}
@@ -156,3 +156,37 @@ func TestTheJudgementOfTheMoveIsShownBesideTheWant(t *testing.T) {
t.Errorf("text = %v", text)
}
}
// 1906/D9 (asked by the lead for issue 1946): the view says, once for each source revision, that it is delivered: the event the
// issue tracker derives "fix delivered" from, in place of the gate's states that go in release 6 (ADR 0539 §17).
func TestTheViewSaysOnceThatASourceRevisionIsDelivered(t *testing.T) {
m := New()
var said []DeliveredEvent
m.SetDeliveredEvent(func(e DeliveredEvent) { said = append(said, e) })
fill(m, sourceE(t, 14355), moduleE(t, "web", fpNew), assign("ace", "web", "b-1", ""),
wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
if len(said) != 0 {
t.Fatalf("said %v before any have", said)
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthFailed))
if len(said) != 0 {
t.Fatalf("said %v for a failed have", said)
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
if len(said) != 1 || said[0].Revision != 14355 || said[0].Group != "g" || said[0].Heads["novox/web"] == "" {
t.Fatalf("said = %+v, want revision 14355 once with its group and heads", said)
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK), wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
if len(said) != 1 {
t.Errorf("said again for the same revision: %v", said)
}
// a newer revision is delivered when its wants and haves are; a revision that goes bad and good again is not said twice
fill(m, sourceE(t, 14356))
if len(said) != 2 || said[1].Revision != 14356 {
t.Errorf("the next revision: %v", said)
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthFailed), haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
if len(said) != 2 {
t.Errorf("said again after a have failed and recovered: %v", said)
}
}
+11 -1
View File
@@ -89,11 +89,21 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
if !reflect.DeepEqual(got, want) {
t.Errorf("reads = %v, want exactly %v", got, want)
}
for _, k := range []string{"emits", "consumes", "provides", "claims"} {
for _, k := range []string{"consumes", "provides", "claims"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the view writes only its answers", k)
}
}
// The one event the view says: a source revision is delivered (1906/D9, ADR 0539 §17), derived by it from the wants and the haves.
var emits []string
if raw, ok := top["emits"]; ok {
if err := json.Unmarshal(raw, &emits); err != nil {
t.Fatal(err)
}
}
if !reflect.DeepEqual(emits, []string{"delivered"}) {
t.Errorf("emits = %v, want exactly [delivered]", emits)
}
// The view uses no build seat: it reads their workers by the broker's named grant and publishes no ask (ADR 0525 §2).
if len(m.Uses) != 0 {
t.Errorf("uses = %v, want none", m.Uses)
+3
View File
@@ -15,6 +15,9 @@
"assigner_differences",
"node_haves"
],
"emits": [
"delivered"
],
"state": [
{ "name": "answers", "history": 5 }
],