DLV-O1, DLV-O3, DLV-O4: the indexer answers its first views and says its views family

The indexer's tools bundle runs in a runtime of its own on its own account
(runs-as) and serves the first-view tools manifests, modules and verdicts,
each answering only the callers its manifest writes (ADR 0621 §1-§2, ADR
0622 §1), and the nine read tools of design 25 § 12.2. The process says each
change of its ten read states on the family indexer-views in its old streams
section (7 days, 256 MiB), with a mark per state the first view answers its
revision from, so a reader that takes the answer misses no event after it
(ADR 0615 §4-§5). The shared shape lives in sdk/views: event, answer, mark,
the emitter, the tools and the reader's follower.

Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 12:22:26 +01:00
parent ae31b66a9e
commit 5d7fff91ce
15 changed files with 1582 additions and 19 deletions
@@ -192,7 +192,7 @@ func heldCounts(s stdio.KeptState) func() (index.OldSectionsCount, error) {
}
func main() {
if err := stdio.Serve("", append(Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState))), catalogTools()...)); err != nil {
if err := stdio.Serve("", append(Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState))), append(catalogTools(), viewTools(os.Getenv, keptSource, time.Now)...)...)); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-indexer-tools] %v\n", err)
os.Exit(1)
}
@@ -0,0 +1,31 @@
package main
import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/views"
"mesh-delivery-indexer/internal/index"
)
// viewTools are the indexer's first-view tools (ADR 0621 §1, ADR 0622 §1), each answering only the callers its manifest
// writes into its env value (views.CallersEnv), and its ordinary read tools (design 25 § 12.2). Every one reads only the
// indexer's own states, so it answers as soon as the runtime of its own is connected (ADR 0621 §3).
func viewTools(getenv func(string) string, state func(string) views.Source, now func() time.Time) []stdio.Tool {
marks := state(views.MarksState)
var out []stdio.Tool
for _, t := range index.FirstViewTools {
out = append(out, views.FirstViewTool(delivery.IndexerModule, t.Tool, t.State, t.Words,
views.ParseCallers(getenv(views.CallersEnv(t.Tool))), state(t.State), marks, now))
}
for _, t := range index.ReadTools {
out = append(out, views.ReadTool(delivery.IndexerModule, t.Tool, t.State, t.Words, state(t.State), marks, now))
}
return out
}
// keptSource is one of the indexer's states as its runtime reads it.
func keptSource(name string) views.Source { return views.KeptSource{State: stdio.State(name)} }
@@ -5,11 +5,13 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
@@ -20,6 +22,10 @@ import (
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/sdk/views"
"mesh-delivery-indexer/internal/index"
)
// busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the
@@ -83,8 +89,115 @@ func TestIndexerWhole(t *testing.T) { // 1238/AC2, AC10
t.Errorf("%s#%d modules = %v, want [%s]", p.Repository, p.Number, p.Modules, want)
}
}
// ADR 0615 §5 (DLV-O4): each change of the candidates is said on the indexer's views family, its value whole at its
// revision; a first view taken at the mark, followed by the events after it, holds the candidate as the bucket does;
// and a waiter on manifest-accepted never wakes on it.
views0 := waitViews(t, ctx, js, "candidates", "feat/x")
cur, err := candidates.Get(ctx, "feat/x")
if err != nil || views0.Revision > cur.Revision() || views0.Deleted || len(views0.Value) == 0 || !views0.Created.Equal(bucketCreated(t, ctx, candidates)) {
t.Errorf("views event = %+v, bucket at %d (%v)", views0, cur.Revision(), err)
}
marks, _ := js.KeyValue(ctx, index.MarksBucket)
f := &views.Follower{Owner: delivery.IndexerModule, Tool: "indexer_read_candidates", State: "candidates",
Ask: func(context.Context, string) (views.Answer, error) {
return views.FirstView(delivery.IndexerModule, "candidates", jsSource{ctx, candidates}, jsSource{ctx, marks}, "", time.Now())
}}
if err := f.Start(ctx); err != nil {
t.Fatal(err)
}
for _, e := range viewsEvents(t, ctx, js, "candidates") {
if gap, why := f.Apply(e); gap {
t.Errorf("a gap after the first view: %s", why)
}
}
if v, ok := f.Get("feat/x"); !ok || string(v) != string(cur.Value()) {
t.Errorf("the follower holds %s, the bucket %s", v, cur.Value())
}
if n := acceptedHeard.Load(); n != 0 {
t.Errorf("a waiter on manifest-accepted woke %d times on the views family", n)
}
}
// jsSource is a bucket as a views.Source.
type jsSource struct {
ctx context.Context
kv jetstream.KeyValue
}
func (s jsSource) Keys() ([]string, error) {
k, err := s.kv.Keys(s.ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return k, err
}
func (s jsSource) Get(key string) (*views.Entry, bool, error) {
e, err := s.kv.Get(s.ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return &views.Entry{Value: e.Value(), Revision: e.Revision()}, true, nil
}
func bucketCreated(t *testing.T, ctx context.Context, kv jetstream.KeyValue) time.Time {
t.Helper()
st, err := kv.Status(ctx)
if err != nil {
t.Fatal(err)
}
return st.(*jetstream.KeyValueBucketStatus).StreamInfo().Created.UTC()
}
// viewsEvents are every event the views family holds for state, in order, read message by message.
func viewsEvents(t *testing.T, ctx context.Context, js jetstream.JetStream, state string) []views.Event {
t.Helper()
st, err := js.Stream(ctx, views0Stream)
if err != nil {
t.Fatal(err)
}
info, err := st.Info(ctx)
if err != nil {
t.Fatal(err)
}
var out []views.Event
for seq := info.State.FirstSeq; seq > 0 && seq <= info.State.LastSeq; seq++ {
m, err := st.GetMsg(ctx, seq)
if err != nil || m.Subject != "mesh.stream.mesh-delivery-indexer.event."+views.EventName(state) {
continue
}
e, err := views.DecodeEvent(m.Data)
if err != nil {
t.Fatal(err)
}
out = append(out, e)
}
return out
}
// waitViews waits for the views family's event of state's key.
func waitViews(t *testing.T, ctx context.Context, js jetstream.JetStream, state, key string) views.Event {
t.Helper()
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
for _, e := range viewsEvents(t, ctx, js, state) {
if e.Key == key {
return e
}
}
}
t.Fatalf("no views event of %s.%s within 20 s", state, key)
return views.Event{}
}
const views0Stream = "INDEXER_VIEWS"
// acceptedHeard counts what a waiter on manifest-accepted heard.
var acceptedHeard atomic.Int64
// startIndexer starts the bus, the stream FORGE with the indexer's durable consumer, the buckets, lets put fill
// mesh-controller_builds, and runs the program. mesh-delivery-gate_source is never created.
func startIndexer(t *testing.T, put func(context.Context, buildLog), before ...func(context.Context, jetstream.JetStream)) (jetstream.JetStream, context.Context, jetstream.KeyValue, jetstream.KeyValue) {
@@ -155,6 +268,20 @@ func startIndexer(t *testing.T, put func(context.Context, buildLog), before ...f
Subjects: []string{buscompose.LogSubject(delivery.BuilderModule, delivery.BuilderLog) + ".>"}, AllowDirect: true}); err != nil {
t.Fatal(err)
}
// The indexer's views family and its marks (ADR 0615 §5), as the grants part makes them from the old sections, and a
// waiter on manifest-accepted, which the views family never wakes.
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: views0Stream, Subjects: []string{"mesh.stream.mesh-delivery-indexer.event.views.>"}}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: index.MarksBucket, History: 1}); err != nil {
t.Fatal(err)
}
acceptedHeard.Store(0)
waiter, err := ctl.Subscribe("mesh.mod.mesh-delivery-indexer.event.manifest-accepted", func(*nats.Msg) { acceptedHeard.Add(1) })
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = waiter.Unsubscribe() })
put(ctx, buildLog{ctx: ctx, js: js})
for _, b := range before {
b(ctx, js)
@@ -469,6 +469,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// What the indexer heard of FORGE (issue 1126 note 76) is bound when its state appears, as every state a delivery adds
// (issue 1790): until then no message is written there, and the rest of the indexer runs.
heard := awaitedStore{process.AwaitBucket(ctx, js, conn, index.HeardBucket, "no FORGE message the indexer handles is written for indexer_heard", logger.Printf)}
// Each change of the states its readers read is said on its views family (ADR 0615 §5), from the mark each state was
// said up to; a first view (the tools bundle) answers at that mark.
startViews(ctx, js, cred, process.AwaitBucket(ctx, js, conn, index.MarksBucket, "no change is said on the views family, so every reader waits on its first view", logger.Printf),
process.AwaitBucket(ctx, js, conn, findingsBucket, "no finding of the indexer is written", logger.Printf),
map[string]readyOf{"manifests": boundNow(manifests), "modules": boundNow(modules), "candidates": boundNow(candidates),
index.VerdictsState: awaited(verdicts), delivery.IndexerPredefinedState: awaited(predefined),
delivery.IndexerConfigCandidatesState: awaited(configCandidates), delivery.PackagesState: awaited(packages),
graphState: awaited(graph), index.HeardState: awaited(heard.b), "taken": awaited(taken)}, errs, logger.Printf)
cc, err := process.HearEvents(cons, func(m jetstream.Msg) { handle(ctx, m, ix, heard, logger); ix.KickFill(ctx) }, logger.Printf)
if err != nil {
return err
@@ -0,0 +1,73 @@
package main
import (
"context"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-delivery/sdk/views"
)
// findingsBucket is the indexer's own findings, which the condition keeper raises and clears (ADR 0564 §2): a value too
// large for its views event, and its first view of the assigner's placement unanswered past its bound.
var findingsBucket = health.FindingsBucket(delivery.IndexerModule)
// readyOf binds a state's bucket: now for one bound at the start, once it appears for one awaited.
type readyOf func(func(jetstream.KeyValue))
func boundNow(kv jetstream.KeyValue) readyOf { return func(f func(jetstream.KeyValue)) { f(kv) } }
func awaited(b *process.AwaitedBucket) readyOf { return b.Ready }
// startViews says each change of the indexer's viewed states on its views family (ADR 0615 §5), once its marks state
// is bound: each state from where its mark says, as soon as its own bucket is bound. A watch that ends is sent on errs.
func startViews(ctx context.Context, js jetstream.JetStream, cred process.Account, marks, findings *process.AwaitedBucket, states map[string]readyOf, errs chan<- error, logf func(string, ...any)) {
tooLarge := func(state, key string, size int) {
kv := findings.KV()
if kv == nil {
return
}
k, v, err := views.TooLargeFinding(delivery.IndexerModule, state, key, size, time.Now())
if err == nil {
_, err = kv.Put(ctx, k, v)
}
if err != nil {
logf("views: the finding of %s.%s too large is not written: %v", state, key, err)
}
}
marks.Ready(func(mk jetstream.KeyValue) {
em := views.Emitter{Owner: delivery.IndexerModule, Subject: cred.StreamSubject, Publish: views.JetStreamPublish(js),
Marks: mk, TooLarge: tooLarge, Logf: logf}
for _, state := range sortedStates(states) {
states[state](func(kv jetstream.KeyValue) {
if err := em.Start(ctx, state, kv, errs); err != nil {
logf("%v: its readers ask its first view at each change until the indexer starts again", err)
}
})
}
})
}
func sortedStates(states map[string]readyOf) []string {
out := make([]string, 0, len(states))
for s := range states {
out = append(out, s)
}
for i := range out {
for j := i + 1; j < len(out); j++ {
if out[j] < out[i] {
out[i], out[j] = out[j], out[i]
}
}
}
return out
}
// graphState is the module graph's state, named apart from run's bucket of the same name.
const graphState = graph.State
@@ -0,0 +1,55 @@
package index
import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-delivery/sdk/views"
)
// ViewsStream is the indexer's views family on its old streams section (ADR 0615 §5): one event per changed key of
// each state in ViewedStates.
const ViewsStream = views.IndexerStream
// MarksBucket is the indexer's state of what its views family said each state up to.
const MarksBucket = delivery.IndexerModule + "_" + views.MarksState
// FirstViewTools are the indexer's first-view tools (ADR 0621 §1, ADR 0622 §1): the tool, the state it answers, and
// the words of what that state holds. Each answers only its callers list (CallersEnv), which the manifest writes.
var FirstViewTools = []ViewedTool{
{Tool: "manifests", State: "manifests", Words: "every manifest the indexer keeps, by its digest."},
{Tool: "modules", State: "modules", Words: "every module the indexer holds, its fingerprint, inputs and manifest digest."},
{Tool: "verdicts", State: VerdictsState, Words: "the judge's verdict on each manifest: accepted, waiting or refused."},
}
// ReadTools are the indexer's ordinary read tools, one per state its readers read (design 25 § 12.2).
var ReadTools = []ViewedTool{
{Tool: "indexer_read_manifests", State: "manifests", Words: "every manifest the indexer keeps, by its digest."},
{Tool: "indexer_read_modules", State: "modules", Words: "every module the indexer holds, its fingerprint, inputs and manifest digest."},
{Tool: "indexer_read_predefined", State: delivery.IndexerPredefinedState, Words: "the predefined config values of each module on the trunk."},
{Tool: "indexer_read_config_candidates", State: delivery.IndexerConfigCandidatesState, Words: "the config declarations of each open pull request."},
{Tool: "indexer_read_candidates", State: "candidates", Words: "the candidate groups: the pull requests sharing a branch, their heads and the modules each touches."},
{Tool: "indexer_read_packages", State: delivery.PackagesState, Words: "the published packages per series."},
{Tool: "indexer_read_graph", State: graph.State, Words: "the module graph: each module's build and provision edges."},
{Tool: "indexer_read_heard", State: HeardState, Words: "the forge messages the indexer heard and what it did with each."},
{Tool: "indexer_read_taken", State: "taken", Words: "each repository's merge the indexer took in last."},
}
// ViewedTool is one tool over one state of the indexer's.
type ViewedTool struct {
Tool, State, Words string
}
// ViewedStates are the states the indexer's views family says, each by its bucket: the ten its readers read.
var ViewedStates = map[string]string{
"manifests": delivery.ManifestsBucket,
"modules": delivery.ModulesBucket,
VerdictsState: VerdictsBucket,
delivery.IndexerPredefinedState: PredefinedBucket,
delivery.IndexerConfigCandidatesState: ConfigCandidatesBucket,
"candidates": delivery.CandidatesBucket,
delivery.PackagesState: delivery.PackagesBucket,
graph.State: graph.Bucket,
HeardState: HeardBucket,
"taken": delivery.TakenBucket,
}
+62 -14
View File
@@ -16,6 +16,10 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-delivery/sdk/views"
"git.novox.be/novox/mesh-sdk/go/health"
"mesh-delivery-indexer/internal/index"
)
// The controller's manifest reader is mesh-controller internal/catalogue (Manifest, manifest.go: Consumes :521,
@@ -29,19 +33,29 @@ type state struct {
}
type artifact struct {
Name string `json:"name"`
Kind string `json:"kind"`
Language string `json:"language"`
System string `json:"system"`
From string `json:"from"`
Binary string `json:"binary"`
Loads []string `json:"loads"`
Name string `json:"name"`
Kind string `json:"kind"`
Language string `json:"language"`
System string `json:"system"`
From string `json:"from"`
Binary string `json:"binary"`
Loads []string `json:"loads"`
Env map[string]string `json:"env"`
}
type stream struct {
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
MaxMiB int `json:"max-mib"`
}
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Slug string `json:"slug"`
Module string `json:"module"`
Version string `json:"version"`
Slug string `json:"slug"`
RunsAs string `json:"runs-as"`
Streams []stream `json:"streams"`
Claims []struct {
Name string `json:"name"`
Scope string `json:"scope"`
@@ -99,7 +113,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build
}
// 1126 note 76: heard keeps the last 64 FORGE messages the indexer handled, for indexer_heard.
if want := []state{{Name: "candidates", History: 10}, {Name: "heard", History: 64}, {Name: "manifests", History: 1}, {Name: "modules", History: 20}, {Name: "predefined", History: 5}, {Name: "config-candidates", History: 5}, {Name: "taken", History: 1}, {Name: "takeins", History: 1}, {Name: "verdicts", History: 5}, {Name: "oldsections", History: 1}, {Name: delivery.PackagesState, History: delivery.PackagesHistory},
{Name: graph.State, History: graph.History}}; !reflect.DeepEqual(m.State, want) {
{Name: graph.State, History: graph.History}, {Name: views.MarksState, History: 1}, {Name: health.FindingsState, History: 5}}; !reflect.DeepEqual(m.State, want) {
t.Errorf("state = %+v, want %+v", m.State, want)
}
if want := []string{"mesh-build-go.toolchains", "mesh-build-node.toolchains", "mesh-delivery-assigner.placement", "mesh-delivery-builder.build-log", "mesh-delivery-checker.previews", "mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) {
@@ -125,17 +139,51 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build
// 1945: indexer_take_in, served by its tools bundle through the takeins state; the process serves none. The same bundle
// reads the verdicts state (indexer_verdicts) and asks the indexer to judge again through the takeins state
// (indexer_judge; issue 774, clause 3). 1966/AC11: indexer_old_sections reads the count of the old sections (state oldsections).
if want := []string{"indexer_take_in", "indexer_old_sections", "indexer_verdicts", "indexer_judge"}; !reflect.DeepEqual(m.Tools, want) {
// ADR 0621 §1, ADR 0622 §1: the first-view tools manifests, modules and verdicts, and design 25 § 12.2's nine read
// tools, all of the tools bundle, which runs in a runtime of its own on the indexer's account (runs-as).
want := []string{"indexer_take_in", "indexer_old_sections", "indexer_verdicts", "indexer_judge"}
for _, v := range append(append([]index.ViewedTool{}, index.FirstViewTools...), index.ReadTools...) {
want = append(want, v.Tool)
if _, ok := index.ViewedStates[v.State]; !ok {
t.Errorf("%s answers the state %s, which the views family does not say", v.Tool, v.State)
}
}
if !reflect.DeepEqual(m.Tools, want) || len(want) != 16 {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
if m.RunsAs != m.Module || m.SecretsOwner != m.RunsAs {
t.Errorf("runs-as = %q, secrets-owner %q: the tools bundle runs on the indexer's own account (ADR 0621 §1)", m.RunsAs, m.SecretsOwner)
}
// ADR 0621 §2, ADR 0622 §1: each first-view tool answers exactly its readers; the indexer is on none.
callers := map[string]string{"manifests": "mesh-grants,node-tools,mesh-delivery-assigner", "modules": "mesh-grants,node-tools,mesh-delivery-assigner", "verdicts": "mesh-grants"}
for _, a := range m.Build.Artifacts {
if a.Name != "tools" {
continue
}
if len(a.Env) != len(callers) {
t.Errorf("tools env = %v, want only the three callers lists", a.Env)
}
for tool, list := range callers {
if got := a.Env[views.CallersEnv(tool)]; got != list {
t.Errorf("%s callers = %q, want %q", tool, got, list)
}
if views.ParseCallers(a.Env[views.CallersEnv(tool)]).Allows("n1." + m.Module) {
t.Errorf("%s answers the indexer itself", tool)
}
}
}
// ADR 0615 §5, ADR 0619 §2: its views family on its old streams section, 7 days and 256 MiB, dropping its oldest.
if want := []stream{{Name: index.ViewsStream, Events: []string{views.EventPattern}, MaxAgeDays: views.StreamDays, MaxMiB: views.StreamMiB}}; !reflect.DeepEqual(m.Streams, want) {
t.Errorf("streams = %+v, want %+v", m.Streams, want)
}
// ADR 0604 §2: it holds the seat mesh-catalog, serving its four verbs from its tools bundle (which stays without a
// request subject).
if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.CatalogSeat || m.Claims[0].Scope != catalogue.ScopeMesh ||
!slices.Equal(m.Claims[0].Serves, []string{"modules", "module", "provides", "dependents"}) {
t.Errorf("claims = %+v, want mesh-catalog with its four verbs", m.Claims)
}
// No streams, no bus, no other seat (ADR 0437 §4); it says no event.
for _, k := range []string{"streams", "logs", "provides", "requires", "uses", "defines-seats", "seat", "seats", "bus"} {
// No bus, no other seat (ADR 0437 §4).
for _, k := range []string{"logs", "provides", "requires", "uses", "defines-seats", "seat", "seats", "bus"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the indexer has none", k)
}
+26 -3
View File
@@ -2,6 +2,7 @@
"module": "mesh-delivery-indexer",
"version": "1",
"slug": "indexer",
"runs-as": "mesh-delivery-indexer",
"claims": [
{
"name": "mesh-catalog",
@@ -18,7 +19,19 @@
"indexer_take_in",
"indexer_old_sections",
"indexer_verdicts",
"indexer_judge"
"indexer_judge",
"manifests",
"modules",
"verdicts",
"indexer_read_manifests",
"indexer_read_modules",
"indexer_read_predefined",
"indexer_read_config_candidates",
"indexer_read_candidates",
"indexer_read_packages",
"indexer_read_graph",
"indexer_read_heard",
"indexer_read_taken"
],
"consumes": [
"mesh-build-go.toolchain.reported",
@@ -38,7 +51,12 @@
{ "name": "verdicts", "history": 5 },
{ "name": "oldsections", "history": 1 },
{ "name": "packages", "history": 5 },
{ "name": "graph", "history": 5 }
{ "name": "graph", "history": 5 },
{ "name": "view-marks", "history": 1 },
{ "name": "findings", "history": 5 }
],
"streams": [
{ "name": "indexer-views", "events": ["views.**"], "max-age-days": 7, "max-mib": 256 }
],
"emits": [
"manifest-refused",
@@ -115,7 +133,12 @@
"binary": "mesh-delivery-indexer-tools",
"loads": [
"mesh-delivery-indexer-tools"
]
],
"env": {
"MESH_VIEW_CALLERS_MANIFESTS": "mesh-grants,node-tools,mesh-delivery-assigner",
"MESH_VIEW_CALLERS_MODULES": "mesh-grants,node-tools,mesh-delivery-assigner",
"MESH_VIEW_CALLERS_VERDICTS": "mesh-grants"
}
}
]
}
+12 -1
View File
@@ -2,4 +2,15 @@ module git.novox.be/novox/mesh-delivery/sdk
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats.go v1.54.0
)
require (
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
+196
View File
@@ -0,0 +1,196 @@
package views
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Emitter is an owner's process saying each change of its own states on its views family (ADR 0615 §5): it follows
// each state it is given from the revision its mark says it said up to, publishes one Event per change, and writes the
// mark once it has said every change the bus had handed it. A part watches only its own state (ADR 0596 §4): an owner
// follows its own buckets, never another's.
type Emitter struct {
// Owner is the owner module, said in every event.
Owner string
// Subject is where an event of the given local name lands on the owner's own stream (process.Account.StreamSubject).
Subject func(event string) string
// Publish sends one message to the owner's stream, its id the bus's dedup id.
Publish func(ctx context.Context, msg *nats.Msg, id string) error
// Marks is the owner's state MarksState; nil says nothing is marked, and every start says from the bucket's end.
Marks jetstream.KeyValue
// TooLarge is told of a value too large for an event (said as a gap): the owner's finding naming the key. Nil
// only logs it.
TooLarge func(state, key string, size int)
Logf func(string, ...any)
Now func() time.Time
}
// JetStreamPublish is Emitter.Publish through js, with the bus's dedup id.
func JetStreamPublish(js jetstream.JetStream) func(context.Context, *nats.Msg, string) error {
return func(ctx context.Context, msg *nats.Msg, id string) error {
pub, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
_, err := js.PublishMsg(pub, msg, jetstream.WithMsgID(id))
return err
}
}
func (e Emitter) now() time.Time {
if e.Now != nil {
return e.Now().UTC()
}
return time.Now().UTC()
}
func (e Emitter) logf(format string, args ...any) {
if e.Logf != nil {
e.Logf(format, args...)
}
}
// created is when the bucket's stream was made, and its fill now.
func created(ctx context.Context, kv jetstream.KeyValue) (time.Time, uint64, Fill, error) {
st, err := kv.Status(ctx)
if err != nil {
return time.Time{}, 0, Fill{}, err
}
b, ok := st.(*jetstream.KeyValueBucketStatus)
if !ok || b.StreamInfo() == nil {
return time.Time{}, 0, Fill{}, fmt.Errorf("the bus answered no stream behind %s", st.Bucket())
}
info := b.StreamInfo()
return info.Created.UTC(), info.State.LastSeq, Fill{Keys: uint64(info.State.NumSubjects), Bytes: info.State.Bytes, MaxBytes: info.Config.MaxBytes}, nil
}
// Start follows state's bucket kv: from the revision after its mark when the mark is of the same bucket (the same
// Created), otherwise from the bucket's end, writing a mark there first, so a reader's first view taken now misses
// nothing. It returns once the watch is set; the changes are said on a goroutine until ctx ends, and a watch that ends
// is sent on errs.
func (e Emitter) Start(ctx context.Context, state string, kv jetstream.KeyValue, errs chan<- error) error {
made, last, fill, err := created(ctx, kv)
if err != nil {
return fmt.Errorf("the state %s cannot be followed for its views: %w", state, err)
}
from := last + 1
if m, ok := e.mark(ctx, state); ok && m.Created.Equal(made) && m.Revision <= last {
from = m.Revision + 1
} else {
e.writeMark(ctx, state, Mark{State: state, Revision: last, Created: made, Fill: fill})
}
w, err := kv.WatchAll(ctx, jetstream.ResumeFromRevision(from))
if err != nil {
return fmt.Errorf("the state %s cannot be watched for its views: %w", state, err)
}
go func() {
defer w.Stop()
said := uint64(0)
for {
select {
case <-ctx.Done():
return
case u, ok := <-w.Updates():
if !ok {
if errs != nil {
errs <- fmt.Errorf("the watch of %s for its views ended", state)
}
return
}
if u != nil {
if err := e.say(ctx, state, made, u); err != nil {
// Not marked: the next start says it again from the mark, and the bus keeps it once by its id.
e.logf("views: %s.%s at %d is not said: %v", state, u.Key(), u.Revision(), err)
continue
}
said = u.Revision()
}
// Marked once every change handed so far is said, so a busy state writes one mark per burst.
if said > 0 && len(w.Updates()) == 0 {
_, _, fill, err := created(ctx, kv)
if err != nil {
e.logf("views: the fill of %s is not read: %v", state, err)
}
e.writeMark(ctx, state, Mark{State: state, Revision: said, Created: made, Fill: fill})
said = 0
}
}
}
}()
return nil
}
// Event is the event saying u, a change of state made at made.
func (e Emitter) Event(state string, made time.Time, u jetstream.KeyValueEntry) Event {
ev := Event{V: Version, Owner: e.Owner, State: state, Key: u.Key(), Revision: u.Revision(), Created: made}
if u.Operation() != jetstream.KeyValuePut {
ev.Deleted = true
return ev
}
if n := len(u.Value()); n > MaxValueBytes {
ev.Gap = true
e.logf("views: %s.%s at %d holds %d bytes, more than an event carries: said as a gap, so a reader asks its first view", state, u.Key(), u.Revision(), n)
if e.TooLarge != nil {
e.TooLarge(state, u.Key(), n)
}
return ev
}
ev.Value = json.RawMessage(u.Value())
if !json.Valid(ev.Value) {
ev.Value, _ = json.Marshal(string(u.Value()))
}
return ev
}
func (e Emitter) say(ctx context.Context, state string, made time.Time, u jetstream.KeyValueEntry) error {
ev := e.Event(state, made, u)
data, err := ev.Encode()
if err != nil {
return err
}
msg := nats.NewMsg(e.Subject(EventName(state)))
msg.Header.Set("x-event-id", ev.ID())
msg.Header.Set("x-source", e.Owner)
msg.Header.Set("x-time", e.now().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = data
return e.Publish(ctx, msg, ev.ID())
}
func (e Emitter) mark(ctx context.Context, state string) (Mark, bool) {
if e.Marks == nil {
return Mark{}, false
}
got, err := e.Marks.Get(ctx, state)
if err != nil {
if !errors.Is(err, jetstream.ErrKeyNotFound) {
e.logf("views: the mark of %s is not read (%v): said from the bucket's end", state, err)
}
return Mark{}, false
}
m, err := DecodeMark(got.Value())
if err != nil {
e.logf("views: the mark of %s is not read (%v): said from the bucket's end", state, err)
return Mark{}, false
}
return m, true
}
func (e Emitter) writeMark(ctx context.Context, state string, m Mark) {
if e.Marks == nil {
return
}
m.V, m.At = Version, e.now()
data, err := json.Marshal(m)
if err != nil {
e.logf("views: the mark of %s is not written: %v", state, err)
return
}
if _, err := e.Marks.Put(ctx, state, data); err != nil {
e.logf("views: the mark of %s at %d is not written (%v): a first view answers the one before", state, m.Revision, err)
}
}
+47
View File
@@ -0,0 +1,47 @@
package views
import (
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// The findings a views part writes in its own state `findings`, which the condition keeper raises and clears
// (novox/hq ADR 0564 §2): an owner's value too large for an event (TooLargeKind), and a reader's first view unanswered
// past its bound (UnansweredKind).
const (
TooLargeKind = "view-too-large"
UnansweredKind = "first-view-unanswered"
)
// TooLargeFinding is the owner's finding of state's key too large to say whole on its views family.
func TooLargeFinding(owner, state, key string, size int, at time.Time) (string, []byte, error) {
id := owner + "." + state + "." + key
summary := fmt.Sprintf("%s's %s.%s holds %d bytes, more than one views event carries (%d): every reader asks its first view again at each change of it", owner, state, key, size, MaxValueBytes)
return finding(owner, TooLargeKind, id, summary,
fmt.Sprintf("A value of %s is too large for its views event", owner),
"Each change of this key is said as a gap, so every reader of the state asks the owner's first view once more. "+
"It works, but costs a first view per change; the value should be made smaller or split.", at)
}
// UnansweredFinding is a reader's finding of its first view of s unanswered past its bound (ADR 0615 §6): it names the
// owner and the tool. A first view answered at revision 0 with nothing in it is said too, with the operator's step.
func UnansweredFinding(reader string, s Status, at time.Time) (string, []byte, error) {
id := reader + "." + s.Owner + "." + s.State
summary := fmt.Sprintf("%s has no first view of %s's %s: %s has not answered since %s (%s)", reader, s.Owner, s.State, s.Tool,
s.Asked.Format(time.RFC3339), strings.TrimSpace(s.Error))
return finding(reader, UnansweredKind, id, summary,
fmt.Sprintf("%s waits for %s", reader, s.Owner),
fmt.Sprintf("%s reads %s's %s only through %s, and keeps asking it; it never reads the state itself. The answer clears this. "+
"Look at %s's runtime of its own: is it running and connected?", reader, s.Owner, s.State, s.Tool, s.Owner), at)
}
func finding(observer, kind, id, summary, headline, explanation string, at time.Time) (string, []byte, error) {
key := health.FindingKey(health.ScopeModule, id, kind)
value, err := health.EncodeFinding(health.Finding{Key: key, Kind: kind, Subject: health.Subject{Scope: health.ScopeModule, ID: id},
Severity: health.Warning, Summary: summary, Headline: headline, Explanation: explanation,
Evidence: []health.Evidence{{At: at.UTC(), Said: summary}}, Source: observer + "." + kind, Observer: observer, At: at.UTC()})
return key, value, err
}
+341
View File
@@ -0,0 +1,341 @@
package views
import (
"cmp"
"context"
"encoding/json"
"fmt"
"maps"
"slices"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
)
// Follower is a reader's picture of one state of an owner (ADR 0615 §4 to §6): it asks the owner's first-view tool at
// its start, then keeps the picture from the owner's events alone, asking the tool once more only on a gap in revisions
// (said in its log), never on a timer. Started while the owner is down, it keeps asking, never reading the owner's state
// instead, and past Bound raises its condition naming the owner and the tool; the answer clears it.
type Follower struct {
Owner string
// Tool is the owner's tool it asks, as the reader's invokes names it (<module>.<tool>).
Tool string
State string
// Ask calls the tool for the page after after.
Ask func(ctx context.Context, after string) (Answer, error)
// Raise and Clear are the reader's condition of this first view (past Bound; cleared by the answer). Nil says
// nothing.
Raise func(Status)
Clear func(Status)
// Changed is told of each key the picture changes (deleted: the key is gone), after the change is held.
Changed func(key string, value json.RawMessage, deleted bool)
Logf func(string, ...any)
Now func() time.Time
// Bound is how long it asks before it raises; zero is views.Bound. Wait is the first wait between two asks that
// failed, doubling to a minute; zero is five seconds.
Bound time.Duration
Wait time.Duration
mu sync.Mutex
values map[string]json.RawMessage
st Status
asking bool
// during are the events heard while an ask runs: taken again after its answer, so none is lost to a picture read
// before it was written.
during []Event
ready chan struct{}
}
// Status is what a reader's read tool says of one first view (design 25 § 12.2): the owner, the tool and the revision
// its picture holds, when it asked and when it was answered, how many asks, the hand acts among them, and whether its
// condition is raised.
type Status struct {
Owner string `json:"owner"`
Tool string `json:"tool"`
State string `json:"state"`
Revision uint64 `json:"revision"`
Created time.Time `json:"created,omitzero"`
Keys int `json:"keys"`
Asked time.Time `json:"asked,omitzero"`
Answered time.Time `json:"answered,omitzero"`
Asks int `json:"asks"`
ByHand int `json:"by-hand"`
Raised bool `json:"raised"`
// Why is the last reason it asked (start, a gap and which, a hand act) or why the last ask failed.
Why string `json:"why,omitempty"`
Error string `json:"error,omitempty"`
}
func (f *Follower) now() time.Time {
if f.Now != nil {
return f.Now().UTC()
}
return time.Now().UTC()
}
func (f *Follower) logf(format string, args ...any) {
if f.Logf != nil {
f.Logf(format, args...)
}
}
func (f *Follower) init() {
if f.values == nil {
f.values = map[string]json.RawMessage{}
f.ready = make(chan struct{})
f.st = Status{Owner: f.Owner, Tool: f.Tool, State: f.State}
}
}
// Status is the follower's status now.
func (f *Follower) Status() Status {
f.mu.Lock()
defer f.mu.Unlock()
f.init()
s := f.st
s.Keys = len(f.values)
return s
}
// Ready is closed once a first view was answered.
func (f *Follower) Ready() <-chan struct{} {
f.mu.Lock()
defer f.mu.Unlock()
f.init()
return f.ready
}
// Values is a copy of the picture.
func (f *Follower) Values() map[string]json.RawMessage {
f.mu.Lock()
defer f.mu.Unlock()
f.init()
return maps.Clone(f.values)
}
// Get is one key of the picture.
func (f *Follower) Get(key string) (json.RawMessage, bool) {
f.mu.Lock()
defer f.mu.Unlock()
f.init()
v, ok := f.values[key]
return v, ok
}
// Start asks the first view until it is answered or ctx ends (ADR 0615 §6): each failed ask waits, doubling, and past
// Bound raises the condition. It returns once answered.
func (f *Follower) Start(ctx context.Context) error { return f.askUntil(ctx, "start", false) }
// Again asks the first view once more by hand (a re-read tool, recorded by the caller as a hand act), until answered or
// ctx ends.
func (f *Follower) Again(ctx context.Context, why string) error {
return f.askUntil(ctx, "by hand: "+why, true)
}
func (f *Follower) askUntil(ctx context.Context, why string, byHand bool) error {
f.mu.Lock()
f.init()
if f.asking {
f.mu.Unlock()
return nil // one ask at a time; the one running brings the picture to its newest
}
f.asking = true
f.during = nil
f.st.Why = why
if byHand {
f.st.ByHand++
}
f.mu.Unlock()
defer func() { f.mu.Lock(); f.asking = false; f.mu.Unlock() }()
bound, wait := f.Bound, f.Wait
if bound == 0 {
bound = Bound
}
if wait == 0 {
wait = 5 * time.Second
}
first := f.now()
for {
err := f.askOnce(ctx)
if err == nil {
return nil
}
f.mu.Lock()
f.st.Error = err.Error()
raise := !f.st.Raised && f.now().Sub(first) >= bound
if raise {
f.st.Raised = true
}
s := f.st
f.mu.Unlock()
f.logf("the first view %s of %s's %s is not answered (%v): asked again in %s", f.Tool, f.Owner, f.State, err, wait)
if raise && f.Raise != nil {
f.Raise(s)
}
pause, cancel := context.WithTimeout(ctx, wait) // a context's bound between two asks, no timer of its own
<-pause.Done()
cancel()
if ctx.Err() != nil {
return ctx.Err()
}
if wait *= 2; wait > time.Minute {
wait = time.Minute
}
}
}
// askOnce asks every page and, once whole, takes it as the picture.
func (f *Follower) askOnce(ctx context.Context) error {
f.mu.Lock()
f.st.Asked, f.st.Asks = f.now(), f.st.Asks+1
f.mu.Unlock()
var first Answer
values := map[string]json.RawMessage{}
after := ""
for page := 0; ; page++ {
a, err := f.Ask(ctx, after)
if err != nil {
return err
}
if page == 0 {
first = a
}
maps.Copy(values, a.Values)
if a.Next == "" || a.Next == after {
break
}
after = a.Next
}
f.mu.Lock()
old := f.values
f.values = values
wasRaised := f.st.Raised
f.st.Revision, f.st.Created, f.st.Answered, f.st.Raised, f.st.Error = first.Revision, first.Created, f.now(), false, ""
// The events heard while it asked, after the answer's revision and in order, are taken again.
during := f.during
f.during = nil
slices.SortFunc(during, func(a, b Event) int { return cmp.Compare(a.Revision, b.Revision) })
for _, ev := range during {
if ev.Created.Equal(f.st.Created) && ev.Revision == f.st.Revision+1 && !ev.Gap {
if ev.Deleted {
delete(f.values, ev.Key)
} else {
f.values[ev.Key] = ev.Value
}
f.st.Revision = ev.Revision
}
}
values = maps.Clone(f.values)
s := f.st
select {
case <-f.ready:
default:
close(f.ready)
}
f.mu.Unlock()
if wasRaised && f.Clear != nil {
f.Clear(s)
}
if f.Changed != nil {
for k, v := range values {
if ov, ok := old[k]; !ok || string(ov) != string(v) {
f.Changed(k, v, false)
}
}
for k := range old {
if _, ok := values[k]; !ok {
f.Changed(k, nil, true)
}
}
}
return nil
}
// Apply takes one event of the owner's views family into the picture and says whether it was a gap: another Created
// (the owner's state made again), a revision past the one after the picture's, or a value too large to carry. A
// revision at or below the picture's is already in it (a durable consumer handing what it held) and is passed over.
// Before the first view is answered an event is passed over too: the answer holds it.
func (f *Follower) Apply(ev Event) (gap bool, why string) {
f.mu.Lock()
f.init()
if f.asking {
f.during = append(f.during, ev)
}
select {
case <-f.ready:
default:
f.mu.Unlock()
return false, ""
}
switch {
case !ev.Created.Equal(f.st.Created):
why = fmt.Sprintf("%s's %s was made again (or not marked when the first view was answered): created %s, held %s", f.Owner, f.State, ev.Created.Format(time.RFC3339), f.st.Created.Format(time.RFC3339))
case ev.Revision <= f.st.Revision:
f.mu.Unlock()
return false, ""
case ev.Revision > f.st.Revision+1:
why = fmt.Sprintf("%s's %s at revision %d, the picture holds %d: %d changes missed", f.Owner, f.State, ev.Revision, f.st.Revision, ev.Revision-f.st.Revision-1)
case ev.Gap:
why = fmt.Sprintf("%s's %s.%s at revision %d was too large to carry", f.Owner, f.State, ev.Key, ev.Revision)
}
if why != "" {
f.mu.Unlock()
return true, why
}
if ev.Deleted {
delete(f.values, ev.Key)
} else {
f.values[ev.Key] = ev.Value
}
f.st.Revision = ev.Revision
f.mu.Unlock()
if f.Changed != nil {
f.Changed(ev.Key, ev.Value, ev.Deleted)
}
return false, ""
}
// Hear is a reader's consumer of one owner's views family, routing each event to the follower of its state; a gap asks
// that follower's first view once more on its own goroutine, said in the log. A message it cannot read is terminated.
func Hear(ctx context.Context, followers map[string]*Follower, logf func(string, ...any)) func(jetstream.Msg) {
return func(m jetstream.Msg) {
ev, err := DecodeEvent(m.Data())
if err != nil {
logf("views: %s is not a views event, dropped: %v", m.Subject(), err)
_ = m.Term()
return
}
f := followers[ev.State]
if f == nil {
_ = m.Ack() // a state this reader does not follow
return
}
if gap, why := f.Apply(ev); gap {
logf("views: a gap in %s (%s): its first view %s is asked once more", ev.State, why, f.Tool)
go func() {
f.mu.Lock()
f.init()
f.st.Why = "a gap: " + why
f.mu.Unlock()
_ = f.askUntil(ctx, "a gap: "+why, false)
}()
}
_ = m.Ack()
}
}
// AskVia is a Follower's Ask through a process's call of the owner's tool (process.Conn.Call), decoding its answer.
func AskVia(call func(key string, body any) (json.RawMessage, error), tool string) func(context.Context, string) (Answer, error) {
return func(_ context.Context, after string) (Answer, error) {
body := map[string]any{}
if after != "" {
body["after"] = after
}
raw, err := call(tool, body)
if err != nil {
return Answer{}, err
}
return DecodeAnswer(raw)
}
}
+159
View File
@@ -0,0 +1,159 @@
package views
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// KeptSource is a state as a tools bundle reads it through its runtime (stdio.KeptState), as a Source.
type KeptSource struct{ State stdio.KeptState }
// Keys is every key with a value.
func (k KeptSource) Keys() ([]string, error) { return k.State.Keys() }
// Get is one key's value and revision.
func (k KeptSource) Get(key string) (*Entry, bool, error) {
e, err := k.State.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return &Entry{Value: e.Value, Revision: e.Revision}, true, nil
}
// ParseCallers is a callers list as a manifest writes it into an env value: comma-separated, each a module (on any
// machine), <machine>.<module> or person.<name> (stdio.Callers).
func ParseCallers(list string) stdio.Callers {
var out stdio.Callers
for _, c := range strings.Split(list, ",") {
if c = strings.TrimSpace(c); c != "" {
out = append(out, c)
}
}
return out
}
// CallersEnv is the env value of a first-view tool's callers list, in the owner's tools bundle: MESH_VIEW_CALLERS_<TOOL>,
// the tool's name in capitals, a hyphen an underscore.
func CallersEnv(tool string) string {
return "MESH_VIEW_CALLERS_" + strings.ToUpper(strings.ReplaceAll(tool, "-", "_"))
}
// Refusal is why caller may not ask tool, or nil: the caller's proof, never an argument, against the tool's callers
// list holding exactly its readers (ADR 0621 §2, ADR 0622 §1); the owner itself is on no list, so it is refused too.
func Refusal(tool string, callers stdio.Callers, caller string) error {
if callers.Allows(caller) {
return nil
}
return fmt.Errorf("%s is refused to %q: the tool answers only %s (novox/hq ADR 0621 §2)", tool, caller, strings.Join(callers, ", "))
}
// FirstViewTool is an owner's first-view tool of one of its states (ADR 0621 §1): each call answers one page of what the
// state holds now, at the revision its mark says, to the callers its list names and nobody else. It reads only the
// owner's own state, so it answers as soon as the runtime is connected, before the owner has asked anyone anything
// (ADR 0621 §3).
func FirstViewTool(owner, name, state, words string, callers stdio.Callers, values, marks Source, now func() time.Time) stdio.Tool {
return stdio.Tool{
Name: name,
Description: fmt.Sprintf("The first view of %s's state %s (novox/hq ADR 0615, ADR 0621): %s Each value whole by key, at the revision "+
"the owner said its views family up to, so a reader then follows the events on that family and asks again only on a gap in "+
"revisions. One page at a time: next is the key to ask after for the rest. Answered only to %s; any other caller is refused.",
owner, state, words, strings.Join(callers, ", ")),
Input: map[string]any{"type": "object", "properties": map[string]any{
"after": map[string]any{"type": "string", "description": "the key the page before ended at (its next); none starts at the first"}}},
Run: func(args map[string]any) (any, error) {
if err := Refusal(name, callers, stdio.CallerOf(args)); err != nil {
return nil, err
}
after, _ := args["after"].(string)
return FirstView(owner, state, values, marks, strings.TrimSpace(after), now())
},
}
}
// ReadTool is an owner's ordinary read tool of one of its states (design 25 § 12.2): the keys, one key's value and
// revision, or a page of values in the first view's form, and the state's fill against its cap as its mark last said
// it. Granted, as any tool is, to every reader that invokes it, and callable by a session.
func ReadTool(owner, name, state, words string, values, marks Source, now func() time.Time) stdio.Tool {
return stdio.Tool{
Name: name,
Description: fmt.Sprintf("Read %s's state %s: %s With no argument, its keys and how full it is against its cap (as the "+
"owner last marked it); with key, that key's value and revision; with values, a page of values whole by key, at the "+
"revision the owner said its views family up to, continued with after (the page's next).", owner, state, words),
Input: map[string]any{"type": "object", "properties": map[string]any{
"key": map[string]any{"type": "string", "description": "one key: its value and revision"},
"values": map[string]any{"type": "boolean", "description": "a page of values whole by key"},
"after": map[string]any{"type": "string", "description": "with values: the key the page before ended at"}}},
Run: func(args map[string]any) (any, error) {
key, _ := args["key"].(string)
after, _ := args["after"].(string)
all, _ := args["values"].(bool)
return Read(owner, state, values, marks, strings.TrimSpace(key), all, strings.TrimSpace(after), now())
},
}
}
// ReadAnswer is a read tool's answer without values: the state's keys and fill.
type ReadAnswer struct {
Owner string `json:"owner"`
State string `json:"state"`
Keys []string `json:"keys"`
Fill *Fill `json:"fill,omitempty"`
Revision uint64 `json:"revision"`
Marked time.Time `json:"marked,omitzero"`
// Words say what the fill means where the owner has not marked the state yet.
Words string `json:"words,omitempty"`
}
// KeyAnswer is one key of a read tool's answer.
type KeyAnswer struct {
Owner string `json:"owner"`
State string `json:"state"`
Key string `json:"key"`
Found bool `json:"found"`
Value json.RawMessage `json:"value,omitempty"`
Revision uint64 `json:"revision,omitempty"`
}
// Read is a read tool's answer: one key, a page of values, or the keys and fill.
func Read(owner, state string, values, marks Source, key string, all bool, after string, now time.Time) (any, error) {
switch {
case key != "":
e, ok, err := values.Get(key)
if err != nil {
return nil, fmt.Errorf("%s.%s cannot be read: %w", state, key, err)
}
a := KeyAnswer{Owner: owner, State: state, Key: key, Found: ok}
if ok {
a.Value, a.Revision = e.Value, e.Revision
}
return a, nil
case all:
return FirstView(owner, state, values, marks, after, now)
}
keys, err := values.Keys()
if err != nil {
return nil, fmt.Errorf("the state %s cannot be listed: %w", state, err)
}
slices.Sort(keys)
a := ReadAnswer{Owner: owner, State: state, Keys: keys}
if marks != nil {
if e, ok, err := marks.Get(state); err == nil && ok {
if m, err := DecodeMark(e.Value); err == nil {
fill := m.Fill
a.Fill, a.Revision, a.Marked = &fill, m.Revision, m.At
}
}
}
if a.Fill == nil {
a.Words = "the owner has not marked this state yet: its fill is said once its views family says a change of it"
}
if a.Keys == nil {
a.Keys = []string{}
}
return a, nil
}
+242
View File
@@ -0,0 +1,242 @@
// Package views is the owners' side and the readers' side of a first view (novox/hq ADR 0615, ADR 0621, ADR 0622;
// design 25 § 12.2): the indexer and the assigner answer a reader's first view of one of their states as a tool of
// their own module (Answer, FirstView), say each change of that state as one event on their views family (Event,
// Emitter), and a reader keeps its picture from the answer and the events, asking the tool again only on a gap in
// revisions (Follower). The tools' read side (Read) answers the ordinary read tools of § 12.2 too.
package views
import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
)
// Version is the version of every value this package writes and the only one it reads.
const Version = 1
// The owners' views families (design 25 § 12.2; ADR 0615 §5, ADR 0619 §2): one stream each on its owner's old `streams`
// section, kept by limits 7 days and at most 256 MiB, dropping its oldest when full, so a reader that missed a dropped
// event sees the gap in revisions and asks once more. A stream's name is the mesh's, so each carries its owner's name.
const (
IndexerStream = "indexer-views"
AssignerStream = "assigner-views"
StreamDays = 7
StreamMiB = 256
// EventPrefix starts each event's local name: `views.<state>`, one event per changed key of that state.
EventPrefix = "views."
// EventPattern is every event of a views family, as a stream's events and a reader's consumes name it.
EventPattern = EventPrefix + "**"
)
// MarksState is an owner's own state of the revision each followed state was said up to (Mark), one key per state:
// what a first view answers its revision from, so a reader that takes the answer misses no event after it.
const MarksState = "view-marks"
// MaxValueBytes is the largest value an event carries whole: under the bus's largest message (1 MiB, the server's
// max_payload) with the event's own fields. A larger one is said as its revision alone (Event.Gap), and the owner
// writes a finding naming the key (design 25 § 12.2).
const MaxValueBytes = 960 * 1024
// PageBytes bounds the values one answer carries, so the answer fits one bus message; Answer.Next continues it.
const PageBytes = 768 * 1024
// Bound is how long a reader waits for its first view before it raises the condition naming the owner and the verb
// (ADR 0615 §6): longer than an owner's start, so a normal start, readers and owners coming up together, tells nobody.
const Bound = 2 * time.Minute
// EventName is the local name of the event saying a change of state: `views.<state>`.
func EventName(state string) string { return EventPrefix + state }
// StateOfEvent is the state an event's local name (or the last tokens of its subject) says, or "".
func StateOfEvent(name string) string {
if i := strings.LastIndex(name, ".event."+EventPrefix); i >= 0 {
name = name[i+len(".event."):]
}
s, ok := strings.CutPrefix(name, EventPrefix)
if !ok || s == "" || strings.ContainsAny(s, ".*> ") {
return ""
}
return s
}
// Event is one change of one key of an owner's state, said on its views family: the key's value whole, or a delete
// mark, at the revision of the state the change was written at. Created is when the owner's state was made on the bus:
// a state made again (the bus module's recreate) starts its revisions again, and a reader takes another Created as a gap.
type Event struct {
V int `json:"v"`
Owner string `json:"owner"`
State string `json:"state"`
Key string `json:"key"`
Revision uint64 `json:"revision"`
Created time.Time `json:"created"`
Deleted bool `json:"deleted,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
// Gap says the value was too large to carry (MaxValueBytes): the reader asks its first view once.
Gap bool `json:"gap,omitempty"`
}
// ID is the event's id on the bus, so the same change said twice (an owner's restart) is kept once.
func (e Event) ID() string {
return fmt.Sprintf("%s-%s-%d-%d", e.Owner, e.State, e.Created.UnixNano(), e.Revision)
}
// Encode writes an event.
func (e Event) Encode() ([]byte, error) {
e.V = Version
if e.Owner == "" || e.State == "" || e.Key == "" || e.Revision == 0 {
return nil, errors.New("views: an event names its owner, state, key and revision")
}
if e.Deleted || e.Gap {
e.Value = nil
}
return json.Marshal(e)
}
// DecodeEvent reads an event of this version.
func DecodeEvent(data []byte) (Event, error) {
var e Event
if err := decode(data, &e); err != nil {
return Event{}, err
}
if e.State == "" || e.Key == "" || e.Revision == 0 {
return Event{}, errors.New("views: an event names no state, key or revision")
}
return e, nil
}
// Mark is the revision an owner said its state up to, and when that state was made (MarksState).
type Mark struct {
V int `json:"v"`
State string `json:"state"`
Revision uint64 `json:"revision"`
Created time.Time `json:"created"`
At time.Time `json:"at"`
// Fill is the state's bucket as the bus held it when the mark was written: what a read tool says of its fill.
Fill Fill `json:"fill"`
}
// Fill is a state's bucket's fill against its cap, as the bus says it.
type Fill struct {
Keys uint64 `json:"keys"`
Bytes uint64 `json:"bytes"`
MaxBytes int64 `json:"max-bytes"`
}
// DecodeMark reads a mark of this version.
func DecodeMark(data []byte) (Mark, error) {
var m Mark
return m, decode(data, &m)
}
// Answer is a first view, or one page of an ordinary read: the state's values by key, from After on, at the revision
// of the state the owner said up to (Mark) before it read them; Next is the key to ask after for the rest, empty when
// the answer is whole. A reader keeps the first page's revision: a value on a later page may be newer, and the events
// after that revision bring every key to its newest in order.
type Answer struct {
V int `json:"v"`
Owner string `json:"owner"`
State string `json:"state"`
Revision uint64 `json:"revision"`
Created time.Time `json:"created"`
Values map[string]json.RawMessage `json:"values"`
Next string `json:"next,omitempty"`
At time.Time `json:"at"`
}
// DecodeAnswer reads an answer of this version.
func DecodeAnswer(data []byte) (Answer, error) {
var a Answer
if err := decode(data, &a); err != nil {
return Answer{}, err
}
if a.Values == nil {
a.Values = map[string]json.RawMessage{}
}
return a, nil
}
// Entry is one key's value and revision, as a state read gives it.
type Entry struct {
Value json.RawMessage
Revision uint64
}
// Source is the read side of one state, as a tools bundle reaches its own state (stdio.KeptState's Keys and Get).
type Source interface {
Keys() ([]string, error)
Get(key string) (*Entry, bool, error)
}
// FirstView answers one page of state from its owner's source: the mark first, so no change after the revision it
// answers at is missed, then the values in key order after after, until PageBytes. A state not said yet (no mark)
// answers revision 0: a first view of whatever it holds, which the reader takes as a gap at the first event.
func FirstView(owner, state string, values, marks Source, after string, now time.Time) (Answer, error) {
a := Answer{V: Version, Owner: owner, State: state, Values: map[string]json.RawMessage{}, At: now.UTC()}
if marks != nil {
e, ok, err := marks.Get(state)
if err != nil {
return Answer{}, fmt.Errorf("the mark of %s cannot be read: %w", state, err)
}
if ok {
m, err := DecodeMark(e.Value)
if err != nil {
return Answer{}, fmt.Errorf("the mark of %s: %w", state, err)
}
a.Revision, a.Created = m.Revision, m.Created
}
}
keys, err := values.Keys()
if err != nil {
return Answer{}, fmt.Errorf("the state %s cannot be listed: %w", state, err)
}
slices.Sort(keys)
size := 0
for _, k := range keys {
if after != "" && k <= after {
continue
}
e, ok, err := values.Get(k)
if err != nil {
return Answer{}, fmt.Errorf("%s.%s cannot be read: %w", state, k, err)
}
if !ok {
continue
}
if size > 0 && size+len(k)+len(e.Value) > PageBytes {
a.Next = lastKey(a.Values)
break
}
a.Values[k] = e.Value
size += len(k) + len(e.Value)
}
return a, nil
}
func lastKey(m map[string]json.RawMessage) string {
last := ""
for k := range m {
if k > last {
last = k
}
}
return last
}
func decode(data []byte, into any) error {
var probe struct {
V *int `json:"v"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return fmt.Errorf("views: value does not fit its shape: %w", err)
}
if probe.V == nil || *probe.V != Version {
return fmt.Errorf("views: unknown value version: this reader reads %d", Version)
}
if err := json.Unmarshal(data, into); err != nil {
return fmt.Errorf("views: value does not fit its shape: %w", err)
}
return nil
}
+202
View File
@@ -0,0 +1,202 @@
package views
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// mapSource is a state held in memory, as a Source.
type mapSource map[string]Entry
func (m mapSource) Keys() ([]string, error) {
var out []string
for k := range m {
out = append(out, k)
}
return out, nil
}
func (m mapSource) Get(key string) (*Entry, bool, error) {
e, ok := m[key]
if !ok {
return nil, false, nil
}
return &e, true, nil
}
func markOf(state string, rev uint64, made time.Time) Entry {
raw, _ := json.Marshal(Mark{V: Version, State: state, Revision: rev, Created: made, Fill: Fill{Keys: 2, Bytes: 100, MaxBytes: 1000}})
return Entry{Value: raw, Revision: 1}
}
// The owners' and the readers' side of a first view (ADR 0615 §4-§6, ADR 0621 §2-§3, ADR 0622): one table of cases,
// since mesh-delivery is over its test budget.
func TestViews(t *testing.T) {
made := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC)
now := func() time.Time { return made.Add(time.Hour) }
values := mapSource{"a": {Value: json.RawMessage(`{"n":1}`), Revision: 7}, "b": {Value: json.RawMessage(`{"n":2}`), Revision: 9}}
marks := mapSource{"modules": markOf("modules", 9, made)}
t.Run("a first view answers every value at the mark's revision, the mark read first", func(t *testing.T) {
a, err := FirstView("o", "modules", values, marks, "", now())
if err != nil || a.Revision != 9 || !a.Created.Equal(made) || len(a.Values) != 2 || a.Next != "" {
t.Fatalf("answer = %+v, %v", a, err)
}
if a, _ := FirstView("o", "modules", values, mapSource{}, "", now()); a.Revision != 0 {
t.Errorf("a state not marked answers revision %d, want 0", a.Revision)
}
})
t.Run("a first view larger than a page is paged by key", func(t *testing.T) {
big := mapSource{}
for i := range 4 {
big[fmt.Sprintf("k%d", i)] = Entry{Value: json.RawMessage(`"` + strings.Repeat("x", PageBytes/3) + `"`), Revision: uint64(i + 1)}
}
got := map[string]bool{}
after := ""
for pages := 0; ; pages++ {
a, err := FirstView("o", "s", big, nil, after, now())
if err != nil || pages > 4 {
t.Fatalf("page %d: %+v %v", pages, a.Next, err)
}
for k := range a.Values {
got[k] = true
}
if a.Next == "" {
break
}
after = a.Next
}
if len(got) != 4 {
t.Errorf("pages hold %v, want the four keys", got)
}
})
t.Run("each first-view tool answers its callers alone, never the owner, by the proof and not an argument", func(t *testing.T) {
callers := ParseCallers("mesh-grants, node-tools,mesh-delivery-assigner")
for caller, allowed := range map[string]bool{"novox.mesh-grants": true, "ace.node-tools": true, "g14.mesh-delivery-assigner": true,
"novox.mesh-delivery-indexer": false, "novox.mesh-config": false, "person.jochen": false, stdio.Unproven: false} {
if err := Refusal("manifests", callers, caller); (err == nil) != allowed {
t.Errorf("%q: err = %v, allowed %v", caller, err, allowed)
}
}
// A caller argument names nobody: a call with no proof is refused.
tool := FirstViewTool("mesh-delivery-indexer", "manifests", "modules", "w.", callers, values, marks, now)
if _, err := tool.Run(map[string]any{"caller": "novox.mesh-grants"}); err == nil {
t.Errorf("an unproven call naming a caller is answered")
}
})
t.Run("a read tool says the keys and the fill, one key, or a page of values", func(t *testing.T) {
r, err := Read("o", "modules", values, marks, "", false, "", now())
ra, ok := r.(ReadAnswer)
if err != nil || !ok || len(ra.Keys) != 2 || ra.Fill == nil || ra.Fill.MaxBytes != 1000 || ra.Revision != 9 {
t.Fatalf("read = %+v, %v", r, err)
}
r, _ = Read("o", "modules", values, marks, "b", false, "", now())
if k := r.(KeyAnswer); !k.Found || k.Revision != 9 {
t.Errorf("one key = %+v", k)
}
r, _ = Read("o", "modules", values, nil, "", false, "", now())
if ra := r.(ReadAnswer); ra.Fill != nil || ra.Words == "" {
t.Errorf("an unmarked state says its fill as %+v", ra)
}
})
// A follower whose owner answers from values and marks.
follower := func(ask func(context.Context, string) (Answer, error)) (*Follower, *[]string) {
var said []string
f := &Follower{Owner: "o", Tool: "o.modules", State: "modules", Ask: ask, Bound: time.Nanosecond, Wait: time.Millisecond,
Raise: func(s Status) { said = append(said, "raised "+s.Tool) }, Clear: func(s Status) { said = append(said, "cleared") }}
return f, &said
}
answer := func(context.Context, string) (Answer, error) { return FirstView("o", "modules", values, marks, "", now()) }
ev := func(rev uint64, key, value string) Event {
e := Event{V: Version, Owner: "o", State: "modules", Key: key, Revision: rev, Created: made}
if value == "" {
e.Deleted = true
} else {
e.Value = json.RawMessage(value)
}
return e
}
t.Run("a reader applies a put and a delete from the events alone, and passes over what it holds", func(t *testing.T) {
asks := 0
f, _ := follower(func(ctx context.Context, after string) (Answer, error) { asks++; return answer(ctx, after) })
if err := f.Start(context.Background()); err != nil {
t.Fatal(err)
}
for _, e := range []Event{ev(8, "a", `{"n":0}`), ev(10, "c", `{"n":3}`), ev(11, "a", "")} {
if gap, why := f.Apply(e); gap {
t.Fatalf("revision %d is a gap: %s", e.Revision, why)
}
}
if _, ok := f.Get("a"); ok || f.Status().Revision != 11 || asks != 1 {
t.Errorf("picture = %v at %d after %d asks", f.Values(), f.Status().Revision, asks)
}
if v, _ := f.Get("c"); string(v) != `{"n":3}` {
t.Errorf("c = %s", v)
}
})
t.Run("a gap, a state made again and a value too large each ask once more", func(t *testing.T) {
for name, e := range map[string]Event{
"a revision missed": ev(12, "a", `{}`),
"made again": {V: Version, Owner: "o", State: "modules", Key: "a", Revision: 1, Created: made.Add(time.Minute), Value: json.RawMessage(`{}`)},
"too large": {V: Version, Owner: "o", State: "modules", Key: "a", Revision: 10, Created: made, Gap: true},
} {
f, _ := follower(answer)
_ = f.Start(context.Background())
if gap, _ := f.Apply(e); !gap {
t.Errorf("%s: not taken as a gap", name)
}
}
})
t.Run("a reader whose owner is down keeps asking, raises past its bound, and the answer clears it", func(t *testing.T) {
down := 3
f, said := follower(func(ctx context.Context, after string) (Answer, error) {
if down > 0 {
down--
return Answer{}, errors.New("no responders")
}
return answer(ctx, after)
})
if err := f.Start(context.Background()); err != nil {
t.Fatal(err)
}
if strings.Join(*said, ",") != "raised o.modules,cleared" || f.Status().Asks != 4 || f.Status().Raised {
t.Errorf("said %v after %d asks", *said, f.Status().Asks)
}
})
t.Run("an event heard while an ask runs is taken again after its answer", func(t *testing.T) {
var f *Follower
f, _ = follower(func(ctx context.Context, after string) (Answer, error) {
a, err := answer(ctx, after) // the picture read at 9, before revision 10 was written
if f.Status().Asks == 2 {
f.Apply(ev(10, "d", `{"n":4}`))
}
return a, err
})
_ = f.Start(context.Background())
_ = f.Again(context.Background(), "test")
if v, ok := f.Get("d"); !ok || string(v) != `{"n":4}` || f.Status().Revision != 10 || f.Status().ByHand != 1 {
t.Errorf("d = %s, %v at %d", v, ok, f.Status().Revision)
}
})
t.Run("an event names its state on its subject, and decodes as it was encoded", func(t *testing.T) {
if s := StateOfEvent("mesh.stream.mesh-delivery-indexer.event.views.config-candidates"); s != "config-candidates" {
t.Errorf("state of subject = %q", s)
}
raw, err := ev(3, "k", `{"x":1}`).Encode()
got, err2 := DecodeEvent(raw)
if err != nil || err2 != nil || got.Revision != 3 || string(got.Value) != `{"x":1}` {
t.Errorf("round trip = %+v %v %v", got, err, err2)
}
if _, err := (Event{Owner: "o", State: "s"}).Encode(); err == nil {
t.Errorf("an event with no key or revision is encoded")
}
})
}