Fold a pending assignment into the value of its assignment key (issue 1094)

A wait for a build is the value of <node>.<module> with state pending and a pending object (id p-<unix nanoseconds>, a string), so every reader that skips a value with a state skips it, and the controller's own lists skip it through Stands. The transitions are compare-and-set on the key's revision (claim, release, settle, repoint, mark); assigning over a wait carries it into the value that stands, applied when it was waiting; a plan pass never overwrites it; one open wait per machine and module, and a key that holds the module refuses a new one. The table is no longer read or written, ForgetEndedPending is gone (no key is removed), and a wait of a machine the store dropped is none. Callers change type only; their conditions are the next issue.
This commit is contained in:
2026-10-18 05:17:30 +02:00
parent 0149a3d7de
commit 88ea361bf6
11 changed files with 620 additions and 147 deletions
+63
View File
@@ -0,0 +1,63 @@
package chain
import (
"encoding/json"
"strings"
"testing"
"time"
)
// **A pending value is not a standing assignment** (ADR 0261, ADR 0408, issue 1094): it names its node and module, holds no
// time assigned, says `state: pending`, and every reader that skips a value with a state skips it.
func TestAPendingValueIsNotAStandingAssignment(t *testing.T) {
since := time.Date(2026, 10, 18, 3, 0, 0, 0, time.UTC)
a := Assignment{Node: "anchor", Module: "notes", State: StatePending,
Pending: &Pending{ID: "p-1", State: PendingWaiting, Build: "build-1", Since: since}}
if a.Stands() {
t.Fatal("a pending value stands")
}
raw, _ := json.Marshal(a)
for _, want := range []string{`"node":"anchor"`, `"module":"notes"`, `"state":"pending"`, `"pending":{"id":"p-1","state":"waiting"`} {
if !strings.Contains(string(raw), want) {
t.Errorf("the value lacks %s: %s", want, raw)
}
}
if strings.Contains(string(raw), `"assigned"`) {
t.Errorf("a pending value names a time assigned: %s", raw)
}
// The wait carried into the value that stands: no state, so it stands, and the record stays.
made := Assignment{Node: "anchor", Module: "notes", Assigned: since, Pending: &Pending{ID: "p-1", State: PendingApplied}}
if !made.Stands() || !a.Pending.Open() || made.Pending.Open() {
t.Fatalf("stands %v, open before %v, open after %v", made.Stands(), a.Pending.Open(), made.Pending.Open())
}
var back Assignment
if err := json.Unmarshal(raw, &back); err != nil || back.Pending == nil || back.Pending.Build != "build-1" || !back.Pending.Since.Equal(since) {
t.Fatalf("read back %+v: %v", back, err)
}
now := since
raised := Pending{ID: "p-2", State: PendingExpired, Raised: &now, Acknowledged: &now, Cleared: &now}
body, _ := json.Marshal(raised)
if !strings.Contains(string(body), `"raised"`) || !strings.Contains(string(body), `"acknowledged"`) || !strings.Contains(string(body), `"cleared"`) {
t.Errorf("the marks of a wait: %s", body)
}
}
func TestTheTransitionsOfAPendingAssignment(t *testing.T) {
ok := [][2]string{{PendingWaiting, PendingExpired}, {PendingWaiting, PendingWithdrawn}, {PendingApplying, PendingApplied},
{PendingApplying, PendingRefused}}
for _, p := range ok {
if !CanSettle(p[0], p[1]) {
t.Errorf("%s may end as %s", p[0], p[1])
}
}
for _, p := range [][2]string{{PendingWaiting, PendingApplied}, {PendingWaiting, PendingRefused}, {PendingApplying, PendingExpired},
{PendingApplying, PendingWithdrawn}, {PendingApplied, PendingExpired}, {PendingExpired, PendingWithdrawn},
{PendingWaiting, PendingWaiting}} {
if CanSettle(p[0], p[1]) {
t.Errorf("%s may not end as %s", p[0], p[1])
}
}
if !CanMove(PendingWaiting, PendingApplying) || !CanMove(PendingApplying, PendingWaiting) || CanMove(PendingApplied, PendingWaiting) {
t.Error("a claim moves a wait to applying and back")
}
}
+54 -1
View File
@@ -189,9 +189,62 @@ type Assignment struct {
// Ended is when the assignment ended (an unassignment): absent while it stands. **No key of the chain is removed**
// (ADR 0408 §4), so an unassigned module keeps its key, saying so; every reader treats a value with Ended, or with a
// State, as no assignment. State is, in a later delivery, the pending assignment waiting for its build (issue 1094).
// State, as no assignment. State is StatePending while the key holds an assignment waiting for its build, or one that
// was never made (issue 1094).
Ended time.Time `json:"ended,omitzero"`
State string `json:"state,omitempty"`
// Pending is the assignment's wait for its build (novox/hq ADR 0261), kept in the value of the key it waits for and, when
// it is made, carried into the value that stands: the record of what became of the wait.
Pending *Pending `json:"pending,omitempty"`
}
// StatePending is the State of a value that holds no assignment but a pending one.
const StatePending = "pending"
// The states of a pending assignment (novox/hq ADR 0261).
const (
PendingWaiting = "waiting"
PendingApplying = "applying"
PendingApplied = "applied"
PendingRefused = "refused"
PendingExpired = "expired"
PendingWithdrawn = "withdrawn"
)
// Pending is an assignment kept until its module is registered: the build it waits for, where that build is from, and the
// times of its claim and its end. Its id is p-<unix nanoseconds of Since>, unique for a key, which holds one at a time.
type Pending struct {
ID string `json:"id"`
State string `json:"state"`
Build string `json:"build,omitempty"`
Repository string `json:"repository,omitempty"`
Path string `json:"path,omitempty"`
Since time.Time `json:"since"`
Note string `json:"note,omitempty"`
Settled *time.Time `json:"settled,omitempty"`
Raised *time.Time `json:"raised,omitempty"`
Acknowledged *time.Time `json:"acknowledged,omitempty"`
Cleared *time.Time `json:"cleared,omitempty"`
}
// Open says the assignment is still to be made.
func (p Pending) Open() bool { return p.State == PendingWaiting || p.State == PendingApplying }
// CanSettle says a pending assignment in state from may end in state to: a wait expires or is withdrawn, a claim becomes
// applied or refused, and nothing else.
func CanSettle(from, to string) bool {
switch from {
case PendingWaiting:
return to == PendingExpired || to == PendingWithdrawn
case PendingApplying:
return to == PendingApplied || to == PendingRefused
}
return false
}
// CanMove says a pending assignment in state from may go to state to without ending: claimed, or released again.
func CanMove(from, to string) bool {
return (from == PendingWaiting && to == PendingApplying) || (from == PendingApplying && to == PendingWaiting)
}
// Stands says the value is an assignment that holds: it has not ended and is not waiting in a state of its own.