| 1 | package activator |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/hostbox" |
| 13 | "github.com/carlosframework/platform/internal/hostclaim" |
| 14 | "github.com/carlosframework/platform/internal/instancectl" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | func servingRetryFixture(t *testing.T) (release.Store, hostclaim.Observed, *ClaimRuntime, ServingRetries, *time.Time, *int) { |
| 20 | t.Helper() |
| 21 | ctx := context.Background() |
| 22 | st := release.NewDirStore(t.TempDir()) |
| 23 | now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) |
| 24 | host := "shop.bab.oncarlos.com" |
| 25 | in := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: now} |
| 26 | body := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-06T12:00:00Z"}`) |
| 27 | absent, _ := hostclaim.Read(ctx, st, host) |
| 28 | o, err := hostclaim.Update(ctx, st, absent, hostclaim.BeginCreate(in, body, "create", "console", now)) |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | o, err = hostclaim.Update(ctx, st, o, hostclaim.CompleteCreate("create", "box")) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | seq, stops := 0, 0 |
| 37 | d := ServingRetries{Claims: st, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller", Now: func() time.Time { return now }, NewID: func() string { seq++; return fmt.Sprint("id", seq) }, Inputs: func(context.Context, registry.Route) (string, error) { return "inputs", nil }, StopAttempt: func(context.Context, hostclaim.Observed, hostclaim.WakeRetryAttempt) error { stops++; return nil }, ExactHealth: func(context.Context, *exec.Cmd, hostclaim.WakeRetryAttempt) error { return nil }} |
| 38 | base := &ClaimRuntime{Box: "box", Read: func(ctx context.Context, host string) (hostclaim.Observed, error) { |
| 39 | return hostclaim.Read(ctx, st, host) |
| 40 | }, StopForeign: func(context.Context, string) error { return nil }, Prerequisites: func(context.Context, registry.Route, hostclaim.Observed, StartPolicy) error { return nil }, Admit: func(ctx context.Context, _ registry.Route, o hostclaim.Observed, _ StartPolicy) (hostclaim.Observed, error) { |
| 41 | r := retryOf(o) |
| 42 | if r == nil || r.Attempt == nil || r.Attempt.Session != 0 { |
| 43 | t.Fatal("admission preceded first reservation") |
| 44 | } |
| 45 | seq++ |
| 46 | return hostclaim.Update(ctx, st, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, Region: "ie", PredecessorRegion: "ie", Box: "box", Mode: "cold", NewGeneration: fmt.Sprint("gen", seq), PredecessorCertified: true})) |
| 47 | }, Prepare: func(context.Context, registry.Route, hostclaim.Observed, StartPolicy) (ClaimStart, error) { |
| 48 | return ClaimStart{}, nil |
| 49 | }, FailedAdmission: func(context.Context, hostclaim.Observed, error) error { return nil }} |
| 50 | return st, o, base, d, &now, &stops |
| 51 | } |
| 52 | func TestServingRetriesSurviveRestartAndFifthFailure(t *testing.T) { |
| 53 | st, o, base, d, now, stops := servingRetryFixture(t) |
| 54 | ctx := context.Background() |
| 55 | p := StartPolicy{} |
| 56 | rt := registry.Route{Host: o.Host} |
| 57 | for n := 1; n <= 5; n++ { |
| 58 | runtime, err := d.Wrap(base) |
| 59 | if err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | if err = runtime.Prerequisites(ctx, rt, o, p); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | admitted, err := runtime.Admit(ctx, rt, o, p) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if retryOf(admitted).Attempt.Session != admitted.Claim.Holder.Session { |
| 70 | t.Fatal("unbound start") |
| 71 | } |
| 72 | s, err := runtime.Prepare(ctx, rt, admitted, p) |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if err = s.Reserve(ctx, 0); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | if err = s.Failed(ctx, 0); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | o, err = hostclaim.Read(ctx, st, o.Host) |
| 83 | if err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | if retryOf(o).AttemptsReserved != n { |
| 87 | t.Fatal("budget reset") |
| 88 | } |
| 89 | if err = runtime.Prerequisites(ctx, rt, o, p); err == nil { |
| 90 | t.Fatal("early retry admitted") |
| 91 | } |
| 92 | *now = now.Add(time.Second << uint(n-1)) |
| 93 | } |
| 94 | if retryOf(o).State != "failed" || *stops != 5 { |
| 95 | t.Fatalf("retry=%+v stops=%d", retryOf(o), *stops) |
| 96 | } |
| 97 | runtime, _ := d.Wrap(base) |
| 98 | if err := runtime.Prerequisites(ctx, rt, o, p); err == nil { |
| 99 | t.Fatal("new controller reset failed latch") |
| 100 | } |
| 101 | } |
| 102 | func TestServingRetriesReadOnlyRefusalSpendsNothing(t *testing.T) { |
| 103 | st, o, base, d, _, _ := servingRetryFixture(t) |
| 104 | ctx := context.Background() |
| 105 | base.Prerequisites = func(context.Context, registry.Route, hostclaim.Observed, StartPolicy) error { |
| 106 | return hostbox.ErrUnsupported |
| 107 | } |
| 108 | runtime, _ := d.Wrap(base) |
| 109 | if err := runtime.Prerequisites(ctx, registry.Route{Host: o.Host}, o, StartPolicy{}); err == nil { |
| 110 | t.Fatal("missing prerequisite passed") |
| 111 | } |
| 112 | fresh, _ := hostclaim.Read(ctx, st, o.Host) |
| 113 | if fresh.ETag != o.ETag || retryOf(fresh) != nil { |
| 114 | t.Fatal("prerequisite spent authority") |
| 115 | } |
| 116 | } |
| 117 | func TestServingRetriesCrashMustStopBeforeNextReservation(t *testing.T) { |
| 118 | st, o, base, d, _, stops := servingRetryFixture(t) |
| 119 | ctx := context.Background() |
| 120 | rt := registry.Route{Host: o.Host} |
| 121 | runtime, _ := d.Wrap(base) |
| 122 | admitted, err := runtime.Admit(ctx, rt, o, StartPolicy{}) |
| 123 | if err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | restarted, _ := d.Wrap(base) |
| 127 | if err = restarted.Prerequisites(ctx, rt, admitted, StartPolicy{}); !errors.Is(err, hostbox.ErrChanged) { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | fresh, _ := hostclaim.Read(ctx, st, o.Host) |
| 131 | if *stops != 1 || retryOf(fresh).AttemptsReserved != 1 || retryOf(fresh).State != "open" { |
| 132 | t.Fatalf("not settled: %+v", retryOf(fresh)) |
| 133 | } |
| 134 | } |
| 135 | func TestServingRetriesHealthBindsExactLaunch(t *testing.T) { |
| 136 | st, o, base, d, _, _ := servingRetryFixture(t) |
| 137 | ctx := context.Background() |
| 138 | rt := registry.Route{Host: o.Host} |
| 139 | d.ExactHealth = func(context.Context, *exec.Cmd, hostclaim.WakeRetryAttempt) error { return hostbox.ErrChanged } |
| 140 | runtime, _ := d.Wrap(base) |
| 141 | admitted, err := runtime.Admit(ctx, rt, o, StartPolicy{}) |
| 142 | if err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | s, err := runtime.Prepare(ctx, rt, admitted, StartPolicy{}) |
| 146 | if err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | process, _ := os.FindProcess(os.Getpid()) |
| 150 | if err = s.Healthy(ctx, 0, &exec.Cmd{Process: process}); err == nil { |
| 151 | t.Fatal("wrong invocation cleared episode") |
| 152 | } |
| 153 | fresh, _ := hostclaim.Read(ctx, st, o.Host) |
| 154 | if retryOf(fresh) == nil { |
| 155 | t.Fatal("health cleared episode") |
| 156 | } |
| 157 | d.ExactHealth = func(context.Context, *exec.Cmd, hostclaim.WakeRetryAttempt) error { return nil } |
| 158 | runtime, _ = d.Wrap(base) |
| 159 | s, err = runtime.Prepare(ctx, rt, fresh, StartPolicy{}) |
| 160 | if err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | if err = s.Healthy(ctx, 0, &exec.Cmd{Process: process}); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | fresh, _ = hostclaim.Read(ctx, st, o.Host) |
| 167 | if retryOf(fresh) != nil { |
| 168 | t.Fatal("exact health did not clear") |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestServingDiskRefusalSpendsNoRetry(t *testing.T) { |
| 173 | st, o, base, d, _, stops := servingRetryFixture(t) |
| 174 | effects := 0 |
| 175 | base.Prerequisites = func(context.Context, registry.Route, hostclaim.Observed, StartPolicy) error { effects++; return nil } |
| 176 | d.BeforeReserve = func(context.Context, registry.Route, hostclaim.Observed) error { return instancectl.ErrDiskFull } |
| 177 | runtime, err := d.Wrap(base) |
| 178 | if err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | for i := 0; i < 12; i++ { |
| 182 | if err := runtime.Prerequisites(t.Context(), registry.Route{Host: o.Host}, o, StartPolicy{}); !errors.Is(err, instancectl.ErrDiskFull) { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | if _, err := runtime.Admit(t.Context(), registry.Route{Host: o.Host}, o, StartPolicy{}); !errors.Is(err, instancectl.ErrDiskFull) { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | } |
| 189 | after, err := hostclaim.Read(t.Context(), st, o.Host) |
| 190 | if err != nil || after.ETag != o.ETag || effects != 0 || *stops != 0 { |
| 191 | t.Fatal("low disk spent a retry or stopped a writer", err, effects, *stops) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | func TestServingSeedRerunSettlesWithoutReservation(t *testing.T) { |
| 196 | for _, fault := range []string{"", "stop-fails", "stale"} { |
| 197 | t.Run(fault, func(t *testing.T) { |
| 198 | st, o, base, d, _, stops := servingRetryFixture(t) |
| 199 | runtime, err := d.Wrap(base) |
| 200 | if err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | admitted, err := runtime.Admit(t.Context(), registry.Route{Host: o.Host}, o, StartPolicy{}) |
| 204 | if err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | expected := admitted.Claim.Holder.WakeRetry.AttemptsReserved |
| 208 | if fault == "stop-fails" { |
| 209 | d.StopAttempt = func(context.Context, hostclaim.Observed, hostclaim.WakeRetryAttempt) error { return hostbox.ErrChanged } |
| 210 | } |
| 211 | if fault == "stale" { |
| 212 | admitted.ETag = "stale" |
| 213 | } |
| 214 | d.BeforeReserve = func(context.Context, registry.Route, hostclaim.Observed) error { |
| 215 | t.Fatal("settlement required new preparation capacity") |
| 216 | return nil |
| 217 | } |
| 218 | settled, err := d.SettleAbandoned(t.Context(), admitted) |
| 219 | if fault != "" { |
| 220 | if err == nil { |
| 221 | t.Fatal("settled", fault) |
| 222 | } |
| 223 | return |
| 224 | } |
| 225 | r := settled.Claim.Holder.WakeRetry |
| 226 | if err != nil || r.State != "open" || r.Attempt != nil || r.AttemptsReserved != expected || *stops != 1 { |
| 227 | t.Fatal("rerun settlement", r, err, *stops) |
| 228 | } |
| 229 | again, err := d.SettleAbandoned(t.Context(), settled) |
| 230 | if err != nil || again.ETag != settled.ETag || *stops != 1 { |
| 231 | t.Fatal("settled replay changed state", err) |
| 232 | } |
| 233 | fresh, err := hostclaim.Read(t.Context(), st, o.Host) |
| 234 | if err != nil || fresh.ETag != settled.ETag { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | }) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestServingRetriesReturnOwnClaimWrites(t *testing.T) { |
| 242 | st, o, base, d, now, _ := servingRetryFixture(t) |
| 243 | var expected hostclaim.Observed |
| 244 | calls := 0 |
| 245 | base.Prepare = func(_ context.Context, _ registry.Route, o hostclaim.Observed, _ StartPolicy) (ClaimStart, error) { |
| 246 | expected = o |
| 247 | return ClaimStart{RetryClaimWrite: func(before, after hostclaim.Observed) error { |
| 248 | if before.ETag != expected.ETag || after.ETag == before.ETag || after.Claim.Gen != before.Claim.Gen+1 { |
| 249 | t.Fatal("write did not chain from Prepare") |
| 250 | } |
| 251 | expected = after |
| 252 | calls++ |
| 253 | return nil |
| 254 | }}, nil |
| 255 | } |
| 256 | runtime, err := d.Wrap(base) |
| 257 | if err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | rt := registry.Route{Host: o.Host} |
| 261 | admitted, err := runtime.Admit(t.Context(), rt, o, StartPolicy{}) |
| 262 | if err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | start, err := runtime.Prepare(t.Context(), rt, admitted, StartPolicy{}) |
| 266 | if err != nil { |
| 267 | t.Fatal(err) |
| 268 | } |
| 269 | if err := start.Reserve(t.Context(), 0); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | if calls != 0 { |
| 273 | t.Fatal("first reservation invented a write") |
| 274 | } |
| 275 | if err := start.Failed(t.Context(), 0); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | *now = now.Add(2 * time.Second) |
| 279 | if err := start.Reserve(t.Context(), 1); err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | actual, err := hostclaim.Read(t.Context(), st, o.Host) |
| 283 | if err != nil || calls != 2 || expected.ETag != actual.ETag { |
| 284 | t.Fatal("lost retry write", calls, err) |
| 285 | } |
| 286 | } |
| 287 | |