No build reaches a machine without a gate; a release plan walks what waits (hq ADR 0236)

A send carries the machine's whole declaration, so at the switch to roll the next send of
anything would have carried the old default's backlog, unjudged, to every machine. A gated
send now carries and judges everything waiting on its machine; every other send is refused
or leaves the machine; a release plan walks what waits one machine at a time, the control
node last, and one that fails holds the next until a person releases it.
This commit is contained in:
jochen
2026-10-06 19:14:25 +02:00
parent 41f7b2c152
commit 2bfa6ae4a0
15 changed files with 1163 additions and 40 deletions
+65 -3
View File
@@ -46,8 +46,8 @@ type GateVerdict struct {
Epoch uint64
}
// RecordGate writes a build's verdict. **A failed build's row is written once**: a second failure for
// the same build is refused with ErrGateKept, which is what keeps a rollback to one per build — the row
// RecordGate writes a build's verdict. A build that passed on one machine may still fail on the next one
// judged; **a failed build's row is written once**: any later verdict for it is refused with ErrGateKept, which is what keeps a rollback to one per build — the row
// is written before the rollback's send, and a controller replaced in between finds it.
func (i *Inventory) RecordGate(ctx context.Context, v GateVerdict) error {
epoch, err := i.actingEpoch(ctx)
@@ -64,7 +64,7 @@ func (i *Inventory) RecordGate(ctx context.Context, v GateVerdict) error {
on conflict (build) do update set verdict = excluded.verdict, rollback = excluded.rollback,
why = excluded.why, previous = excluded.previous, machines = excluded.machines,
judged_at = now(), epoch = excluded.epoch
where build_gate.verdict = 'passed' and excluded.verdict = 'passed'`,
where build_gate.verdict = 'passed'`,
v.Build, v.Module, v.Commit, v.Previous, v.Plan, v.Machines, v.Verdict, v.Rollback, v.Why, v.Component,
v.JudgingFrom, epoch)
if err != nil {
@@ -245,3 +245,65 @@ func (i *Inventory) BuildFingerprints(ctx context.Context, module string) (map[s
}
return out, rows.Err()
}
// Fingerprints is BuildFingerprints for every module at once: module → commit → fingerprint.
func (i *Inventory) Fingerprints(ctx context.Context) (map[string]map[string]string, error) {
rows, err := i.store.Pool().Query(ctx,
`select module, commit_hash, made, coalesce(manifest::text, '') from build
where module is not null and failed = '' and commit_hash <> '' order by at desc`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]map[string]string{}
for rows.Next() {
var module, commit, manifest string
var made []byte
if err := rows.Scan(&module, &commit, &made, &manifest); err != nil {
return nil, err
}
if out[module] == nil {
out[module] = map[string]string{}
}
if _, seen := out[module][commit]; seen {
continue
}
sum := sha256.Sum256(append(append(made, 0), []byte(manifest)...))
out[module][commit] = hex.EncodeToString(sum[:])
}
return out, rows.Err()
}
// PassedCommits is, per module, the commits a build of which passed its gate on some machine.
func (i *Inventory) PassedCommits(ctx context.Context) (map[string]map[string]bool, error) {
rows, err := i.store.Pool().Query(ctx, `select module, commit_hash from build_gate where verdict = 'passed'`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]map[string]bool{}
for rows.Next() {
var module, commit string
if err := rows.Scan(&module, &commit); err != nil {
return nil, err
}
if out[module] == nil {
out[module] = map[string]bool{}
}
out[module][commit] = true
}
return out, rows.Err()
}
// BuildOf is the id of the newest successful build of a module from a commit; empty when none is
// recorded (a manifest handed over by hand).
func (i *Inventory) BuildOf(ctx context.Context, module, commit string) (string, error) {
var id string
err := i.store.Pool().QueryRow(ctx,
`select id from build where module = $1 and commit_hash = $2 and failed = '' order by at desc limit 1`,
module, commit).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return id, err
}
@@ -65,3 +65,8 @@ create table bus_step (
outcome text not null default '' check (outcome in ('', 'done', 'failed')),
found text not null default ''
);
-- 4. A release plan (ADR 0236): the builds that wait for a gate — the backlog the old default left, and
-- whatever a plan built and did not send — walked through the machines one at a time, each judged
-- before the next. Its walk is kept with the plan.
alter table release_plan add column release jsonb;
+51 -7
View File
@@ -38,6 +38,9 @@ type Plan struct {
Revision int64 `json:"revision"`
// Epoch is the controller lease epoch that wrote it last; zero for a write that claimed none.
Epoch uint64 `json:"epoch,omitempty"`
// Release is set on a release plan (novox/hq ADR 0236): not a merge's, but the builds waiting for a
// gate, walked through the machines one at a time.
Release *PlanRelease `json:"release,omitempty"`
}
// ErrPlanMoved is a save against a plan written by somebody else since it was read.
@@ -103,6 +106,35 @@ type PlanGate struct {
Rollback string `json:"rollback,omitempty"`
// Kept says a passing verdict was written to the gate's records.
Kept bool `json:"kept,omitempty"`
// Carried is every module whose build moved on the judged machines with the send — the plan's own
// module and whatever else was waiting there for a gate (novox/hq ADR 0236): each is judged here, a
// pass is its verdict too, and one that fails is put back.
Carried []CarriedMove `json:"carried,omitempty"`
// Failing names the modules the last judging found wanting.
Failing []string `json:"failing,omitempty"`
}
// CarriedMove is one module's build moving on a machine with a gated send.
type CarriedMove struct {
Module string `json:"module"`
Node string `json:"node"`
From string `json:"from,omitempty"`
To string `json:"to"`
Build string `json:"build,omitempty"`
}
// PlanRelease is a release plan's walk through the machines (novox/hq ADR 0236): every module build
// that waits for a gate, sent one machine at a time, each judged before the next.
type PlanRelease struct {
Order []string `json:"order"`
Next int `json:"next"`
// Gate is the machine being judged; nil between machines.
Gate *PlanGate `json:"gate,omitempty"`
Done []string `json:"done,omitempty"`
// Skipped are the machines not heard from when their turn came, left as they were.
Skipped []string `json:"skipped,omitempty"`
// By is the person who released it, empty when the mesh did.
By string `json:"by,omitempty"`
}
// The states a plan passes through.
@@ -138,6 +170,12 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
if err != nil {
return err
}
var release []byte
if p.Release != nil {
if release, err = json.Marshal(p.Release); err != nil {
return err
}
}
// **And how long the tier it left took** (novox/hq to-be 45 Phase 0): measured here, where the
// plan moves, in the same transaction as the move, so no save can move a tier unmeasured or
// measure one twice.
@@ -153,15 +191,16 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
var revision int64
err = tx.QueryRow(ctx,
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note,
branch, tier_entered, revision, epoch)
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13)
branch, tier_entered, revision, epoch, release)
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13, $14)
on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier,
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note, branch = excluded.branch,
tier_entered = excluded.tier_entered, revision = release_plan.revision + 1, epoch = excluded.epoch
tier_entered = excluded.tier_entered, revision = release_plan.revision + 1, epoch = excluded.epoch,
release = excluded.release
where release_plan.revision = $12
returning revision`,
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note, p.Branch, entered,
p.Revision, epoch).Scan(&revision)
p.Revision, epoch, release).Scan(&revision)
if errors.Is(err, pgx.ErrNoRows) {
// The row is there and at another revision — moved since this was read, or there already
// when this one is new: either way not this writer's to overwrite. (A plan saved before plans
@@ -216,7 +255,7 @@ func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
rows, err := i.store.Pool().Query(ctx,
`select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch,
coalesce(tier_entered, created), revision, coalesce(epoch, 0)
coalesce(tier_entered, created), revision, coalesce(epoch, 0), release
from release_plan `+tail)
if err != nil {
return nil, err
@@ -225,12 +264,17 @@ func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
var out []Plan
for rows.Next() {
var p Plan
var tiers, modules []byte
var tiers, modules, release []byte
var epoch int64
if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State,
&p.Tier, &tiers, &modules, &p.Note, &p.Branch, &p.TierEntered, &p.Revision, &epoch); err != nil {
&p.Tier, &tiers, &modules, &p.Note, &p.Branch, &p.TierEntered, &p.Revision, &epoch, &release); err != nil {
return nil, err
}
if len(release) > 0 {
if err := json.Unmarshal(release, &p.Release); err != nil {
return nil, err
}
}
p.Epoch = uint64(epoch)
if err := json.Unmarshal(tiers, &p.Tiers); err != nil {
return nil, err