B5g3-3: the indexer keeps an input of any size and answers input in chunks

Builds: design 47 G §712, §354, §681, A-237, A-139

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 09:20:34 +01:00
parent ccbefc5653
commit 2aed3151af
11 changed files with 521 additions and 53 deletions
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
"os"
"strings"
"sync"
@@ -291,7 +292,7 @@ func main() {
// the parts whose entry declares it (novox/hq ADR 0663).
tools = append(tools, index.RevisionTool(revisions, index.Module), index.EntryTool(revisions, keptRead{stdio.State(index.EntryIndexState)}), index.EntriesTool(revisions, keptRead{stdio.State(index.EntryIndexState)}, keptRead{stdio.State(index.NamedState)}), index.VersionsTool(keptRead{stdio.State(index.VersionsState)}),
index.OutdatedTool(&index.Indexer{Versions: keptRead{stdio.State(index.VersionsState)}, Heard: keptRead{stdio.State(index.HeardState)}, Revisions: revisions}),
index.InputTool(keptRead{stdio.State(index.InputsState)}), index.InputsReadTool(keptRead{stdio.State(index.InputsState)}))
index.InputsReadTool(keptRead{stdio.State(index.InputsState)}, stateobjects.ForState(index.InputsState).Objects))
tools = append(tools, index.CatalogRevisionTools(revisions, asks, time.Now, func(what, why string) error {
_, err := acts.Record(meshhandactsclient.Request{What: what, Why: why, Cause: index.DropCandidateGraphTool, Verb: "drop-candidate-graph"})
return err
@@ -0,0 +1,95 @@
//go:build integration
package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
"mesh-delivery-indexer/internal/index"
)
// TestIndexerAnswersARunsInputOverOneMiBInChunks (design 47 G §712, A-237, §681): an input of 3 MiB, kept in the state inputs with
// its object beside it, is answered by the seat mesh-catalog's verb input as a count of chunks and then sent on the call's chunk
// subjects, each acknowledged by the caller; the chunks carry the exact bytes.
func TestIndexerAnswersARunsInputOverOneMiBInChunks(t *testing.T) {
body := make([]byte, 3<<20)
if _, err := rand.Read(body); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(body)
digest := hex.EncodeToString(sum[:])
js, _, _, _ := startIndexer(t, func(context.Context, buildLog) {}, func(ctx context.Context, js jetstream.JetStream) {
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: index.InputsBucket, History: 1})
if err != nil {
t.Fatal(err)
}
objs, err := js.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{Bucket: catalogue.BusName(index.Module, catalogue.StateObjectsName(index.InputsState))})
if err != nil {
t.Fatal(err)
}
key := meshcatalog.InputsByTimeDigestKey(time.Now().UTC().Format("20060102T150405Z"), digest)
if err := stateobjects.OverBus(kv, objs, catalogue.StateObjectsName(index.InputsState)).Put(key, base64.StdEncoding.EncodeToString(body)); err != nil {
t.Fatal(err)
}
})
nc := js.Conn()
call := "0123456789abcdef0123"
send := buscalls.SeatChunks{Seat: meshcatalog.Seat, Verb: meshcatalog.VerbInput, Call: call}
chunks := make(chan *nats.Msg, 16)
sub, err := nc.ChanSubscribe(send.Chunks(), chunks)
if err != nil {
t.Fatal(err)
}
defer sub.Unsubscribe()
req, _ := json.Marshal(meshcatalog.InputInput{Digest: digest, Call: call})
var reply struct {
Result meshcatalog.InputAnswer `json:"result"`
Error string `json:"error"`
}
for deadline := time.Now().Add(30 * time.Second); ; time.Sleep(100 * time.Millisecond) {
m, err := nc.Request("mesh.seat.mesh-catalog.tool.input", req, 5*time.Second)
if err == nil {
if err := json.Unmarshal(m.Data, &reply); err != nil {
t.Fatal(err)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("the verb input is not answered within 30 s: %v", err)
}
}
if reply.Error != "" || reply.Result.Digest != digest || reply.Result.Chunks != 6 {
t.Fatalf("the answer %+v (%q), want %s in 6 chunks", reply.Result, reply.Error, digest)
}
var got bytes.Buffer
next := func() (string, []byte, error) {
select {
case m := <-chunks:
return m.Subject, m.Data, nil
case <-time.After(20 * time.Second):
return "", nil, buscalls.ErrSeatChunkMissed
}
}
ack := func(subject string) error { _, err := nc.Request(subject, []byte("{}"), 5*time.Second); return err }
if _, err := send.Receive(reply.Result.Chunks, next, ack, &got); err != nil {
t.Fatal(err)
}
if !bytes.Equal(got.Bytes(), body) {
t.Errorf("the chunks carry %d bytes that are not the input's %d", got.Len(), len(body))
}
}
@@ -0,0 +1,98 @@
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
"mesh-delivery-indexer/internal/index"
)
// inputObjects is the object store beside the state inputs (`inputs-objects`, a state's own, design 47 G §712, A-237), opened when
// first used: until the grants part has made it every call is process.ErrNotYet.
type inputObjects struct {
js jetstream.JetStream
module string
mu sync.Mutex
bus stateobjects.Objects
}
func (o *inputObjects) open() (stateobjects.Objects, error) {
o.mu.Lock()
defer o.mu.Unlock()
if o.bus != nil {
return o.bus, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
store, err := o.js.ObjectStore(ctx, catalogue.BusName(o.module, catalogue.StateObjectsName(index.InputsState)))
if errors.Is(err, jetstream.ErrBucketNotFound) || errors.Is(err, jetstream.ErrStreamNotFound) {
return nil, fmt.Errorf("the object store of the state inputs is not on the bus yet: %w", process.ErrNotYet)
}
if err != nil {
return nil, fmt.Errorf("the object store of the state inputs is not open: %w", err)
}
o.bus = stateobjects.BusObjects{Store: store}
return o.bus, nil
}
func (o *inputObjects) MostBytes() int { return 0 }
func (o *inputObjects) Put(name string, data []byte) error {
b, err := o.open()
if err != nil {
return err
}
return b.Put(name, data)
}
func (o *inputObjects) Get(name string) ([]byte, error) {
b, err := o.open()
if err != nil {
return nil, err
}
return b.Get(name)
}
func (o *inputObjects) Delete(name string) error {
b, err := o.open()
if err != nil {
return err
}
return b.Delete(name)
}
func (o *inputObjects) List() ([]stateobjects.Object, error) {
b, err := o.open()
if err != nil {
return nil, err
}
return b.List()
}
// coreChunks is the core connection as a send of chunks reaches it (index.ChunkLine): a chunk is published on no stream, an
// acknowledgement is a request the holder answers.
type coreChunks struct{ nc *nats.Conn }
func (c coreChunks) Publish(subject string, data []byte) error { return c.nc.Publish(subject, data) }
func (c coreChunks) Hear(subject string, fn func(string)) (func(), error) {
sub, err := c.nc.Subscribe(subject, func(m *nats.Msg) {
fn(m.Subject)
if m.Reply != "" {
_ = m.Respond([]byte("{}"))
}
})
if err != nil {
return nil, err
}
return func() { _ = sub.Unsubscribe() }, nil
}
@@ -23,9 +23,11 @@ import (
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshbuilds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -443,6 +445,16 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
pinsBucket := process.AwaitBucket(ctx, js, conn, index.PinsBucket, "no record's pin on a catalog graph revision is kept, so no revision is removed", logger.Printf)
pinsStore := awaitedStore{b: pinsBucket}
consumers := process.AwaitBucket(ctx, js, conn, index.Module+"_"+process.ConsumersState, "indexer_consumer reads no consumer", logger.Printf)
// The state inputs and the object store beside it; the seat mesh-catalog's verb input is answered here in chunks of the core
// connection, of any size (design 47 G §712, A-237).
inputsStore := awaitedStore{b: process.AwaitBucket(ctx, js, conn, index.InputsBucket, "no input of a run ask is kept, so the verb input answers none", logger.Printf)}
inObjects := &inputObjects{js: js, module: cred.Module}
stopInput, err := calls.AnswerSeat(meshcatalog.Seat, map[string]process.SeatHandler{
meshcatalog.VerbInput: index.InputVerb(inputsStore, inObjects, coreChunks{conn}, buscalls.SeatAckWithin, logger.Printf)})
if err != nil {
return fmt.Errorf("answering the verb input: %w", err)
}
defer stopInput()
ix := &index.Indexer{NamedSets: named, Findings: writer, OldSections: awaitedStore{b: oldSections}, Unjudged: awaitedStore{b: unjudged}, Requirements: awaitedStore{b: requirements}, Store: kvStore{candidates}, Modules: modules, Manifests: kvStore{manifests}, Taken: awaitedStore{b: taken}, Builds: known,
Verdicts: awaitedStore{b: verdicts}, SayRefused: sayRefused(conn, cred, logger.Printf), SayAccepted: sayAccepted(conn, cred),
Predefined: awaitedStore{b: predefined}, ConfigCandidates: awaitedStore{b: configCandidates}, Logf: logger.Printf,
@@ -458,7 +470,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
Versions: awaitedStore{b: process.AwaitBucket(ctx, js, conn, index.VersionsBucket, "no released version is kept, so no package-published makes a version", logger.Printf)},
BuildEdges: awaitedStore{b: process.AwaitBucket(ctx, js, conn, index.BuildEdgesBucket, "no build of another watched branch keeps its edges", logger.Printf)},
Pins: pinsStore, PinsPending: pendingOf(js, cred),
Inputs: awaitedStore{b: process.AwaitBucket(ctx, js, conn, index.InputsBucket, "no input of a run ask is kept, so the verb input answers none", logger.Printf)},
Inputs: inputsStore,
InputObjects: inObjects,
SayVersionReleased: sayVersionReleased(conn, cred), SayVersionsFilled: sayVersionsFilled(conn, cred),
// The take-in step (ADR 0633): its state pending and its stream take-in, both this delivery's, bound when they appear;
// until then every merge is taken in at once, as before.
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
"reflect"
"slices"
"strings"
@@ -120,6 +121,9 @@ type Indexer struct {
SayVersionsFilled func(ctx context.Context, e meshcatalog.VersionsFilledEvent, id string) error
// Inputs is the state inputs (run_inputs.go, design 47 G §354): the inputs of the indexer's run asks; nil keeps none.
Inputs Store
// InputObjects is the object store beside the state inputs (`inputs-objects`): an input too large for a key is kept there
// (mesh-sdk stateobjects, design 47 G §712, A-237); nil keeps a large input nowhere and refuses it.
InputObjects stateobjects.Objects
// Registry is the registrations and their trusted changes (registrations.go, design 47 G §2 to §4, §667); SeedModules reads
// what the first registrations are composed from (§8). Nil: no registration act is answered.
Registry *Registry
@@ -910,7 +910,7 @@ func (ix *Indexer) dry() *dryRun {
Requirements: nil, Findings: nil, SeatDefinitions: nil, ReadFile: ix.ReadFile, SaySeatDefinition: nil,
OwnBuildState: nil, Heard: nil, Placement: nil, RunningWhole: nil, underSeats: true,
// A dry run keeps no catalog graph revision (catalog_revisions.go): its revision is the take-in's to keep.
Revisions: nil, SayCatalogRevision: nil, SayFill: nil, EntryIndex: nil, NamedSets: nil, Versions: nil, SayVersionReleased: nil, SayVersionsFilled: nil, Inputs: nil, Pins: nil, PinsPending: nil,
Revisions: nil, SayCatalogRevision: nil, SayFill: nil, EntryIndex: nil, NamedSets: nil, Versions: nil, SayVersionReleased: nil, SayVersionsFilled: nil, Inputs: nil, InputObjects: nil, Pins: nil, PinsPending: nil,
// A dry run writes no registration and asks no one.
Registry: nil, SeedModules: nil}
x.Logf = func(format string, args ...any) { ix.logf("take-in, computed and not written: "+format, args...) }
@@ -1131,7 +1131,7 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633)
for _, f := range []string{"Store", "Modules", "Manifests", "Predefined", "ConfigCandidates", "Taken", "Verdicts", "OldSections", "Unjudged",
"JudgeID", "SayRefused", "SayAccepted", "Packages", "Graph", "Files", "GoSum", "GoMod", "Now", "Builds", "Named", "Trunk",
"Read", "Contains", "Logf", "ReadOpen", "OwnBuild", "Message", "Pending", "SayTakeIn",
"Requirements", "Findings", "SeatDefinitions", "ReadFile", "SaySeatDefinition", "Runs", "OwnBuildState", "Heard", "Placement", "RunningWhole", "Revisions", "SayCatalogRevision", "SayFill", "EntryIndex", "NamedSets", "Registry", "SeedModules", "Versions", "SayVersionReleased", "SayVersionsFilled", "Inputs", "BuildEdges", "Pins", "PinsPending"} {
"Requirements", "Findings", "SeatDefinitions", "ReadFile", "SaySeatDefinition", "Runs", "OwnBuildState", "Heard", "Placement", "RunningWhole", "Revisions", "SayCatalogRevision", "SayFill", "EntryIndex", "NamedSets", "Registry", "SeedModules", "Versions", "SayVersionReleased", "SayVersionsFilled", "Inputs", "InputObjects", "BuildEdges", "Pins", "PinsPending"} {
named[f] = true
}
ty := reflect.TypeOf(Indexer{})
@@ -0,0 +1,132 @@
package index
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
)
type objectsMem struct {
mu sync.Mutex
objs map[string][]byte
}
func (*objectsMem) MostBytes() int { return 0 }
func (o *objectsMem) Put(name string, data []byte) error {
o.mu.Lock()
defer o.mu.Unlock()
o.objs[name] = data
return nil
}
func (o *objectsMem) Get(name string) ([]byte, error) {
o.mu.Lock()
defer o.mu.Unlock()
d, ok := o.objs[name]
if !ok {
return nil, errors.New("no such object")
}
return d, nil
}
func (o *objectsMem) Delete(name string) error { return nil }
func (o *objectsMem) List() ([]stateobjects.Object, error) { return nil, nil }
// lineMem is a core bus in memory: a chunk published is kept, and its acknowledgement heard at once.
type lineMem struct {
mu sync.Mutex
hear func(string)
chunks map[string][]byte
}
func (l *lineMem) Publish(subject string, data []byte) error {
l.mu.Lock()
l.chunks[subject] = data
h := l.hear
l.mu.Unlock()
var c buscalls.SeatChunk
if err := json.Unmarshal(data, &c); err != nil {
return err
}
go h(buscalls.SeatChunks{Seat: meshcatalog.Seat, Verb: meshcatalog.VerbInput, Call: "0123456789abcdef"}.Ack(c.N))
return nil
}
func (l *lineMem) Hear(_ string, fn func(string)) (func(), error) {
l.mu.Lock()
l.hear = fn
l.mu.Unlock()
return func() {}, nil
}
// Design 47 G §712, A-237: an input is of any size: over a key's limit it is kept as an object beside the state, and the verb
// input answers it in chunks of at most 512 KiB, each acknowledged before the next.
func TestAnInputOverOneMiBIsKeptAsAnObjectAndAnsweredInChunks(t *testing.T) {
ctx := context.Background()
objs := &objectsMem{objs: map[string][]byte{}}
ix := &Indexer{Inputs: newMem(), InputObjects: objs, Now: time.Now}
body := make([]byte, 3<<20+17)
if _, err := rand.Read(body); err != nil {
t.Fatal(err)
}
digest, err := ix.KeepInput(ctx, "ask-big", body)
if err != nil {
t.Fatal(err)
}
if len(objs.objs) != 1 {
t.Fatalf("a large input is one object beside the state, got %d", len(objs.objs))
}
if got, err := InputBody(ctx, ix.Inputs, objs, digest); err != nil || !bytes.Equal(got, body) {
t.Fatalf("the input read back: %d bytes, %v", len(got), err)
}
line := &lineMem{chunks: map[string][]byte{}}
verb := InputVerb(ix.Inputs, objs, line, time.Second, t.Logf)
raw, _ := json.Marshal(meshcatalog.InputInput{Digest: digest, Call: "0123456789abcdef"})
out, err := verb("", raw)
if err != nil {
t.Fatal(err)
}
ans := out.(meshcatalog.InputAnswer)
want := int64((len(body) + buscalls.SeatChunkBytes - 1) / buscalls.SeatChunkBytes)
if ans.Digest != digest || ans.Chunks != want || want < 6 {
t.Fatalf("the answer %+v, want %d chunks", ans, want)
}
send := buscalls.SeatChunks{Seat: meshcatalog.Seat, Verb: meshcatalog.VerbInput, Call: "0123456789abcdef"}
var got []byte
deadline := time.Now().Add(5 * time.Second)
for n := int64(1); n <= want; n++ {
for {
line.mu.Lock()
data, ok := line.chunks[send.Chunk(n)]
line.mu.Unlock()
if ok {
var c buscalls.SeatChunk
if err := json.Unmarshal(data, &c); err != nil || c.N != n || c.Of != want {
t.Fatalf("chunk %d: %+v, %v", n, c, err)
}
got = append(got, c.Data...)
break
}
if time.Now().After(deadline) {
t.Fatalf("chunk %d of %d never sent", n, want)
}
time.Sleep(time.Millisecond)
}
}
if !bytes.Equal(got, body) {
t.Error("the chunks do not carry the input's bytes")
}
if _, err := verb("", json.RawMessage(`{"digest":"sha256:none","call":"0123456789abcdef"}`)); err == nil {
t.Error("an input never kept is answered")
}
if _, err := verb("", json.RawMessage(`{"digest":"`+digest+`","call":"x"}`)); err == nil {
t.Error("a call of no token is answered")
}
}
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"
@@ -15,7 +16,10 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
)
// The indexer's inputs (design 47 G §354): the input of the ask `run` the indexer's validation puts, kept in the old state
@@ -42,6 +46,13 @@ func InputDigest(body []byte) string {
return "sha256:" + hex.EncodeToString(sum[:])
}
// runDigest is the digest a run ask names its input by (buildstep.RunInput.Digest): the 64 hex digits of its sha256, which are also
// the digest of the key's form, a name a state's key may hold (a ':' may not be).
func runDigest(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
// KeepInput keeps the exact bytes of the input of the run ask ask under their write time and sha256, after sweeping the keys more
// than a day old among the 20 oldest, and names it by the ask's id (A-78 d); it answers the digest. The same bytes written again in
// the same second are one key.
@@ -55,13 +66,9 @@ func (ix *Indexer) KeepInput(ctx context.Context, ask string, body []byte) (stri
if err := ix.sweepInputs(ctx); err != nil {
ix.logf("inputs: the keys older than a day are not all deleted: %v", err)
}
digest := InputDigest(body)
digest := runDigest(body)
key := meshcatalog.InputsByTimeDigestKey(ix.now().UTC().Format(inputsTime), digest)
data, err := json.Marshal(base64.StdEncoding.EncodeToString(body))
if err != nil {
return "", err
}
if err := ix.Inputs.Create(ctx, key, data); err != nil && !errors.Is(err, ErrConflict) {
if err := inputValues(ix.Inputs, ix.InputObjects).Put(key, base64.StdEncoding.EncodeToString(body)); err != nil && !errors.Is(err, stateobjects.ErrChanged) {
return "", err
}
if err := ix.Inputs.Create(ctx, InputsAskPrefix+keys.EscapeName(ask), []byte(key)); err != nil && !errors.Is(err, ErrConflict) {
@@ -149,40 +156,132 @@ func InputKey(ctx context.Context, st Store, digest string) (string, error) {
return "", nil
}
// Input answers the verb input: the exact bytes (base64) of the input of the digest, its digest checked.
func Input(ctx context.Context, st Store, in meshcatalog.InputInput) (meshcatalog.InputAnswer, error) {
var out meshcatalog.InputAnswer
if in.Digest == "" {
return out, errors.New("name the input's sha256 (digest)")
}
key, err := InputKey(ctx, st, in.Digest)
if err != nil {
return out, fmt.Errorf("the state %s cannot be read: %w", InputsState, err)
}
raw, _, found, err := st.Get(ctx, key)
if key == "" || err != nil || !found {
return out, fmt.Errorf("no input of digest %s is kept", in.Digest)
}
text, err := meshcatalog.DecodeInputsByTimeDigestValue(raw)
if err != nil {
return out, fmt.Errorf("the input %s cannot be read: %w", key, err)
}
body, err := base64.StdEncoding.DecodeString(text)
if err != nil || InputDigest(body) != in.Digest {
return out, fmt.Errorf("the input kept under %s does not match its digest", key)
}
return meshcatalog.InputAnswer{Digest: in.Digest, Input: text}, nil
// inputValues is the state inputs with its object store beside it (mesh-sdk stateobjects): a value that fits a key is in it, a
// larger one is an object with its reference in the key (ADR 0666 §16).
func inputValues(st Store, objs stateobjects.Objects) stateobjects.Values {
return stateobjects.Values{KV: storeKV{st}, Objects: objs, Store: catalogue.StateObjectsName(InputsState)}
}
// InputTool is the verb input as the tool mesh-catalog.input of the indexer's own runtime.
func InputTool(st Store) stdio.Tool {
return stdio.Tool{Name: meshcatalog.Seat + "." + meshcatalog.VerbInput, Run: func(args map[string]any) (any, error) {
// storeKV is a Store as the state's key-value side.
type storeKV struct{ s Store }
func (k storeKV) Get(key string) (*stateobjects.Entry, error) {
v, rev, found, err := k.s.Get(context.Background(), key)
if err != nil || !found {
return nil, err
}
return &stateobjects.Entry{Value: v, Revision: rev}, nil
}
func changed(err error) error {
if errors.Is(err, ErrConflict) {
return stateobjects.ErrChanged
}
return err
}
func (k storeKV) Create(key string, v []byte) error {
return changed(k.s.Create(context.Background(), key, v))
}
func (k storeKV) Update(key string, v []byte, rev uint64) error {
return changed(k.s.Update(context.Background(), key, v, rev))
}
func (k storeKV) Delete(key string) error {
_, rev, found, err := k.s.Get(context.Background(), key)
if err != nil || !found {
return err
}
return k.s.Delete(context.Background(), key, rev)
}
func (k storeKV) Keys() ([]string, error) { return k.s.Keys(context.Background()) }
// InputBody is the exact bytes of the input of the digest, checked against it, whatever its size.
func InputBody(ctx context.Context, st Store, objs stateobjects.Objects, digest string) ([]byte, error) {
if digest == "" {
return nil, errors.New("name the input's sha256 (digest)")
}
key, err := InputKey(ctx, st, digest)
if err != nil {
return nil, fmt.Errorf("the state %s cannot be read: %w", InputsState, err)
}
if key == "" {
return nil, fmt.Errorf("no input of digest %s is kept", digest)
}
var text string
found, err := inputValues(st, objs).Get(key, &text)
if err != nil {
return nil, fmt.Errorf("the input %s cannot be read: %w", key, err)
}
if !found {
return nil, fmt.Errorf("no input of digest %s is kept", digest)
}
body, err := base64.StdEncoding.DecodeString(text)
if err != nil || runDigest(body) != digest {
return nil, fmt.Errorf("the input kept under %s does not match its digest", key)
}
return body, nil
}
// ChunkLine is the core bus as a send of chunks reaches it: a chunk is published on its subject, kept on no stream, and each is
// acknowledged by the caller as a request on its ack subject, which the holder answers (design 47 G §681).
type ChunkLine interface {
Publish(subject string, data []byte) error
// Hear calls fn with the subject of each request made on subject (a pattern) and answers it, until stop is called.
Hear(subject string, fn func(subject string)) (stop func(), err error)
}
var callToken = regexp.MustCompile(`^[A-Za-z0-9_-]{16,64}$`)
// InputVerb answers the verb input (marked chunks): the digest and how many chunks carry the bytes, then sends them in chunks of at
// most buscalls.SeatChunkBytes on the call's chunk subjects, each acknowledged before the next (A-237, A-139). The input is of any
// size. A caller that does not acknowledge within buscalls.SeatAckWithin ends the send, said in logf.
func InputVerb(st Store, objs stateobjects.Objects, line ChunkLine, within time.Duration, logf func(string, ...any)) func(caller string, body json.RawMessage) (any, error) {
return func(_ string, raw json.RawMessage) (any, error) {
var in meshcatalog.InputInput
if raw, err := json.Marshal(args); err == nil {
_ = json.Unmarshal(raw, &in)
if err := json.Unmarshal(raw, &in); err != nil {
return nil, fmt.Errorf("the arguments are not JSON: %w", err)
}
return Input(context.Background(), st, in)
}}
if !callToken.MatchString(in.Call) {
return nil, errors.New("call is one token of 16 to 64 letters, digits, - or _: 128 random bits the caller chooses")
}
body, err := InputBody(context.Background(), st, objs, in.Digest)
if err != nil {
return nil, err
}
send := buscalls.SeatChunks{Seat: meshcatalog.Seat, Verb: meshcatalog.VerbInput, Call: in.Call}
total := int64((len(body) + buscalls.SeatChunkBytes - 1) / buscalls.SeatChunkBytes)
acks := make(chan string, 1)
// heard before the answer goes, since the caller acknowledges only after it
stop, err := line.Hear(buscalls.SeatAcks(meshcatalog.Seat, meshcatalog.VerbInput), func(subject string) {
select {
case acks <- subject:
default:
}
})
if err != nil {
return nil, fmt.Errorf("the acknowledgements cannot be heard, so no chunk is sent: %w", err)
}
go func() {
defer stop()
for n := int64(1); n <= total; n++ {
from := (n - 1) * buscalls.SeatChunkBytes
to := min(int64(len(body)), from+buscalls.SeatChunkBytes)
data, _ := json.Marshal(buscalls.SeatChunk{N: n, Of: total, Data: body[from:to]})
if err := line.Publish(send.Chunk(n), data); err != nil {
logf("the send %s of the input %s ends at chunk %d of %d: %v", in.Call, in.Digest, n, total, err)
return
}
if !acknowledged(acks, send.Ack(n), within) {
logf("the send %s of the input %s ends at chunk %d of %d: no acknowledgement within %s", in.Call, in.Digest, n, total, within)
return
}
}
}()
return meshcatalog.InputAnswer{Digest: in.Digest, Chunks: total}, nil
}
}
// InputsAnswer is what indexer_inputs answers: each key kept with its time, digest and size, or one input's bytes.
@@ -200,7 +299,7 @@ type InputKept struct {
// InputsReadTool is indexer_inputs (class read): every input the indexer keeps, with its key, digest and size; with a digest,
// that input whole (base64).
func InputsReadTool(st Store) stdio.Tool {
func InputsReadTool(st Store, objs stateobjects.Objects) stdio.Tool {
return stdio.Tool{Name: InputsTool, Description: "Read the indexer's inputs of the ask run (design 47 G section 354): each key (write time and sha256) with its size; with a digest, that input's exact bytes, base64.",
Input: map[string]any{"type": "object", "properties": map[string]any{"digest": map[string]any{"type": "string", "description": "one input's sha256; none lists every key"}}},
Run: func(args map[string]any) (any, error) {
@@ -221,9 +320,18 @@ func InputsReadTool(st Store) stdio.Tool {
if err != nil || !found {
continue
}
text, _ := meshcatalog.DecodeInputsByTimeDigestValue(raw)
kept := InputKept{Key: k, Digest: d, Bytes: base64.StdEncoding.DecodedLen(len(text))}
var kept InputKept
if ref, isRef := stateobjects.ReadRef(raw); isRef {
kept = InputKept{Key: k, Digest: d, Bytes: base64.StdEncoding.DecodedLen(int(ref.Size) - 2)} // the quotes of the JSON string
} else {
text, _ := meshcatalog.DecodeInputsByTimeDigestValue(raw)
kept = InputKept{Key: k, Digest: d, Bytes: base64.StdEncoding.DecodedLen(len(text))}
}
if want != "" {
var text string
if ok, err := inputValues(st, objs).Get(k, &text); err != nil || !ok {
continue
}
kept.Input = text
}
out.Inputs = append(out.Inputs, kept)
@@ -231,3 +339,19 @@ func InputsReadTool(st Store) stdio.Tool {
return out, nil
}}
}
// acknowledged waits within for the acknowledgement on subject, skipping another call's.
func acknowledged(acks <-chan string, subject string, within time.Duration) bool {
ctx, cancel := context.WithTimeout(context.Background(), within)
defer cancel()
for {
select {
case got := <-acks:
if got == subject {
return true
}
case <-ctx.Done():
return false
}
}
}
@@ -260,11 +260,10 @@ func TestAnInputIsKeptByItsDigestAndSweptAfterADay(t *testing.T) {
if err != nil {
t.Fatal(err)
}
a, err := Input(ctx, ix.Inputs, meshcatalog.InputInput{Digest: d1})
if err != nil || a.Digest != d1 || a.Input != "Zmlyc3Q=" {
t.Fatalf("the input read back: %+v, %v", a, err)
if a, err := InputBody(ctx, ix.Inputs, nil, d1); err != nil || string(a) != "first" {
t.Fatalf("the input read back: %q, %v", a, err)
}
if _, err := Input(ctx, ix.Inputs, meshcatalog.InputInput{Digest: "sha256:none"}); err == nil {
if _, err := InputBody(ctx, ix.Inputs, nil, "sha256:none"); err == nil {
t.Error("an input never kept is answered")
}
now = now.Add(23 * time.Hour)
@@ -292,10 +291,10 @@ func TestAnInputIsKeptByItsDigestAndSweptAfterADay(t *testing.T) {
if all, _ := ix.Inputs.Keys(ctx); slices.Contains(all, InputsAskPrefix+"ask-1") {
t.Errorf("the name by ask of the swept input stays: %v", all)
}
if _, err := Input(ctx, ix.Inputs, meshcatalog.InputInput{Digest: d1}); err == nil {
if _, err := InputBody(ctx, ix.Inputs, nil, d1); err == nil {
t.Error("the input a day old is still answered")
}
if _, err := Input(ctx, ix.Inputs, meshcatalog.InputInput{Digest: d3}); err != nil {
if _, err := InputBody(ctx, ix.Inputs, nil, d3); err != nil {
t.Error(err)
}
}
@@ -517,16 +516,16 @@ func TestAnInputIsDeletedAtTheStepEndedOfItsAsk(t *testing.T) {
if err := ix.HearInputEnded(ctx, nodebuildgo.StepEnded{Ask: "a.1"}); err != nil {
t.Errorf("an end heard again: %v", err)
}
if _, err := Input(ctx, ix.Inputs, meshcatalog.InputInput{Digest: d1}); err == nil {
if _, err := InputBody(ctx, ix.Inputs, nil, d1); err == nil {
t.Error("the input of an ended ask is still answered")
}
if _, err := Input(ctx, ix.Inputs, meshcatalog.InputInput{Digest: d2}); err != nil {
if _, err := InputBody(ctx, ix.Inputs, nil, d2); err != nil {
t.Errorf("the input of another ask: %v", err)
}
if got, _ := ix.Inputs.Keys(ctx); len(got) != 2 {
t.Errorf("keys after one end: %v", got)
}
if a, err := InputsReadTool(ix.Inputs).Run(map[string]any{}); err != nil || len(a.(InputsAnswer).Inputs) != 1 {
if a, err := InputsReadTool(ix.Inputs, nil).Run(map[string]any{}); err != nil || len(a.(InputsAnswer).Inputs) != 1 {
t.Errorf("indexer_inputs counts a key by ask: %+v, %v", a, err)
}
}
@@ -382,7 +382,9 @@ func TestServesNoRequestSubjectAndHoldsNoTicker(t *testing.T) { // 1238/AC9 (ADR
for _, f := range forbidden {
// The seat list reader's Bus interface names a method Subscribe; the indexer's own bus hands it its own events
// directly and subscribes to nothing on the bus.
if f == "Subscribe(" && strings.HasSuffix(path, "seatlist.go") {
// The acknowledgements of the chunks of the verb input are heard on the core connection while a send runs (design 47 G
// §712, A-237, §681): cmd/mesh-delivery-indexer/inputs.go.
if f == "Subscribe(" && (strings.HasSuffix(path, "seatlist.go") || strings.HasSuffix(path, "mesh-delivery-indexer/inputs.go")) {
continue
}
if strings.Contains(string(src), f) {