zdd-5 task 3: swap and swap_concurrency keys; attempt and recovery modes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+298
−1
internal/appconfig/appconfig.go+27 −0internal/appconfig/appconfig_test.go+42 −1internal/rollout/policy.go+117 −0internal/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) |
| + } |
| + } |
| +} |