carlos / platform Public

settings-4 task 1: the build state, its registry copy, and root's consumed file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1fa26f33cb78aa7f2add5de244083210b048518c parent 3086625
2 files changed, +336 −0
  • internal/release/buildstate.go +184 −0
  • internal/release/buildstate_test.go +152 −0
diff --git a/internal/release/buildstate.go b/internal/release/buildstate.go
new file mode 100644
index 000000000..6a1fb7a7c
--- /dev/null
+++ b/internal/release/buildstate.go
@@ -0,0 +1,184 @@
+package release
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/carlosframework/platform/internal/controlname"
+)
+
+// ResolvedRequest is an install request R resolved to a build T (settings
+// spec §4.3 rule 2).
+type ResolvedRequest struct {
+ ID uint64 `json:"id"`
+ Target string `json:"target"`
+}
+
+// BuildState is the object beside an instance's replica (settings spec §4.2):
+// the build whose process last opened the database, the request root last
+// resolved, and the highest request completed. The agent writes it under the
+// instance's lease before any process runs a build against the database; it
+// is trusted the way settings are (§2 decision 5).
+type BuildState struct {
+ Schema int `json:"schema"` // 1
+ Host string `json:"host"`
+ LastExecuted string `json:"last_executed,omitempty"`
+ Resolved *ResolvedRequest `json:"resolved,omitempty"`
+ Completed uint64 `json:"completed,omitempty"`
+ CompletedFailed bool `json:"completed_failed,omitempty"`
+ At time.Time `json:"at"`
+}
+
+// BuildStateKey is beside the lease's lock.json at the same prefix.
+func BuildStateKey(replicaPrefix string) string { return replicaPrefix + "/build-state.json" }
+
+// BuildStateCopy is the agent's registry copy, refreshed at every reconcile
+// and before every wake. Known is false until the agent has read the bucket
+// object (absent counts as read); Replica is whether the instance has a
+// replica, which decides rule 3's "unknown".
+type BuildStateCopy struct {
+ Known bool `json:"known"`
+ Replica bool `json:"replica,omitempty"`
+ State *BuildState `json:"state,omitempty"`
+ ReadAt time.Time `json:"read_at"`
+ // ReadErr is set when the latest refresh could not read the object; the
+ // copy keeps its last known state, and the box reports the host as
+ // incomplete for retention (Astra I1 #6). Cleared by a good read.
+ ReadErr string `json:"read_err,omitempty"`
+}
+
+func ParseBuildStateCopy(col string) (BuildStateCopy, error) {
+ if col == "" {
+ return BuildStateCopy{}, nil
+ }
+ var c BuildStateCopy
+ if err := json.Unmarshal([]byte(col), &c); err != nil {
+ return BuildStateCopy{}, fmt.Errorf("build state copy: %v", err)
+ }
+ return c, nil
+}
+
+func (c BuildStateCopy) Column() string {
+ b, _ := json.Marshal(c)
+ return string(b)
+}
+
+// Consumed is root's own record for one host at /opt/carlos/consumed/<host>
+// (settings spec §4.3 rule 2): the request it resolved, before staging, and
+// the highest it saw complete, plus its last decision for reports. Root-owned;
+// the agent reads it.
+type Consumed struct {
+ Schema int `json:"schema"` // 1
+ Host string `json:"host"`
+ Resolved *ResolvedRequest `json:"resolved,omitempty"`
+ Completed uint64 `json:"completed,omitempty"`
+ CompletedFailed bool `json:"completed_failed,omitempty"`
+ Desired string `json:"desired,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ // Baking is the head a pending, unresolved request waits on (this box's
+ // bake window). Reported beside Reason, never in place of it: Reason is
+ // what the held wake check reads (Astra I1 #8).
+ Baking string `json:"baking,omitempty"`
+ At time.Time `json:"at"`
+}
+
+func ConsumedPath(optDir, host string) string { return filepath.Join(optDir, "consumed", host) }
+
+// ReadConsumed is (nil, nil) when root has never written one for host.
+func ReadConsumed(optDir, host string) (*Consumed, error) {
+ if !controlname.ValidHostShape(host) {
+ return nil, fmt.Errorf("consumed: invalid host %q", host)
+ }
+ raw, err := os.ReadFile(ConsumedPath(optDir, host))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var c Consumed
+ if err := json.Unmarshal(raw, &c); err != nil {
+ return nil, fmt.Errorf("consumed %s: %v", host, err)
+ }
+ if c.Schema != 1 || c.Host != host {
+ return nil, fmt.Errorf("consumed %s names %q, schema %d", host, c.Host, c.Schema)
+ }
+ return &c, nil
+}
+
+func WriteConsumed(optDir string, c Consumed) error {
+ if !controlname.ValidHostShape(c.Host) {
+ return fmt.Errorf("consumed: invalid host %q", c.Host)
+ }
+ c.Schema = 1
+ if c.At.IsZero() {
+ c.At = time.Now().UTC().Truncate(time.Second)
+ }
+ raw, err := json.MarshalIndent(c, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(ConsumedPath(optDir, c.Host)), 0o755); err != nil {
+ return err
+ }
+ return WriteFileAtomic(ConsumedPath(optDir, c.Host), raw, 0o644)
+}
+
+// Watermark is the highest completed request id either source knows (§4.3
+// rule 2): an older local file on a box the instance returns to can never
+// make a newer, durably completed request look pending.
+func Watermark(c *Consumed, b *BuildState) uint64 {
+ var w uint64
+ if c != nil {
+ w = c.Completed
+ }
+ if b != nil && b.Completed > w {
+ w = b.Completed
+ }
+ return w
+}
+
+// NextBuildState is the pre-exec write's merge (settings spec §4.2, §4.3
+// rule 2): before build runs against the instance's database, last_executed
+// becomes build, root's resolution is carried, and the request completes if
+// build is its target. That write is what completes a request: at first
+// run, not at install. A request root recorded as failed is carried as
+// completed, failed. Schema, Host and At are the writer's.
+func NextBuildState(cur *BuildState, c *Consumed, build string) BuildState {
+ next := BuildState{}
+ if cur != nil {
+ next = *cur
+ next.Resolved = copyResolved(cur.Resolved)
+ }
+ if c != nil {
+ if c.Resolved != nil && c.Resolved.ID > Watermark(nil, &next) {
+ next.Resolved = copyResolved(c.Resolved)
+ }
+ switch {
+ case c.Completed > next.Completed:
+ next.Completed, next.CompletedFailed = c.Completed, c.CompletedFailed
+ case c.Completed == next.Completed && c.CompletedFailed:
+ // Root learned the completed request's T failed after it ran
+ // (Astra I1 #10): the outcome changes, the request stays closed.
+ next.CompletedFailed = true
+ }
+ }
+ next.LastExecuted = build
+ if next.Resolved != nil && next.Resolved.Target == build && next.Resolved.ID > next.Completed {
+ next.Completed, next.CompletedFailed = next.Resolved.ID, false
+ }
+ return next
+}
+
+func copyResolved(r *ResolvedRequest) *ResolvedRequest {
+ if r == nil {
+ return nil
+ }
+ v := *r
+ return &v
+}
diff --git a/internal/release/buildstate_test.go b/internal/release/buildstate_test.go
new file mode 100644
index 000000000..611c35f8b
--- /dev/null
+++ b/internal/release/buildstate_test.go
@@ -0,0 +1,152 @@
+package release
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestBuildStateKeySitsBesideTheLock(t *testing.T) {
+ if got := BuildStateKey("instances/a.example.com"); got != "instances/a.example.com/build-state.json" {
+ t.Fatalf("BuildStateKey = %q", got)
+ }
+}
+
+func TestConsumedRoundTrip(t *testing.T) {
+ opt := t.TempDir()
+ if c, err := ReadConsumed(opt, "a.example.com"); c != nil || err != nil {
+ t.Fatalf("absent = %+v, %v", c, err)
+ }
+ // Reason is the plain string "request"; Task 2 gives it a named constant.
+ want := Consumed{Schema: 1, Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v2"}, Completed: 2,
+ Desired: "v2", Reason: "request", At: time.Now().UTC().Truncate(time.Second)}
+ if err := WriteConsumed(opt, want); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ReadConsumed(opt, "a.example.com")
+ if err != nil || got.Resolved.Target != "v2" || got.Completed != 2 || got.Reason != "request" {
+ t.Fatalf("round trip = %+v, %v", got, err)
+ }
+ if ConsumedPath(opt, "a.example.com") != filepath.Join(opt, "consumed", "a.example.com") {
+ t.Fatal("the consumed file moved")
+ }
+}
+
+func TestConsumedRefusesAnotherHostsFile(t *testing.T) {
+ opt := t.TempDir()
+ if err := WriteConsumed(opt, Consumed{Schema: 1, Host: "b.example.com"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteFileAtomic(ConsumedPath(opt, "a.example.com"), mustRead(t, ConsumedPath(opt, "b.example.com")), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadConsumed(opt, "a.example.com"); err == nil {
+ t.Fatal("a consumed file naming another host was accepted")
+ }
+}
+
+func TestWatermarkIsTheHighestOfBoth(t *testing.T) {
+ for _, c := range []struct {
+ c *Consumed
+ b *BuildState
+ want uint64
+ }{
+ {nil, nil, 0},
+ {&Consumed{Completed: 4}, nil, 4},
+ {nil, &BuildState{Completed: 6}, 6},
+ {&Consumed{Completed: 4}, &BuildState{Completed: 6}, 6},
+ {&Consumed{Completed: 9}, &BuildState{Completed: 6}, 9},
+ } {
+ if got := Watermark(c.c, c.b); got != c.want {
+ t.Errorf("Watermark(%+v, %+v) = %d, want %d", c.c, c.b, got, c.want)
+ }
+ }
+}
+
+func TestBuildStateCopyColumn(t *testing.T) {
+ c, err := ParseBuildStateCopy("")
+ if err != nil || c.Known {
+ t.Fatalf("an empty column is unknown: %+v %v", c, err)
+ }
+ in := BuildStateCopy{Known: true, Replica: true, State: &BuildState{Schema: 1, Host: "a.example.com", LastExecuted: "v1"}}
+ out, err := ParseBuildStateCopy(in.Column())
+ if err != nil || !out.Known || !out.Replica || out.State.LastExecuted != "v1" {
+ t.Fatalf("round trip = %+v %v", out, err)
+ }
+}
+
+func TestNextBuildState(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ cur *BuildState
+ c *Consumed
+ build string
+ want BuildState
+ }{
+ {"a: nil cur, nil consumed sets only LastExecuted", nil, nil, "v1",
+ BuildState{LastExecuted: "v1"}},
+ {"b: a consumed resolution above the watermark is carried and completes when its target runs",
+ &BuildState{Schema: 1, Host: "h", Completed: 2},
+ &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}, "v3",
+ BuildState{Schema: 1, Host: "h", LastExecuted: "v3", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 3}},
+ {"c: running another build carries the resolution but does not complete it",
+ &BuildState{Schema: 1, Host: "h", Completed: 2},
+ &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}, "v2",
+ BuildState{Schema: 1, Host: "h", LastExecuted: "v2", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}},
+ {"d: a consumed resolution at or below the state's Completed is not carried",
+ &BuildState{Schema: 1, Host: "h", Completed: 3},
+ &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}, "v3",
+ BuildState{Schema: 1, Host: "h", LastExecuted: "v3", Completed: 3}},
+ {"e: a higher consumed Completed raises the state's and carries CompletedFailed",
+ &BuildState{Schema: 1, Host: "h", Completed: 2},
+ &Consumed{Completed: 5, CompletedFailed: true}, "v1",
+ BuildState{Schema: 1, Host: "h", LastExecuted: "v1", Completed: 5, CompletedFailed: true}},
+ {"f: an equal Completed with consumed CompletedFailed marks the state failed and does not reopen",
+ &BuildState{Schema: 1, Host: "h", Completed: 4},
+ &Consumed{Completed: 4, CompletedFailed: true}, "v1",
+ BuildState{Schema: 1, Host: "h", LastExecuted: "v1", Completed: 4, CompletedFailed: true}},
+ {"g: the state's own resolution from another box completes when its target runs",
+ &BuildState{Schema: 1, Host: "h", Resolved: &ResolvedRequest{ID: 4, Target: "v3"}, Completed: 3},
+ nil, "v3",
+ BuildState{Schema: 1, Host: "h", LastExecuted: "v3", Resolved: &ResolvedRequest{ID: 4, Target: "v3"}, Completed: 4}},
+ {"h: the watermark never decreases",
+ &BuildState{Schema: 1, Host: "h", Completed: 6},
+ &Consumed{Completed: 4}, "v1",
+ BuildState{Schema: 1, Host: "h", LastExecuted: "v1", Completed: 6}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var curBefore BuildState
+ if tc.cur != nil {
+ curBefore = *tc.cur
+ }
+ got := NextBuildState(tc.cur, tc.c, tc.build)
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("NextBuildState = %+v (resolved %+v), want %+v (resolved %+v)", got, got.Resolved, tc.want, tc.want.Resolved)
+ }
+ if tc.cur != nil && !reflect.DeepEqual(*tc.cur, curBefore) {
+ t.Fatalf("cur was mutated: %+v", *tc.cur)
+ }
+ })
+ }
+}
+
+func TestNextBuildStateDoesNotAliasConsumed(t *testing.T) {
+ c := &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "v3"}}
+ got := NextBuildState(nil, c, "v2")
+ c.Resolved.Target = "changed"
+ c.Resolved.ID = 99
+ if got.Resolved == nil || got.Resolved.Target != "v3" || got.Resolved.ID != 3 {
+ t.Fatalf("the state aliases the consumed file: %+v", got.Resolved)
+ }
+}
+
+func mustRead(t *testing.T, path string) []byte {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}