carlos / platform Public

zdd-5 task 3: swap and swap_concurrency keys; attempt and recovery modes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a4dd5de7b4aad0ba65120afb51c4f47ff82adce8 parent e8e1433
4 files changed, +298 −1
  • internal/appconfig/appconfig.go +27 −0
  • internal/appconfig/appconfig_test.go +42 −1
  • internal/rollout/policy.go +117 −0
  • internal/rollout/policy_test.go +112 −0
diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go
index ff5d13439..7455e4998 100644
--- a/internal/appconfig/appconfig.go
+++ b/internal/appconfig/appconfig.go
@@ -73,6 +73,19 @@ type Manifest struct {
// The environment binding is one door onto a scope that already exists,
// and is the same axis the secrets themselves are scoped on.
ExecDelivery string `json:"exec_delivery,omitempty"`
+
+ // Swap and SwapConcurrency are the app's swap policy (zero-downtime spec
+ // §4.1): how an awake instance moves to a new generation ("overlap", the
+ // default, or "hold"), and how many swaps of this app may run at once on
+ // one box after a cohort's canary (0 means the default, 1). App-level:
+ // boxes read them from the DEFAULT bundle only, never from a named
+ // environment. Additive to schema 1, like ExecDelivery: absent from every
+ // manifest written before them and omitted when zero, so a manifest with
+ // no policy marshals byte for byte as before. Written by plan 6's doors,
+ // which refuse values ValidSwap and ValidSwapConcurrency reject; a box
+ // that reads a value it does not know runs in hold (spec §4.2).
+ Swap string `json:"swap,omitempty"`
+ SwapConcurrency int `json:"swap_concurrency,omitempty"`
}
// ExecDeliveryFile is the only non-empty value ValidExecDelivery accepts
@@ -88,6 +101,20 @@ func ValidExecDelivery(v string) bool {
return v == "" || v == ExecDeliveryFile
}
+// The two swap policies of zero-downtime spec §4.1.
+const (
+ SwapOverlap = "overlap"
+ SwapHold = "hold"
+)
+
+// ValidSwap reports whether v is a swap policy this binary understands: unset,
+// overlap or hold.
+func ValidSwap(v string) bool { return v == "" || v == SwapOverlap || v == SwapHold }
+
+// ValidSwapConcurrency reports whether n is a storable swap_concurrency: 0
+// (unset, meaning 1) or any positive count.
+func ValidSwapConcurrency(n int) bool { return n >= 0 }
+
// Var is one config var or secret. Kind selects which of Value/Envelope is
// populated: "env" vars are plaintext (Value); "secret" vars are sealed
// client-side before upload (Envelope, base64 — see crypto.go's Seal).
diff --git a/internal/appconfig/appconfig_test.go b/internal/appconfig/appconfig_test.go
index 2fd1cbfa6..78e203a21 100644
--- a/internal/appconfig/appconfig_test.go
+++ b/internal/appconfig/appconfig_test.go
@@ -1,6 +1,10 @@
package appconfig
-import "testing"
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
func TestKeyIsAccountScoped(t *testing.T) {
got := Key("bab", "keymail")
@@ -24,3 +28,40 @@ func TestValidVarName(t *testing.T) {
}
}
}
+
+// The swap policy (zero-downtime spec §4.1) is additive to schema 1: a
+// manifest written before it parses with zero values, and a manifest with no
+// policy marshals with no new keys, byte for byte as before.
+func TestSwapPolicyIsAdditive(t *testing.T) {
+ var old Manifest
+ if err := json.Unmarshal([]byte(`{"schema":1,"app":"blog","vars":{}}`), &old); err != nil {
+ t.Fatal(err)
+ }
+ if old.Swap != "" || old.SwapConcurrency != 0 {
+ t.Fatalf("old manifest parsed as %q/%d", old.Swap, old.SwapConcurrency)
+ }
+ raw, err := json.Marshal(Manifest{Schema: 1, App: "blog", Vars: map[string]Var{}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(raw), "swap") {
+ t.Fatalf("a manifest with no policy marshals %s", raw)
+ }
+ var m Manifest
+ if err := json.Unmarshal([]byte(`{"schema":1,"app":"blog","vars":{},"swap":"hold","swap_concurrency":3}`), &m); err != nil {
+ t.Fatal(err)
+ }
+ if m.Swap != SwapHold || m.SwapConcurrency != 3 {
+ t.Fatalf("parsed %q/%d", m.Swap, m.SwapConcurrency)
+ }
+ for v, want := range map[string]bool{"": true, "overlap": true, "hold": true, "Hold": false, "stop": false} {
+ if ValidSwap(v) != want {
+ t.Errorf("ValidSwap(%q) = %v", v, !want)
+ }
+ }
+ for n, want := range map[int]bool{-1: false, 0: true, 1: true, 64: true} {
+ if ValidSwapConcurrency(n) != want {
+ t.Errorf("ValidSwapConcurrency(%d) = %v", n, !want)
+ }
+ }
+}
diff --git a/internal/rollout/policy.go b/internal/rollout/policy.go
new file mode 100644
index 000000000..2c780b224
--- /dev/null
+++ b/internal/rollout/policy.go
@@ -0,0 +1,117 @@
+package rollout
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// appKey names one app: one scheduler per (account, app) per box (spec §6.1).
+type appKey struct{ acct, app string }
+
+// AppPolicy is the app's swap policy as this box applies it (spec §4.1).
+type AppPolicy struct {
+ Swap activator.SwapMode
+ Concurrency int
+}
+
+// DefaultPolicy is §4.1's defaults: overlap, one swap at a time.
+func DefaultPolicy() AppPolicy {
+ return AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}
+}
+
+// PolicyFromManifest reads the policy keys of an app's default bundle. A nil
+// manifest (never configured) is the defaults. A swap value this binary does
+// not know is hold: missing or unverifiable policy never permits an overlap
+// (spec §4.2).
+func PolicyFromManifest(m *appconfig.Manifest) AppPolicy {
+ p := DefaultPolicy()
+ if m == nil {
+ return p
+ }
+ switch m.Swap {
+ case "", appconfig.SwapOverlap:
+ default:
+ p.Swap = activator.ModeHold
+ }
+ if m.SwapConcurrency >= 1 {
+ p.Concurrency = m.SwapConcurrency
+ }
+ return p
+}
+
+// AttemptMode is an adoption attempt's mode (spec §4.2): hold if the app
+// policy or the build's canonical demand says hold. Any demand other than ""
+// is read as hold, so a future demand value cannot become an overlap here.
+func AttemptMode(p AppPolicy, demand string) activator.SwapMode {
+ if p.Swap == activator.ModeHold || demand != "" {
+ return activator.ModeHold
+ }
+ return activator.ModeOverlap
+}
+
+// RecoveryMode is a recovery attempt's mode (spec §4.2): the RETAINED release
+// demand, with the CURRENT app policy, so an app switched to hold after a
+// build passed still gets hold on its restarts and config changes.
+func RecoveryMode(p AppPolicy, retained string) activator.SwapMode {
+ return AttemptMode(p, retained)
+}
+
+type policyEntry struct {
+ p AppPolicy
+ at time.Time
+}
+
+// policyCache reads each app's policy at most once per ttl. A failed read
+// keeps the last value it had; an app whose policy this box has never read
+// gets hold, because a guess of overlap could run a migration beside a build
+// that cannot share the database.
+type policyCache struct {
+ load func(ctx context.Context, acct, app string) (*appconfig.Manifest, error)
+ ttl time.Duration
+ now func() time.Time
+ mu sync.Mutex
+ m map[appKey]policyEntry
+}
+
+func (c *policyCache) get(ctx context.Context, acct, app string) AppPolicy {
+ k := appKey{acct, app}
+ c.mu.Lock()
+ e, seen := c.m[k]
+ c.mu.Unlock()
+ if seen && c.now().Sub(e.at) < c.ttl {
+ return e.p
+ }
+ m, err := c.load(ctx, acct, app)
+ if err != nil {
+ if seen {
+ return e.p
+ }
+ return AppPolicy{Swap: activator.ModeHold, Concurrency: 1}
+ }
+ p := PolicyFromManifest(m)
+ c.mu.Lock()
+ c.m[k] = policyEntry{p: p, at: c.now()}
+ c.mu.Unlock()
+ return p
+}
+
+// Managed reports whether rt is an activator-managed exec route this
+// scheduler moves: a hibernating exec instance or a native sidecar of a known
+// app. Named-unit routes keep root's adoption and its privileged restart
+// (spec §13), unit-backed routes belong to systemd, and platform routes are
+// never adopted.
+func Managed(rt registry.Route) bool {
+ if rt.Unit != "" || rt.Backing == registry.BackingUnit {
+ return false
+ }
+ if rt.IsSidecar() {
+ return release.SidecarAdoptable(rt)
+ }
+ return release.Adoptable(rt) && rt.Hibernate
+}
diff --git a/internal/rollout/policy_test.go b/internal/rollout/policy_test.go
new file mode 100644
index 000000000..43d40e10d
--- /dev/null
+++ b/internal/rollout/policy_test.go
@@ -0,0 +1,112 @@
+package rollout
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestPolicyFromManifest(t *testing.T) {
+ cases := []struct {
+ m *appconfig.Manifest
+ want AppPolicy
+ }{
+ {nil, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}},
+ {&appconfig.Manifest{}, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}},
+ {&appconfig.Manifest{Swap: "overlap", SwapConcurrency: 4}, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 4}},
+ {&appconfig.Manifest{Swap: "hold"}, AppPolicy{Swap: activator.ModeHold, Concurrency: 1}},
+ // A value this binary does not know never permits an overlap.
+ {&appconfig.Manifest{Swap: "blue-green"}, AppPolicy{Swap: activator.ModeHold, Concurrency: 1}},
+ {&appconfig.Manifest{SwapConcurrency: -3}, AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}},
+ }
+ for _, c := range cases {
+ if got := PolicyFromManifest(c.m); got != c.want {
+ t.Errorf("PolicyFromManifest(%+v) = %+v, want %+v", c.m, got, c.want)
+ }
+ }
+}
+
+func TestModesHoldIfEitherSaysHold(t *testing.T) {
+ overlap, hold := AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}, AppPolicy{Swap: activator.ModeHold, Concurrency: 1}
+ cases := []struct {
+ p AppPolicy
+ demand string
+ want activator.SwapMode
+ }{
+ {overlap, "", activator.ModeOverlap},
+ {overlap, "hold", activator.ModeHold},
+ {hold, "", activator.ModeHold},
+ {hold, "hold", activator.ModeHold},
+ {overlap, "something-new", activator.ModeHold},
+ }
+ for _, c := range cases {
+ if got := AttemptMode(c.p, c.demand); got != c.want {
+ t.Errorf("AttemptMode(%v, %q) = %v, want %v", c.p.Swap, c.demand, got, c.want)
+ }
+ // Recovery reads the RETAINED demand with the CURRENT policy (spec
+ // §4.2, round 12): an app switched to hold later still gets hold.
+ if got := RecoveryMode(c.p, c.demand); got != c.want {
+ t.Errorf("RecoveryMode(%v, %q) = %v, want %v", c.p.Swap, c.demand, got, c.want)
+ }
+ }
+}
+
+// Review focus 2: a bucket blip while the policy is read.
+func TestPolicyCacheNeverGuessesOverlap(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0)
+ var calls int
+ var answer *appconfig.Manifest
+ var fail error
+ c := &policyCache{
+ load: func(context.Context, string, string) (*appconfig.Manifest, error) {
+ calls++
+ return answer, fail
+ },
+ ttl: 30 * time.Second,
+ now: func() time.Time { return now },
+ m: map[appKey]policyEntry{},
+ }
+ ctx := context.Background()
+ fail = errors.New("SlowDown")
+ if got := c.get(ctx, "bab", "blog"); got.Swap != activator.ModeHold {
+ t.Fatalf("never-read policy = %v, want hold", got.Swap)
+ }
+ fail, answer = nil, &appconfig.Manifest{SwapConcurrency: 2}
+ if got := c.get(ctx, "bab", "blog"); got != (AppPolicy{Swap: activator.ModeOverlap, Concurrency: 2}) {
+ t.Fatalf("first read = %+v", got)
+ }
+ n := calls
+ if c.get(ctx, "bab", "blog"); calls != n {
+ t.Fatal("a fresh entry was read again inside the TTL")
+ }
+ now = now.Add(31 * time.Second)
+ fail = errors.New("SlowDown")
+ if got := c.get(ctx, "bab", "blog"); got != (AppPolicy{Swap: activator.ModeOverlap, Concurrency: 2}) {
+ t.Fatalf("after a failed refresh = %+v, want the last known policy", got)
+ }
+}
+
+func TestManagedIsTheActivatorsExecRoutes(t *testing.T) {
+ cases := []struct {
+ rt registry.Route
+ want bool
+ }{
+ {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true}, true},
+ {registry.Route{Kind: "instance", App: "blog", Status: "ready"}, false}, // a systemd unit, not an activator child
+ {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true, Unit: "console.service"}, false}, // named-unit route (spec §13)
+ {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true, Backing: registry.BackingUnit}, false}, // carlos-app@
+ {registry.Route{Kind: "instance", App: "blog", Status: "ready", Hibernate: true, Platform: true}, false},
+ {registry.Route{Kind: "sidecar", App: "eleven", Status: "ready"}, true},
+ {registry.Route{Kind: "static", App: "site", Status: "ready", Hibernate: true}, false},
+ }
+ for _, c := range cases {
+ if got := Managed(c.rt); got != c.want {
+ t.Errorf("Managed(%+v) = %v, want %v", c.rt, got, c.want)
+ }
+ }
+}