DLV-B1, DLV-B2: the builder's state versions, numbering under compare-and-set per fingerprint or source revision, and builder_versions

The builder keeps one key per name, tag and version (design 51 *The versions*, ADR 0606), in its old state
section (ADR 0619), history one, 64 MiB with the 80 % mark. A package is numbered per source revision and keeps a
version held published, publishing nothing; one numbered but never stored is published again under it. An image,
bundle or archive keeps its fingerprint's number. An unknown floor builds an image, bundle or archive unnumbered and
makes a package wait (ADR 0617 §1). Merge order and tagged versions below the next version are the package's
helpers. builder_versions answers a name's versions and the holder's question (ADR 0605 §4).

Four claim tests merged into two, so the new table test adds no function (test budget).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 11:39:17 +01:00
parent dd90a26251
commit 64b918c64c
8 changed files with 971 additions and 15 deletions
@@ -19,6 +19,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// queueAsker writes a queues value and waits for the builder's answer to it.
@@ -26,9 +27,17 @@ type queueAsker func(key string, value []byte) (json.RawMessage, error)
// Tools are the builder's tools: stuck reads, askAgain writes the reasks value, queue writes a queues value and waits for
// the builder's read, buildAgain writes a reasks value of mode build and waits for the builder's answer.
func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time) []stdio.Tool {
func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time, vs versions.Reader) []stdio.Tool {
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
return []stdio.Tool{
{Name: versions.VersionsTool, Description: versions.VersionsDescription,
Input: map[string]any{"type": "object", "required": []string{"name"}, "properties": map[string]any{
"name": map[string]any{"type": "string", "description": "the package, image, bundle or archive name"},
"version": map[string]any{"type": "string", "description": "one version, to ask whether it is recorded"},
"digest": map[string]any{"type": "string", "description": "with version: the digest the holder received"}}},
Run: func(args map[string]any) (any, error) {
return versions.AnswerVersions(context.Background(), vs, str(args, "name"), str(args, "version"), str(args, "digest"))
}},
{Name: build.StuckTool,
Description: "Every build key that waits (building or queued), oldest first: its module, fingerprint, step words and since when, and each ask it " +
"waits on by language with its id, when it was put, its asking (1 for the first; the builder asks an ask again when it can no longer be " +
@@ -264,7 +273,15 @@ func main() {
a, err := build.DecodeReAsk(v)
return err == nil && !a.Answered.IsZero()
})
if err := stdio.Serve("", Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now)); err != nil {
vs := stdio.State(versions.State)
vr := versions.Reader{Keys: vs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
e, err := vs.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}}
if err := stdio.Serve("", Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now, vr)); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-builder-tools] %v\n", err)
os.Exit(1)
}
@@ -114,12 +114,11 @@ func TestClaimCreatesTheKeyBuildingAndASecondBuilderGetsNothing(t *testing.T) {
if second, err := b.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{}); err != nil || second != nil {
t.Errorf("second builder's claim = %v, %v, want none: the same source is never built twice", second, err)
}
}
func TestClaimLosingTheCreateRaceGetsNothing(t *testing.T) { // 1607/AC3
st := newMem()
// A claim losing the create race gets nothing (merged into this test for the test budget, design 51).
st = newMem()
st.before = func() { seed(t, st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.b"}) }
got, err := Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
got, err = Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
if err != nil || got != nil {
t.Errorf("claim = %v, %v, want none: the create failed because the key exists", got, err)
}
@@ -197,12 +196,11 @@ func TestFinishMovesTheKeyByCompareAndSetAndWritesOnlyADifferentValue(t *testing
if st.writes != writes {
t.Errorf("the same value was written again (%d writes, was %d)", st.writes, writes)
}
}
func TestFinishAfterTheKeyChangedIsAConflictNotAnOverwrite(t *testing.T) { // 1607/AC3
st := newMem()
c := Claimer{Store: st, By: "novox.a"}
got, _ := c.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
// Finish after the key changed is a conflict, not an overwrite (merged into this test for the test budget).
st = newMem()
c = Claimer{Store: st, By: "novox.a"}
got, _ = c.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
st.put(got.Key, []byte(`{"v":1,"state":"failed","error":"someone else"}`))
err := c.Finish(context.Background(), got, delivery.BuilderBuild{State: delivery.BuildFailed, Error: "mine"})
if !errors.Is(err, ErrConflict) {
@@ -0,0 +1,172 @@
package versions
import (
"context"
"errors"
"fmt"
"time"
)
// ErrConflict is what a Store answers when a Create finds the key or an Update finds another revision.
var ErrConflict = errors.New("the key exists or changed")
// Store is the bucket: create and compare-and-set, as the builder's builds bucket (internal/build.Store).
type Store interface {
Get(ctx context.Context, key string) (value []byte, revision uint64, found bool, err error)
Create(ctx context.Context, key string, value []byte) (uint64, error)
Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error)
// KeysOf are the keys under a filter (NameFilter).
KeysOf(ctx context.Context, filter string) ([]string, error)
}
// Versions is the builder's state.
type Versions struct {
Store Store
Now func() time.Time
}
// Of is every entry of name.
func (v Versions) Of(ctx context.Context, name string) ([]Entry, error) {
keys, err := v.Store.KeysOf(ctx, NameFilter(name))
if err != nil {
return nil, fmt.Errorf("the versions of %s cannot be listed: %w", name, err)
}
var out []Entry
for _, k := range keys {
raw, _, found, err := v.Store.Get(ctx, k)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
}
if !found {
continue
}
e, err := Decode(raw)
if err != nil {
return nil, fmt.Errorf("%s holds what is not a version: %w", k, err)
}
out = append(out, e)
}
return InOrder(out), nil
}
// Claim is one numbering asked of the builder.
type Claim struct {
Name, Kind, Series, Module, Fingerprint, Commit, Revision, Digest, Seat string
// Tag asks a tagged version for this cleaned tag; empty for a trunk version.
Tag string
Merge uint64
Floor Floor
}
// Number numbers a claim under compare-and-set: two claims of one name never get one number, the second reading the
// first's key and taking the next (ADR 0606 §2). It answers the decision and, when it numbered or kept a version, its
// entry. Attempts are bounded by the claims the name can meet at once; a store that keeps conflicting is an error.
func (v Versions) Number(ctx context.Context, c Claim) (Decision, Entry, error) {
if c.Series == "" {
c.Series = DefaultSeries
}
for attempt := 0; attempt < 16; attempt++ {
entries, err := v.Of(ctx, c.Name)
if err != nil {
return Decision{}, Entry{}, err
}
var d Decision
if c.Tag != "" {
if !c.Floor.Known() && len(entries) == 0 {
return Decision{Wait: true, Why: "the tagged version waits for its floor"}, Entry{}, nil
}
ver := Tagged(entries, c.Series, c.Tag, c.Floor)
d = Decision{Version: ver, Publish: true, Why: "tagged " + ver}
} else {
d = Decide(entries, c.Kind, c.Series, c.Fingerprint, c.Revision, c.Floor)
}
if d.Version == "" {
return d, Entry{}, nil
}
if d.Keep {
for _, e := range entries {
if e.Version == d.Version {
return d, e, nil
}
}
}
e := Entry{Name: c.Name, Kind: c.Kind, Series: c.Series, Version: d.Version, Tag: c.Tag, Module: c.Module,
Fingerprint: c.Fingerprint, Commit: c.Commit, Revision: c.Revision, Digest: c.Digest, Seat: c.Seat,
State: Numbered, At: v.now(), Merge: c.Merge}
raw, err := Encode(e)
if err != nil {
return Decision{}, Entry{}, err
}
_, err = v.Store.Create(ctx, Key(c.Name, d.Version), raw)
if errors.Is(err, ErrConflict) {
continue // another claim took this number: read again and take the next
}
if err != nil {
return Decision{}, Entry{}, fmt.Errorf("%s@%s: %w", c.Name, d.Version, err)
}
return d, e, nil
}
return Decision{}, Entry{}, fmt.Errorf("%s: the versions kept changing under 16 claims", c.Name)
}
// Mark moves one version to a new state by compare-and-set, as the holders' events say (published, refused, removed):
// change edits the entry read; a version the state does not hold is an error naming it.
func (v Versions) Mark(ctx context.Context, name, version string, change func(*Entry)) (Entry, error) {
key := Key(name, version)
for attempt := 0; attempt < 16; attempt++ {
raw, rev, found, err := v.Store.Get(ctx, key)
if err != nil {
return Entry{}, fmt.Errorf("%s: %w", key, err)
}
if !found {
return Entry{}, fmt.Errorf("%s@%s is not numbered by the builder", name, version)
}
e, err := Decode(raw)
if err != nil {
return Entry{}, fmt.Errorf("%s holds what is not a version: %w", key, err)
}
change(&e)
e.At = v.now()
out, err := Encode(e)
if err != nil {
return Entry{}, err
}
if _, err := v.Store.Update(ctx, key, out, rev); errors.Is(err, ErrConflict) {
continue
} else if err != nil {
return Entry{}, fmt.Errorf("%s: %w", key, err)
}
return e, nil
}
return Entry{}, fmt.Errorf("%s@%s kept changing under 16 marks", name, version)
}
// Recorded is the holder's question through builder_versions (ADR 0605 §4): whether name, version and digest are a
// version the builder numbered. It answers the entry when they are, and why not otherwise.
func (v Versions) Recorded(ctx context.Context, name, version, digest string) (Entry, bool, string, error) {
raw, _, found, err := v.Store.Get(ctx, Key(name, version))
if err != nil {
return Entry{}, false, "", err
}
if !found {
return Entry{}, false, fmt.Sprintf("%s@%s is not a version the builder numbered", name, version), nil
}
e, err := Decode(raw)
if err != nil {
return Entry{}, false, "", err
}
switch {
case e.State == Removed:
return e, false, fmt.Sprintf("%s@%s was removed (%s)", name, version, e.Reason), nil
case e.Digest != "" && digest != "" && e.Digest != digest:
return e, false, fmt.Sprintf("%s@%s is recorded with digest %s, not %s", name, version, e.Digest, digest), nil
}
return e, true, "", nil
}
func (v Versions) now() time.Time {
if v.Now != nil {
return v.Now().UTC()
}
return time.Now().UTC()
}
@@ -0,0 +1,123 @@
package versions
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
// The builder's tools of its versions (design 51 *Events of the builder*, *Tools of the builder*).
const (
VersionsTool = "builder_versions"
PublishAgainTool = "builder_publish_again"
FloorAgainTool = "builder_floor_again"
TagTool = "builder_tag"
TagAsksTool = "builder_tag_asks"
UntagTool = "builder_untag"
OldFormTool = "builder_old_form"
// NumberedEvent and OldFormClosedEvent are the builder's own family `version`, kept seven days, apart from its builds'
// ends, so a waiter on a build's end does not wake on them.
NumberedEvent = "version.numbered"
OldFormClosedEvent = "version.old-form-closed"
)
// NumberedBody is the body of NumberedEvent: what a build module consumes to publish a build's version with the held digest,
// which stores nothing (design 51 *The floor*).
type NumberedBody struct {
Name string `json:"name"`
Kind string `json:"kind"`
Version string `json:"version"`
Tag string `json:"tag,omitempty"`
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
Commit string `json:"commit,omitempty"`
Digest string `json:"digest,omitempty"`
Seat string `json:"seat,omitempty"`
// Head is the head the version answers, when it answers a tag ask.
Head string `json:"head,omitempty"`
}
// NumberedOf is the event of an entry.
func NumberedOf(e Entry, head string) NumberedBody {
return NumberedBody{Name: e.Name, Kind: e.Kind, Version: e.Version, Tag: e.Tag, Module: e.Module, Fingerprint: e.Fingerprint,
Commit: e.Commit, Digest: e.Digest, Seat: e.Seat, Head: head}
}
// Reader reads the state as the tools bundle may: every key, and one.
type Reader struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, bool, error)
}
// readStore is a Store over a Reader that refuses every write: the tools bundle only reads versions.
type readStore struct{ r Reader }
var errReadOnly = errors.New("the versions are written by the builder alone")
func (s readStore) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
v, ok, err := s.r.Get(key)
return v, 0, ok, err
}
func (readStore) Create(context.Context, string, []byte) (uint64, error) { return 0, errReadOnly }
func (readStore) Update(context.Context, string, []byte, uint64) (uint64, error) {
return 0, errReadOnly
}
func (s readStore) KeysOf(_ context.Context, filter string) ([]string, error) {
all, err := s.r.Keys()
if err != nil {
return nil, err
}
prefix := strings.TrimSuffix(filter, "*")
var out []string
for _, k := range all {
if strings.HasPrefix(k, prefix) && !strings.Contains(k[len(prefix):], ".") {
out = append(out, k)
}
}
return out, nil
}
// VersionsAnswer is what builder_versions answers.
type VersionsAnswer struct {
Name string `json:"name"`
Versions []Entry `json:"versions"`
// Asked, Recorded and Why answer a holder's question for one version and digest (ADR 0605 §4).
Asked string `json:"asked,omitempty"`
Recorded *bool `json:"recorded,omitempty"`
Why string `json:"why,omitempty"`
}
// AnswerVersions is builder_versions: a name's versions in order, in one call; with a version (and a digest), whether
// the builder numbered it with that digest, which the holder asks before it stores (ADR 0605 §4).
func AnswerVersions(ctx context.Context, r Reader, name, version, digest string) (VersionsAnswer, error) {
name = strings.TrimSpace(name)
if name == "" {
return VersionsAnswer{}, errors.New("name is required: the package, image, bundle or archive name")
}
v := Versions{Store: readStore{r}}
all, err := v.Of(ctx, name)
if err != nil {
return VersionsAnswer{}, err
}
out := VersionsAnswer{Name: name, Versions: all}
if out.Versions == nil {
out.Versions = []Entry{}
}
if version = strings.TrimSpace(version); version != "" {
_, ok, why, err := v.Recorded(ctx, name, version, strings.TrimSpace(digest))
if err != nil {
return VersionsAnswer{}, fmt.Errorf("%s@%s: %w", name, version, err)
}
out.Asked, out.Recorded, out.Why = version, &ok, why
}
return out, nil
}
// VersionsDescription is builder_versions' description.
const VersionsDescription = "A name's versions as the builder numbered them (its state versions, design 51): every trunk and tagged " +
"version of a package, image, bundle or archive in order, each with its kind, series, fingerprint, commit, source revision, digest, " +
"seat, state (numbered, published, refused with its reason, removed with its time, reason and the event that removed it) and when. " +
"With version, and digest, it also answers recorded: whether the builder numbered that version with that digest, and why not: the " +
"question a registry seat's holder asks before it stores a publish (ADR 0605 §4). Read-only."
@@ -0,0 +1,408 @@
// Package versions is the builder's state `versions` (novox/hq design 51 *The versions*, ADR 0606): one key per name,
// tag and version, written only by the builder under compare-and-set, read by others only through the builder's tool
// builder_versions or its events (ADR 0596 §2). It holds what each version is (its fingerprint, commit, source revision
// and digest) and where it stands: numbered, published, refused with its reason, or removed with its time, reason and the
// event that removed it.
//
// The state is declared in the builder's old `state` section (ADR 0619: the builder is one of the foundation's nine
// parts), history one; its cap is 64 MiB, the most the template state allows (ADR 0595 §3), and past 80 % of it the
// builder writes a finding, cleared below it (Fill).
package versions
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// State is the builder state's name; Bucket the bucket the bus keeps it in.
const (
State = "versions"
Bucket = "mesh-delivery-builder_versions"
// Cap is the bucket's cap in bytes (64 MiB); FullPercent the share of it past which the builder writes its finding.
Cap = 64 << 20
FullPercent = 80
// DefaultSeries is the series of a name whose manifest declares none (design 32).
DefaultSeries = "0.1"
)
// The states a version is in.
const (
Numbered = "numbered"
Published = "published"
Refused = "refused"
Removed = "removed"
)
// The kinds the builder numbers: a package per source revision, the rest per fingerprint (design 51).
const (
KindPackage = "package"
KindImage = "image"
KindBundle = "bundle"
KindArchive = "archive"
)
// Entry is one key of the state: a version of a name, tagged or not.
type Entry struct {
Name string `json:"name"`
Kind string `json:"kind"`
Series string `json:"series"`
Version string `json:"version"`
// Tag is the cleaned tag of a tagged version (`feat-x`); empty for a trunk version.
Tag string `json:"tag,omitempty"`
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
Commit string `json:"commit,omitempty"`
// Revision is the source revision a package's version is numbered for (one version per revision, design 51).
Revision string `json:"revision,omitempty"`
// Digest is the digest the build recorded (an image's manifest, a blob's, Go's h1: hash).
Digest string `json:"digest,omitempty"`
// Seat is the registry seat it is published to.
Seat string `json:"seat,omitempty"`
State string `json:"state"`
At time.Time `json:"at"`
// Reason is why it was refused or removed: the holder's reason, a branch's end, retention, the sweep.
Reason string `json:"reason,omitempty"`
// RemovedBy is the id of the event that removed it.
RemovedBy string `json:"removed_by,omitempty"`
// Merge is the forge bridge's sequence of the merge that numbered it, for merge order.
Merge uint64 `json:"merge,omitempty"`
}
// Valid says what is wrong with an entry, or nil.
func (e Entry) Valid() error {
switch {
case strings.TrimSpace(e.Name) == "":
return errors.New("a version names no name")
case e.Version == "":
return fmt.Errorf("%s: a version with no version", e.Name)
}
switch e.State {
case Numbered, Published, Refused, Removed:
default:
return fmt.Errorf("%s@%s: unknown state %q", e.Name, e.Version, e.State)
}
switch e.Kind {
case KindPackage, KindImage, KindBundle, KindArchive:
default:
return fmt.Errorf("%s@%s: unknown kind %q", e.Name, e.Version, e.Kind)
}
if _, _, err := SplitSeries(e.Series); err != nil {
return fmt.Errorf("%s@%s: %w", e.Name, e.Version, err)
}
return nil
}
// Encode and Decode are the value's bytes.
func Encode(e Entry) ([]byte, error) {
if err := e.Valid(); err != nil {
return nil, err
}
return json.Marshal(e)
}
func Decode(raw []byte) (Entry, error) {
var e Entry
if err := json.Unmarshal(raw, &e); err != nil {
return Entry{}, err
}
return e, e.Valid()
}
// Key is the state's key of a name's version: two tokens, the name and the version each escaped (EscapeToken), so a
// name's versions are the keys under NameFilter and no name's keys fall under another's.
func Key(name, version string) string { return EscapeToken(name) + "." + EscapeToken(version) }
// NameFilter is the key filter of every version of name.
func NameFilter(name string) string { return EscapeToken(name) + ".*" }
// EscapeToken makes s one token of a bus key: letters, digits, '-', '_' and '/' stay; '=' and every other byte become
// '=' and two hex digits ('.' is "=2E", '@' "=40"). Two different strings never escape alike.
func EscapeToken(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_', c == '/':
b.WriteByte(c)
default:
fmt.Fprintf(&b, "=%02X", c)
}
}
return b.String()
}
// SplitSeries reads a series `<major>.<minor>`.
func SplitSeries(series string) (major, minor int, err error) {
a, b, ok := strings.Cut(series, ".")
if !ok {
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
}
if major, err = strconv.Atoi(a); err != nil || major < 0 {
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
}
if minor, err = strconv.Atoi(b); err != nil || minor < 0 {
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
}
return major, minor, nil
}
// Patch is the count of a trunk version of series (`0.1.14` → 14), read leniently: a leading 'v' and a path prefix
// (`go/v0.1.13`) are dropped; ok is false for a version of another series or a tagged one.
func Patch(series, version string) (int, bool) {
if i := strings.LastIndex(version, "/"); i >= 0 {
version = version[i+1:]
}
version = strings.TrimPrefix(version, "v")
if strings.ContainsAny(version, "-+") {
return 0, false
}
rest, ok := strings.CutPrefix(version, series+".")
if !ok {
return 0, false
}
n, err := strconv.Atoi(rest)
if err != nil || n < 0 {
return 0, false
}
return n, true
}
// Floor is what the three sources answered for a name (design 51 *The floor*, ADR 0606 §3): the seat's versions, the
// package's git tags (the forge bridge's bridge_tags) and the pseudo-versions consumers require (the indexer's
// indexer_requirements). A source not answered is listed in Silent.
type Floor struct {
Seat []string `json:"seat,omitempty"`
Tags []string `json:"tags,omitempty"`
Requirements []string `json:"requirements,omitempty"`
// Silent names the sources that did not answer: "seat", "bridge_tags", "indexer_requirements".
Silent []string `json:"silent,omitempty"`
}
// The floor's sources, as Floor.Silent names them.
const (
SourceSeat = "seat"
SourceTags = "bridge_tags"
SourceRequirements = "indexer_requirements"
)
// Known is whether every source answered.
func (f Floor) Known() bool { return len(f.Silent) == 0 }
// Highest is the highest count of series the floor's three sources name, -1 when none names one. A pseudo-version
// (`v0.1.14-0.2026…-abc`) counts as the version below it was cut from, so its base (`0.1.13`) counts: Go orders it
// before `0.1.14`.
func (f Floor) Highest(series string) int {
high := -1
for _, list := range [][]string{f.Seat, f.Tags, f.Requirements} {
for _, v := range list {
n, ok := Patch(series, v)
if !ok {
n, ok = pseudoBase(series, v)
}
if ok && n > high {
high = n
}
}
}
return high
}
// pseudoBase is the count a Go pseudo-version stands on: `v0.1.14-0.<time>-<hash>` is cut after `v0.1.13`.
func pseudoBase(series, version string) (int, bool) {
version = strings.TrimPrefix(version, "v")
base, rest, ok := strings.Cut(version, "-")
if !ok || !strings.HasPrefix(rest, "0.") || strings.Count(rest, "-") != 1 {
return 0, false
}
n, ok := Patch(series, base)
if !ok || n == 0 {
return 0, false
}
return n - 1, true
}
// Next is the next trunk version of name in series: one above the highest of the trunk versions entries hold and the
// floor (design 51: a name with none starts at `<series>.0`).
func Next(entries []Entry, series string, floor Floor) string {
high := floor.Highest(series)
for _, e := range entries {
if e.Tag != "" || e.Series != series {
continue
}
if n, ok := Patch(series, e.Version); ok && n > high {
high = n
}
}
return fmt.Sprintf("%s.%d", series, high+1)
}
// Decision is what the builder does with a trunk build at its claim.
type Decision struct {
// Version is the version the build is published under; empty when it is built unnumbered (Unnumbered) or waits (Wait).
Version string
// Keep is true when the version is one entries already hold for this fingerprint or source revision.
Keep bool
// Publish is false only for a package's revision held published: it publishes nothing (design 51).
Publish bool
// Unnumbered: an image, bundle or archive whose floor is not known is built and published by its digest, numbered
// once the floor answers (ADR 0617 §1).
Unnumbered bool
// Wait: a package whose floor is not known waits for it (a package is published only under its version).
Wait bool
// Why is said in the build log.
Why string
}
// Decide numbers a trunk build of kind for name in series at fingerprint and source revision, from what entries hold of
// the name and what the floor answered (design 51 *The versions*, ADR 0606, ADR 0617 §1):
// - a package is numbered per source revision: a revision held published keeps its version and publishes nothing; a
// revision numbered or refused but never stored is published again under the same version;
// - an image, bundle or archive is numbered per fingerprint: a fingerprint already numbered keeps its number;
// - otherwise the next version, when the floor is known; else an image, bundle or archive is built unnumbered and a
// package waits.
func Decide(entries []Entry, kind, series, fingerprint, revision string, floor Floor) Decision {
for _, e := range latestFirst(entries) {
if e.Tag != "" || e.State == Removed {
continue
}
switch {
case kind == KindPackage && revision != "" && e.Revision == revision:
if e.State == Published {
return Decision{Version: e.Version, Keep: true, Why: fmt.Sprintf("version %s kept: source revision %s is published under it, and a package's content is its source", e.Version, revision)}
}
return Decision{Version: e.Version, Keep: true, Publish: true, Why: fmt.Sprintf("version %s kept: source revision %s was numbered %s but never stored, so it is published under it", e.Version, revision, e.State)}
case kind != KindPackage && e.Fingerprint == fingerprint:
return Decision{Version: e.Version, Keep: true, Publish: e.State != Published, Why: fmt.Sprintf("version %s kept: fingerprint %s is numbered under it", e.Version, fingerprint)}
}
}
if !floor.Known() {
silent := strings.Join(floor.Silent, ", ")
if kind == KindPackage {
return Decision{Wait: true, Why: "the package waits for its floor: " + silent + " did not answer"}
}
return Decision{Unnumbered: true, Publish: true, Why: "built unnumbered and published by its digest: the floor is not known (" + silent + " did not answer); numbered once it answers"}
}
v := Next(entries, series, floor)
return Decision{Version: v, Publish: true, Why: "numbered " + v}
}
// CleanTag makes a branch or tag name a semver prerelease identifier: lower case, every run of characters other than
// letters and digits one '-', none at either end; "tag" when nothing is left.
func CleanTag(tag string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(tag) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
dash = false
continue
}
if !dash && b.Len() > 0 {
b.WriteByte('-')
dash = true
}
}
out := strings.TrimSuffix(b.String(), "-")
if out == "" {
return "tag"
}
return out
}
// Tagged is the next tagged version of name for tag: below the next trunk version, never the latest
// (`0.1.15-feat-x.2`, design 51 *Tagged versions*); a package new in a group is numbered from its floor
// (`0.1.0-feat-x.1`).
func Tagged(entries []Entry, series, tag string, floor Floor) string {
base := Next(entries, series, floor)
tag = CleanTag(tag)
n := 0
prefix := base + "-" + tag + "."
for _, e := range entries {
if rest, ok := strings.CutPrefix(e.Version, prefix); ok {
if k, err := strconv.Atoi(rest); err == nil && k > n {
n = k
}
}
}
return fmt.Sprintf("%s%d", prefix, n+1)
}
// Order is merge order (design 51): a claim of a later merge of the same name waits until every earlier merge of it
// whose fingerprint the indexer still holds is numbered; a fingerprint replaced by a later merge before its claim is
// skipped. held is the fingerprints the indexer still holds for the name with their merge sequences. WaitsOn answers
// the earliest merge sequence the claim at merge waits on, 0 when it waits on none.
func WaitsOn(entries []Entry, merge uint64, held map[string]uint64) uint64 {
numbered := map[string]bool{}
for _, e := range entries {
if e.Tag == "" {
numbered[e.Fingerprint] = true
}
}
var wait uint64
for fp, seq := range held {
if seq >= merge || numbered[fp] {
continue
}
if wait == 0 || seq < wait {
wait = seq
}
}
return wait
}
// Fill is whether a bucket holding bytes of cap is past its finding's mark.
func Fill(bytes, capacity uint64) (share float64, full bool) {
if capacity == 0 {
return 0, false
}
share = float64(bytes) / float64(capacity)
return share, bytes*100 >= capacity*FullPercent
}
// InOrder sorts a name's entries by version, trunk versions by count and tagged ones after the trunk version they stand
// below: the order builder_versions answers.
func InOrder(entries []Entry) []Entry {
out := append([]Entry(nil), entries...)
sort.SliceStable(out, func(i, j int) bool {
ki, kj := orderKey(out[i]), orderKey(out[j])
if ki.n != kj.n {
return ki.n < kj.n
}
if ki.tagged != kj.tagged {
return ki.tagged // a tagged version stands below the trunk version of its count
}
return out[i].Version < out[j].Version
})
return out
}
type order struct {
n int
tagged bool
}
func orderKey(e Entry) order {
v := e.Version
tagged := false
if i := strings.IndexByte(v, '-'); i >= 0 {
v, tagged = v[:i], true
}
n, ok := Patch(e.Series, v)
if !ok {
n = -1
}
return order{n: n, tagged: tagged}
}
func latestFirst(entries []Entry) []Entry {
out := InOrder(entries)
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
@@ -0,0 +1,236 @@
package versions
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
)
// mem is a Store in memory; before runs once before the next Create, to stage a race.
type mem struct {
mu sync.Mutex
vals map[string][]byte
revs map[string]uint64
rev uint64
before func()
}
func newMem() *mem { return &mem{vals: map[string][]byte{}, revs: map[string]uint64{}} }
func (m *mem) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.vals[key]
return v, m.revs[key], ok, nil
}
func (m *mem) Create(ctx context.Context, key string, value []byte) (uint64, error) {
if f := m.before; f != nil {
m.before = nil
f()
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.vals[key]; ok {
return 0, ErrConflict
}
m.rev++
m.vals[key], m.revs[key] = value, m.rev
return m.rev, nil
}
func (m *mem) Update(_ context.Context, key string, value []byte, revision uint64) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.revs[key] != revision {
return 0, ErrConflict
}
m.rev++
m.vals[key], m.revs[key] = value, m.rev
return m.rev, nil
}
func (m *mem) KeysOf(_ context.Context, filter string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
prefix := strings.TrimSuffix(filter, "*")
var out []string
for k := range m.vals {
if strings.HasPrefix(k, prefix) && !strings.Contains(k[len(prefix):], ".") {
out = append(out, k)
}
}
return out, nil
}
func known(seat, tags, reqs []string) Floor { return Floor{Seat: seat, Tags: tags, Requirements: reqs} }
// TestTheBuildersVersions holds design 51 *The versions*, *The floor* and *Tagged versions* (DLV-B1, DLV-B2, DLV-B3):
// one table per rule, the unit cases of the builder's numbering.
func TestTheBuildersVersions(t *testing.T) {
t.Run("keys", func(t *testing.T) {
names := []string{"@novox/mesh-sdk", "git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-sdk", "a=40b", "a@b"}
seen := map[string]string{}
for _, n := range names {
k := Key(n, "0.1.14")
if strings.Count(k, ".") != 1 {
t.Errorf("Key(%q) = %q, want two tokens", n, k)
}
if o, dup := seen[k]; dup {
t.Errorf("%q and %q escape alike: %q", n, o, k)
}
seen[k] = n
if !strings.HasPrefix(k, strings.TrimSuffix(NameFilter(n), "*")) {
t.Errorf("Key(%q) = %q is not under NameFilter %q", n, k, NameFilter(n))
}
}
})
t.Run("floor", func(t *testing.T) {
for _, c := range []struct {
name string
floor Floor
want string
}{
{"none starts at 0", known(nil, nil, nil), "0.1.0"},
{"the Go tag of today gives v0.1.14", known(nil, []string{"go/v0.1.13"}, []string{"v0.1.14-0.20261103221822-85ba0a9a70d8"}), "0.1.14"},
{"npm's 0.1.17 on the seat gives 0.1.18", known([]string{"0.1.17", "0.1.3"}, nil, nil), "0.1.18"},
{"another series and a tagged version count nothing", known([]string{"0.2.40", "0.1.9-feat-x.1"}, nil, nil), "0.1.0"},
{"the highest of the three", known([]string{"0.1.4"}, []string{"v0.1.9"}, []string{"v0.1.7"}), "0.1.10"},
} {
if got := Next(nil, "0.1", c.floor); got != c.want {
t.Errorf("%s: Next = %s, want %s", c.name, got, c.want)
}
}
if got := Next([]Entry{{Version: "0.1.20", Series: "0.1"}, {Version: "0.1.21-x.1", Series: "0.1", Tag: "x"}}, "0.1", known([]string{"0.1.3"}, nil, nil)); got != "0.1.21" {
t.Errorf("Next above the state's own = %s, want 0.1.21", got)
}
})
t.Run("decide", func(t *testing.T) {
pub := Entry{Name: "n", Kind: KindPackage, Series: "0.1", Version: "0.1.5", Revision: "r1", Fingerprint: "fA", State: Published}
ref := pub
ref.State, ref.Version, ref.Revision = Refused, "0.1.6", "r2"
img := Entry{Name: "i", Kind: KindBundle, Series: "0.1", Version: "0.1.2", Fingerprint: "fB", State: Published}
silent := Floor{Silent: []string{SourceSeat}}
for _, c := range []struct {
name string
entries []Entry
kind, fp, rev string
floor Floor
version string
keep, publish, unnum bool
wait bool
}{
{"a package's revision held published keeps its version and publishes nothing", []Entry{pub}, KindPackage, "fX", "r1", silent, "0.1.5", true, false, false, false},
{"a revision numbered but refused is published by its second build under the same version", []Entry{pub, ref}, KindPackage, "fY", "r2", known(nil, nil, nil), "0.1.6", true, true, false, false},
{"a new revision takes the next", []Entry{pub, ref}, KindPackage, "fZ", "r3", known(nil, nil, nil), "0.1.7", false, true, false, false},
{"a bundle's fingerprint keeps its number", []Entry{img}, KindBundle, "fB", "", silent, "0.1.2", true, false, false, false},
{"an unknown floor builds a bundle unnumbered", nil, KindBundle, "fC", "", silent, "", false, true, true, false},
{"an unknown floor builds an image unnumbered", nil, KindImage, "fC", "", silent, "", false, true, true, false},
{"an unknown floor makes a package wait", nil, KindPackage, "fC", "r9", silent, "", false, false, false, true},
} {
d := Decide(c.entries, c.kind, "0.1", c.fp, c.rev, c.floor)
if d.Version != c.version || d.Keep != c.keep || d.Publish != c.publish || d.Unnumbered != c.unnum || d.Wait != c.wait || d.Why == "" {
t.Errorf("%s: Decide = %+v", c.name, d)
}
}
})
t.Run("two claims never one number", func(t *testing.T) {
st := newMem()
v := Versions{Store: st, Now: func() time.Time { return time.Unix(0, 0) }}
ctx := context.Background()
st.before = func() { // a second claim takes 0.1.0 between the first's read and its create
if _, _, err := (Versions{Store: st}).Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f2"}); err != nil {
t.Error(err)
}
}
d, e, err := v.Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f1"})
if err != nil || d.Version != "0.1.1" || e.State != Numbered || e.Series != DefaultSeries {
t.Fatalf("Number = %+v, %+v, %v, want 0.1.1 after the other claim's 0.1.0", d, e, err)
}
if d, _, _ := v.Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f1"}); !d.Keep || d.Version != "0.1.1" {
t.Errorf("a numbered fingerprint claimed again = %+v, want it kept", d)
}
got, err := v.Mark(ctx, "n", "0.1.1", func(e *Entry) { e.State = Published })
if err != nil || got.State != Published {
t.Errorf("Mark = %+v, %v", got, err)
}
if _, err := v.Mark(ctx, "n", "0.9.9", func(*Entry) {}); err == nil {
t.Error("marking a version never numbered is no error")
}
})
t.Run("recorded", func(t *testing.T) {
st := newMem()
v := Versions{Store: st}
ctx := context.Background()
if _, _, err := v.Number(ctx, Claim{Name: "n", Kind: KindImage, Fingerprint: "f", Digest: "sha256:aa", Floor: known(nil, nil, nil)}); err != nil {
t.Fatal(err)
}
for _, c := range []struct {
version, digest string
ok bool
}{{"0.1.0", "sha256:aa", true}, {"0.1.0", "sha256:bb", false}, {"0.1.7", "sha256:aa", false}} {
if _, ok, why, err := v.Recorded(ctx, "n", c.version, c.digest); err != nil || ok != c.ok || (!ok && why == "") {
t.Errorf("Recorded(%s, %s) = %v, %q, %v; want %v", c.version, c.digest, ok, why, err, c.ok)
}
}
// builder_versions answers the same through the tools bundle's read-only view.
r := Reader{Keys: func() ([]string, error) {
var all []string
for k := range st.vals {
all = append(all, k)
}
return all, nil
}, Get: func(k string) (json.RawMessage, bool, error) {
v, _, ok, err := st.Get(ctx, k)
return v, ok, err
}}
a, err := AnswerVersions(ctx, r, "n", "0.1.0", "sha256:bb")
if err != nil || len(a.Versions) != 1 || a.Recorded == nil || *a.Recorded || a.Why == "" {
t.Errorf("builder_versions = %+v, %v; want one version and not recorded for another digest", a, err)
}
if _, err := AnswerVersions(ctx, r, " ", "", ""); err == nil {
t.Error("builder_versions with no name answered")
}
})
t.Run("tagged", func(t *testing.T) {
es := []Entry{{Version: "0.1.14", Series: "0.1"}, {Version: "0.1.15-feat-x.1", Series: "0.1", Tag: "feat-x"}}
if got := Tagged(es, "0.1", "feat/X", known(nil, nil, nil)); got != "0.1.15-feat-x.2" {
t.Errorf("Tagged = %s, want 0.1.15-feat-x.2", got)
}
if got := Tagged(nil, "0.1", "feat-x", known(nil, nil, nil)); got != "0.1.0-feat-x.1" {
t.Errorf("a package new in a group = %s, want 0.1.0-feat-x.1", got)
}
if got := CleanTag("--Fix/ä_1965--"); got != "fix-1965" {
t.Errorf("CleanTag = %s", got)
}
})
t.Run("merge order", func(t *testing.T) {
es := []Entry{{Fingerprint: "f1", Version: "0.1.0", Series: "0.1"}}
for _, c := range []struct {
name string
merge uint64
held map[string]uint64
want uint64
}{
{"an earlier merge numbered waits on nothing", 5, map[string]uint64{"f1": 3}, 0},
{"an earlier merge not numbered is waited on", 5, map[string]uint64{"f1": 3, "f2": 4}, 4},
{"a replaced fingerprint the indexer no longer holds is skipped", 5, map[string]uint64{}, 0},
{"a later merge is not waited on", 5, map[string]uint64{"f3": 7}, 0},
} {
if got := WaitsOn(es, c.merge, c.held); got != c.want {
t.Errorf("%s: WaitsOn = %d, want %d", c.name, got, c.want)
}
}
})
t.Run("fill", func(t *testing.T) {
if _, full := Fill(Cap*79/100, Cap); full {
t.Error("79 % is full")
}
if _, full := Fill((Cap*80+99)/100, Cap); !full {
t.Error("80 % is not full")
}
})
}
@@ -93,7 +93,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
if m.Module != "mesh-delivery-builder" {
t.Errorf("module = %q", m.Module)
}
if want := []state{{Name: "builds", History: 5}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
if want := []state{{Name: "builds", History: 5}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "versions", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
t.Errorf("state = %+v, want %+v", m.State, want)
}
// 1887/A4c: the builder chooses no node, so it reads neither presence nor have, invokes no build module's tool and
@@ -124,7 +124,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
t.Errorf("secrets-owner = %q", m.SecretsOwner)
}
// 1945: the builder's tools (builder_seat_queue: note 62), served by its tools bundle, and the event it says when it asks an ask again.
if want := []string{"builder_announce_again", "builder_ask_again", "builder_build_again", "builder_builds_since", "builder_seat_queue", "builder_stuck"}; !reflect.DeepEqual(sorted(m.Tools), want) {
if want := []string{"builder_announce_again", "builder_ask_again", "builder_build_again", "builder_builds_since", "builder_seat_queue", "builder_stuck", "builder_versions"}; !reflect.DeepEqual(sorted(m.Tools), want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
if want := []string{"build.asked-again"}; !reflect.DeepEqual(m.Emits, want) {
+4 -2
View File
@@ -8,7 +8,8 @@
"builder_build_again",
"builder_builds_since",
"builder_seat_queue",
"builder_stuck"
"builder_stuck",
"builder_versions"
],
"emits": [
"build.asked-again"
@@ -26,7 +27,8 @@
{ "name": "hearing", "history": 1 },
{ "name": "instances", "history": 1 },
{ "name": "queues", "history": 1 },
{ "name": "reasks", "history": 1 }
{ "name": "reasks", "history": 1 },
{ "name": "versions", "history": 1 }
],
"logs": [
{ "name": "build-log", "max-age-days": 30 }