A holder that failed leaves a version to publish again by hand: said numbered again while the build is held, built again under the same number when its file is gone, numbered anew after a published; refused for other bytes. The floor is asked again by hand; a branch's tagged versions are removed through each seat's remove, per repository. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
414 lines
15 KiB
Go
414 lines
15 KiB
Go
// 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"`
|
|
// Repository is a tagged version's repository: its branch's end removes it (Untagged).
|
|
Repository string `json:"repository,omitempty"`
|
|
// RemovedBy is the id of the event that removed it.
|
|
RemovedBy string `json:"removed_by,omitempty"`
|
|
// RefusedDigest is the digest the holder refused: one other than Digest means the seat was offered other bytes under
|
|
// this number, which builder_publish_again refuses to publish again (design 51 *Tools of the builder*).
|
|
RefusedDigest string `json:"refused_digest,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
|
|
}
|