activator: record a seed placed after the first upload (Plan 4 Task 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+226
−3
internal/activator/activator.go+15 −0internal/activator/activator_test.go+45 −0internal/activator/backend_unit.go+9 −0internal/activator/seedplace.go+35 −3internal/activator/seedplace_linux_test.go+122 −0
CI passed — run details
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 3fd5980d1..9e0107012 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -619,6 +619,12 @@ type configAdmitter interface { |
| AdmitConfig(ctx context.Context, rt registry.Route) error |
| } |
| +// seedPlacer is the optional half of a Backend that records a seed placed |
| +// after the first upload (seeded-instances spec §3.4). |
| +type seedPlacer interface { |
| + SeedPlaced(ctx context.Context, rt registry.Route, rep Rep) error |
| +} |
| + |
| // configRefusedRetryAfter is the Retry-After (seconds) a refused wake sends. |
| const configRefusedRetryAfter = 30 |
| @@ -1344,6 +1350,15 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro |
| return w, fmt.Errorf("replicate %s: %w", host, err) |
| } |
| + // 5b. A seeded row's first upload, then its record (spec §3.4 step 5). |
| + // A failure here does not fail the wake: the instance is serving from |
| + // the placed file, and the next wake records it (finishPlacement). |
| + if p, ok := a.backend.(seedPlacer); ok { |
| + if err := p.SeedPlaced(ctx, rt, rep); err != nil { |
| + log.Printf("activator: %s: seed placement not recorded yet: %v", host, err) |
| + } |
| + } |
| + |
| // 6. Co-spawn the sidecar, last — only once the instance is healthy and |
| // replicating, so the sidecar always boots against a live instance (its |
| // marked requests pass through the edge while we're awake). A spawn |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index e9823ca7b..ac567e48c 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -35,6 +35,7 @@ import ( |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/seeds" |
| _ "modernc.org/sqlite" |
| ) |
| @@ -6452,3 +6453,47 @@ func TestStaleSidecarRestartNeverInstallsOntoAFreshWake(t *testing.T) { |
| t.Fatal("the stale restart installed over the fresh wake's own sidecar") |
| } |
| } |
| + |
| +// seedCountingBackend is fakeBackend plus the optional seed recorder. |
| +type seedCountingBackend struct { |
| + fakeBackend |
| + placed []string |
| + rep Rep |
| + replicated int // Replicate calls seen when SeedPlaced ran |
| + err error |
| +} |
| + |
| +func (b *seedCountingBackend) SeedPlaced(_ context.Context, rt registry.Route, rep Rep) error { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.placed = append(b.placed, rt.Host) |
| + b.rep, b.replicated = rep, len(b.syncIntervals) |
| + return b.err |
| +} |
| + |
| +func TestDoWakeRecordsSeedPlacementAfterReplication(t *testing.T) { |
| + for _, recErr := range []error{nil, errors.New("record unreachable")} { |
| + b := &seedCountingBackend{err: recErr} |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, b, reg) |
| + rt := testRoute("shop.example.com") |
| + rt.SeedSource = seeds.RowSeed{ID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: seeds.KindReplica, State: seeds.StateReady}.Encode() |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A failed record never fails the wake: the instance serves. |
| + arrangeAwake(t, a, rt) |
| + if len(b.placed) != 1 || b.placed[0] != rt.Host { |
| + t.Fatalf("SeedPlaced calls = %q, want one, after replication started", b.placed) |
| + } |
| + if len(b.reps) != 1 || b.rep != Rep(b.reps[0]) || b.replicated != 1 { |
| + t.Fatalf("SeedPlaced saw rep %v after %d replications; want the wake's own, after Replicate", b.rep, b.replicated) |
| + } |
| + if !a.Owns(rt.Host) { |
| + t.Fatalf("recorder error %v: the wake did not serve", recErr) |
| + } |
| + } |
| +} |
diff --git a/internal/activator/backend_unit.go b/internal/activator/backend_unit.go| index 19535c0a5..91ef5a1aa 100644 |
| --- a/internal/activator/backend_unit.go |
| +++ b/internal/activator/backend_unit.go |
| @@ -308,6 +308,15 @@ func (b *SplitBackend) AdmitConfig(ctx context.Context, rt registry.Route) error |
| } |
| return nil |
| } |
| + |
| +// SeedPlaced dispatches on Backing, like every other call. A unit tenant |
| +// never carries a seed. |
| +func (b *SplitBackend) SeedPlaced(ctx context.Context, rt registry.Route, rep Rep) error { |
| + if p, ok := b.pick(rt).(seedPlacer); ok { |
| + return p.SeedPlaced(ctx, rt, rep) |
| + } |
| + return nil |
| +} |
| func (b *SplitBackend) Restore(ctx context.Context, rt registry.Route) (bool, error) { |
| return b.pick(rt).Restore(ctx, rt) |
| } |
diff --git a/internal/activator/seedplace.go b/internal/activator/seedplace.go| index 5801cba2f..e998ef61b 100644 |
| --- a/internal/activator/seedplace.go |
| +++ b/internal/activator/seedplace.go |
| @@ -355,6 +355,38 @@ func (e *readErrs) Read(p []byte) (int, error) { |
| return n, err |
| } |
| -// finishPlacement is Task 4's: the record's CAS to placed and the |
| -// snapshot's delete, best effort, once the host has a replica of its own. |
| -func (b *ExecBackend) finishPlacement(context.Context, registry.Route, *seeds.RowSeed) {} |
| +// SeedPlaced records a seed placed once the host's own replica holds it |
| +// (spec §3.4 steps 4 and 5): it waits for the first upload, then |
| +// OnSeedPlaced CASes the record to placed and deletes seeds/<host>/<id>/. |
| +// The snapshot goes only after the replica exists, so a crash at any point |
| +// retries from the placed local file or the snapshot. A replicator that |
| +// cannot be asked for its first upload records nothing. |
| +func (b *ExecBackend) SeedPlaced(ctx context.Context, rt registry.Route, rep Rep) error { |
| + src, err := seeds.ParseRowSeed(rt.SeedSource) |
| + if err != nil || src == nil || !src.Placeable() || src.State == seeds.StatePlaced { |
| + return err |
| + } |
| + s, ok := rep.(interface{ SyncAndWait(context.Context) error }) |
| + if !ok { |
| + return fmt.Errorf("first upload of %s: the replicator cannot confirm it", rt.Host) |
| + } |
| + if err := s.SyncAndWait(ctx); err != nil { |
| + return fmt.Errorf("first upload of %s: %w", rt.Host, err) |
| + } |
| + if b.OnSeedPlaced == nil { |
| + return errors.New("no seed recorder on this box") |
| + } |
| + return b.OnSeedPlaced(ctx, rt.Account, rt.App, rt.Host, src.ID) |
| +} |
| + |
| +// finishPlacement is SeedPlaced for a host whose own replica already |
| +// exists (a placed CAS lost on an earlier wake). Best effort: a failure is |
| +// logged, never the wake's, and the next wake asks again. |
| +func (b *ExecBackend) finishPlacement(ctx context.Context, rt registry.Route, src *seeds.RowSeed) { |
| + if b.OnSeedPlaced == nil { |
| + return |
| + } |
| + if err := b.OnSeedPlaced(ctx, rt.Account, rt.App, rt.Host, src.ID); err != nil { |
| + log.Printf("activator: %s: seed %s not recorded as placed yet: %v", rt.Host, src.ID, err) |
| + } |
| +} |
diff --git a/internal/activator/seedplace_linux_test.go b/internal/activator/seedplace_linux_test.go| index 13e2381bc..41282ac8e 100644 |
| --- a/internal/activator/seedplace_linux_test.go |
| +++ b/internal/activator/seedplace_linux_test.go |
| @@ -587,3 +587,125 @@ func TestSeededRestorePlacesAWALModeSnapshot(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// syncRep is a Rep that can be asked for its first upload. |
| +type syncRep struct { |
| + synced int |
| + err error |
| +} |
| + |
| +func (r *syncRep) Err() error { return nil } |
| +func (r *syncRep) Close(context.Context) error { return nil } |
| +func (r *syncRep) SyncAndWait(context.Context) error { r.synced++; return r.err } |
| + |
| +// plainRep is a Rep that cannot be asked for its first upload. |
| +type plainRep struct{} |
| + |
| +func (plainRep) Err() error { return nil } |
| +func (plainRep) Close(context.Context) error { return nil } |
| + |
| +func withSeedState(t *testing.T, rt registry.Route, state string) registry.Route { |
| + t.Helper() |
| + src, err := seeds.ParseRowSeed(rt.SeedSource) |
| + if err != nil || src == nil { |
| + t.Fatalf("seed column %q: %v", rt.SeedSource, err) |
| + } |
| + src.State = state |
| + rt.SeedSource = src.Encode() |
| + return rt |
| +} |
| + |
| +func TestSeedPlacedWaitsForTheFirstUpload(t *testing.T) { |
| + b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) |
| + rt, _, id := seededRoute(t, b, rt, seeds.StateReady) |
| + rt.Account, rt.App = "acct", "shop" |
| + var calls []string |
| + b.OnSeedPlaced = func(_ context.Context, acct, app, host, got string) error { |
| + calls = append(calls, acct+"/"+app+"/"+host+"/"+got) |
| + return nil |
| + } |
| + rep := &syncRep{} |
| + if err := b.SeedPlaced(context.Background(), rt, rep); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rep.synced != 1 || len(calls) != 1 || calls[0] != "acct/shop/"+rt.Host+"/"+id { |
| + t.Fatalf("synced %d, calls %q", rep.synced, calls) |
| + } |
| + // A failed first upload records nothing. |
| + rep = &syncRep{err: errors.New("upload failed")} |
| + calls = nil |
| + if err := b.SeedPlaced(context.Background(), rt, rep); err == nil || len(calls) != 0 { |
| + t.Fatalf("after a failed sync: err %v calls %q", err, calls) |
| + } |
| + // A replicator that cannot confirm the upload records nothing either: |
| + // the snapshot must outlive every moment the replica might not exist. |
| + if err := b.SeedPlaced(context.Background(), rt, plainRep{}); err == nil || len(calls) != 0 { |
| + t.Fatalf("unconfirmable upload: err %v calls %q", err, calls) |
| + } |
| + // A recorder that fails is the caller's to log. |
| + b.OnSeedPlaced = func(context.Context, string, string, string, string) error { return errors.New("conflict") } |
| + if err := b.SeedPlaced(context.Background(), rt, &syncRep{}); err == nil { |
| + t.Fatal("a failed record reported success") |
| + } |
| + b.OnSeedPlaced = nil |
| + if err := b.SeedPlaced(context.Background(), rt, &syncRep{}); err == nil { |
| + t.Fatal("a box with no recorder reported the seed recorded") |
| + } |
| + b.OnSeedPlaced = func(_ context.Context, acct, app, host, got string) error { |
| + calls = append(calls, got) |
| + return nil |
| + } |
| + // Placed already, not placeable, or no seed: nothing at all. |
| + for name, r := range map[string]registry.Route{ |
| + "placed": withSeedState(t, rt, seeds.StatePlaced), |
| + "aborted": withSeedState(t, rt, seeds.StateAborted), |
| + "none": func() registry.Route { r := rt; r.SeedSource = ""; return r }(), |
| + } { |
| + rep = &syncRep{} |
| + if err := b.SeedPlaced(context.Background(), r, rep); err != nil || rep.synced != 0 || len(calls) != 0 { |
| + t.Fatalf("%s row: err %v synced %d calls %q", name, err, rep.synced, calls) |
| + } |
| + } |
| +} |
| + |
| +// Review Focus 5: the placed CAS failed after the first upload, so the row |
| +// still reads ready. The next wake finds the host's own replica and |
| +// finishes the placement; a failure there never fails the wake, and the |
| +// wake after that asks again. |
| +func TestSeedPlacedIsFinishedOnTheNextWake(t *testing.T) { |
| + old := hasOwnReplica |
| + hasOwnReplica = func(context.Context, string, string, string) (bool, error) { return true, nil } |
| + defer func() { hasOwnReplica = old }() |
| + oldRestore := restoreReplica |
| + restoreReplica = func(_ context.Context, dbPath, _, _, _ string) (bool, error) { return true, writeMinimalSQLite(dbPath) } |
| + defer func() { restoreReplica = oldRestore }() |
| + b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) |
| + rt, objs, id := seededRoute(t, b, rt, seeds.StateReady) // the CAS was lost last wake |
| + rt.Account, rt.App = "acct", "shop" |
| + var got []string |
| + var recErr error |
| + b.OnSeedPlaced = func(_ context.Context, acct, app, host, seed string) error { |
| + got = append(got, acct+"/"+app+"/"+host+"/"+seed) |
| + return recErr |
| + } |
| + want := "acct/shop/" + rt.Host + "/" + id |
| + recErr = errors.New("record unreachable") |
| + if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh { |
| + t.Fatalf("Restore with a failing recorder = fresh %v, %v; want a served wake", fresh, err) |
| + } |
| + recErr = nil |
| + if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh { |
| + t.Fatalf("Restore = fresh %v, %v", fresh, err) |
| + } |
| + if len(got) != 2 || got[0] != want || got[1] != want { |
| + t.Fatalf("finishPlacement calls = %q, want two for %s", got, want) |
| + } |
| + if objs.opens != 0 { |
| + t.Fatalf("snapshot opened %d times; the own replica wins", objs.opens) |
| + } |
| + // No recorder on this box: the wake still serves. |
| + b.OnSeedPlaced = nil |
| + if _, err := b.Restore(context.Background(), rt); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |