host: relocation refuses what it cannot read: an unreadable completed relocation holds the barrier and the sweep, an empty listing needs session 0 and the replicator's own zero position, and a malformed adoption record fails the flush
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 files changed,
+1013
−161
internal/activator/fenceowner_test.go+62 −0internal/activator/recoveryprobe.go+3 −0internal/activator/recoveryprobe_sweep_test.go+11 −0internal/host/relocationassembly.go+61 −19internal/host/relocationassembly_test.go+259 −0internal/host/relocationeffects.go+0 −29internal/host/relocationflush.go+35 −37internal/host/relocationflush_adoption_test.go+47 −8internal/host/relocationsweep.go+133 −45internal/host/relocationsweep_test.go+119 −0internal/hostclaim/relocation.go+6 −4internal/hostclaim/relocation_probestop.go+33 −9internal/hostclaim/relocation_probestop_test.go+49 −4internal/hostclaim/relocation_test.go+1 −1internal/hostrelocate/participant_gate_test.go+49 −0internal/hostrelocate/participant_test.go+2 −1internal/hostrelocate/worker.go+11 −4internal/hostrelocate/worker_bounds_test.go+54 −0internal/lease/lease.go+15 −0internal/lease/position_test.go+63 −0
diff --git a/internal/activator/fenceowner_test.go b/internal/activator/fenceowner_test.go| index 540b8f3ea..25c1054a5 100644 |
| --- a/internal/activator/fenceowner_test.go |
| +++ b/internal/activator/fenceowner_test.go |
| @@ -110,3 +110,65 @@ func TestLiftFenceAsLiftsOnlyTheOwnersFence(t *testing.T) { |
| t.Fatal("a plain lift left the owner behind") |
| } |
| } |
| + |
| +// TestAnOwnedCallNeverTakesBackAFenceReplacedWhileItWaited (fix round 1, |
| +// M1; ruling 75): FenceAndStopAs installs its owned fence and waits for |
| +// the stop. Meanwhile a plain FenceAndStop installs at the same generation, |
| +// or a plain LiftFence lifts it. When the stop completes the owned call |
| +// answers ErrStopUnproven and leaves the fence as the other caller left |
| +// it: still plain and unowned, or still lifted, never the owner's again. |
| +func TestAnOwnedCallNeverTakesBackAFenceReplacedWhileItWaited(t *testing.T) { |
| + for _, meanwhile := range []string{"a plain fence", "a plain lift"} { |
| + t.Run(meanwhile, func(t *testing.T) { |
| + a, e, _, _, _ := maintenanceAwake(t) |
| + stopped := make(chan struct{}) |
| + a.mu.Lock() |
| + e.state, e.stopped = actStopping, stopped |
| + a.mu.Unlock() |
| + type answer struct { |
| + proof *StopEvidence |
| + err error |
| + } |
| + done := make(chan answer, 1) |
| + go func() { |
| + proof, err := a.FenceAndStopAs(maintenanceContext(t, 5*time.Second), e.host, 12, "owner-1") |
| + done <- answer{proof, err} |
| + }() |
| + for installed := false; !installed; { |
| + a.mu.Lock() |
| + installed = a.fences[e.host] == 12 && a.fenceOwners[e.host] == "owner-1" |
| + a.mu.Unlock() |
| + time.Sleep(time.Millisecond) |
| + } |
| + switch meanwhile { |
| + case "a plain fence": |
| + if _, err := a.FenceAndStop(maintenanceContext(t, 20*time.Millisecond), e.host, 12); !errors.Is(err, ErrStopUnproven) { |
| + t.Fatalf("the plain fence: %v", err) |
| + } |
| + case "a plain lift": |
| + if !a.LiftFence(e.host, 12) { |
| + t.Fatal("the plain lift lifted nothing") |
| + } |
| + } |
| + a.mu.Lock() |
| + delete(a.entries, e.host) |
| + a.mu.Unlock() |
| + close(stopped) |
| + got := <-done |
| + if !errors.Is(got.err, ErrStopUnproven) || got.proof != nil { |
| + t.Fatalf("the owned call after its fence was replaced: %v %v", got.proof, got.err) |
| + } |
| + a.mu.Lock() |
| + gen := a.fences[e.host] |
| + owner, owned := a.fenceOwners[e.host] |
| + a.mu.Unlock() |
| + want := uint64(12) |
| + if meanwhile == "a plain lift" { |
| + want = 0 |
| + } |
| + if gen != want || owned { |
| + t.Fatalf("the fence is %d owned by %q (%v), want %d unowned", gen, owner, owned, want) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/activator/recoveryprobe.go b/internal/activator/recoveryprobe.go| index 3783923bf..f82a54e9d 100644 |
| --- a/internal/activator/recoveryprobe.go |
| +++ b/internal/activator/recoveryprobe.go |
| @@ -321,8 +321,11 @@ func (a *Service) reviewRecoveryHold(e *actEntry) { |
| // The entry retains both the observed fence and the adapter's |
| // owning generation. Check and remove under the same lock as the |
| // hold transition; never lift a replacement operation's fence. |
| + // A lift clears an owned fence's owner with it (FenceAndStopAs), |
| + // so no owner outlives its fence. |
| if e.recoveryPolicy.RecoveryFence != 0 && a.fences[e.host] == e.recoveryPolicy.RecoveryFence { |
| delete(a.fences, e.host) |
| + delete(a.fenceOwners, e.host) |
| } |
| log.Printf("activator: %s source opened on the recovery probe's session; serving", e.host) |
| if e.gen.N != 0 { |
diff --git a/internal/activator/recoveryprobe_sweep_test.go b/internal/activator/recoveryprobe_sweep_test.go| index 8f03fe3ae..fa83dfd95 100644 |
| --- a/internal/activator/recoveryprobe_sweep_test.go |
| +++ b/internal/activator/recoveryprobe_sweep_test.go |
| @@ -89,6 +89,11 @@ func TestRecoveryProbeSweepOpensOnlyExactSessionAndFence(t *testing.T) { |
| for _, phase := range []string{hostclaim.PhaseProbing, hostclaim.PhaseProbed, hostclaim.PhaseOpened, "serving", "new-session", "foreign-executor", "unreadable"} { |
| t.Run(phase, func(t *testing.T) { |
| a, r, e, proc, _, admitted := sweptProbe(t) |
| + // The cut fence carries an owner (FenceAndStopAs at the same |
| + // generation): the opening's lift clears it with the fence. |
| + a.mu.Lock() |
| + a.fenceOwners = map[string]string{probeHost: "owner-1"} |
| + a.mu.Unlock() |
| fresh := admitted |
| c, h := *admitted.Claim, *admitted.Claim.Holder |
| c.Holder = &h |
| @@ -128,6 +133,12 @@ func TestRecoveryProbeSweepOpensOnlyExactSessionAndFence(t *testing.T) { |
| if a.Fenced(probeHost) || proc.isStopped() { |
| t.Fatal("exact opening did not release the cut fence and retain its process") |
| } |
| + a.mu.Lock() |
| + _, owned := a.fenceOwners[probeHost] |
| + a.mu.Unlock() |
| + if owned { |
| + t.Fatal("the opening lifted the cut fence but left its owner") |
| + } |
| release, err := a.beginRequest(t.Context(), registry.Route{Host: probeHost}) |
| if err != nil { |
| t.Fatalf("opened session cannot serve: %v", err) |
diff --git a/internal/host/relocationassembly.go b/internal/host/relocationassembly.go| index 9fcbd0708..a16b9b91a 100644 |
| --- a/internal/host/relocationassembly.go |
| +++ b/internal/host/relocationassembly.go |
| @@ -25,8 +25,12 @@ import ( |
| // RelocationActivator is the part of activator.Service relocation uses: |
| // the probe attempt (Task 8d) and the local fence that ends a passed |
| // probe's session before the serving CAS (ruling 49). The fence is an |
| -// owned one (ruling 75): taken and met again only under the owner the |
| -// probe stop persisted, and lifted only under it. |
| +// owned one (ruling 75): relocation takes and meets it again only under |
| +// the owner the probe stop persisted, and lifts it only under that owner. |
| +// The activator's other paths do not ask for the owner: a plain |
| +// FenceAndStop or LiftFence at the same generation, or a recovery probe |
| +// opening on a RecoveryFence equal to it, replaces or lifts it and clears |
| +// the owner, after which the owner can neither meet nor lift it. |
| type RelocationActivator interface { |
| ProbeWake(context.Context, activator.ProbeConfig) (activator.ProbeResult, error) |
| FenceAndStopAs(context.Context, string, uint64, string) (*activator.StopEvidence, error) |
| @@ -144,8 +148,9 @@ func relocationPathsFor(dataDir string, rt registry.Route) relocationPaths { |
| } |
| // relocationHostPaths reads the host's own row: the source's flush |
| -// publishes the paths its writer used. No row is no paths, which the |
| -// flush refuses rather than guess. |
| +// publishes the paths its writer used. No row, or a row that cannot be |
| +// read, is no paths, and ReleaseFlushFiles.Flush refuses empty paths |
| +// before it stages anything rather than guess. |
| func relocationHostPaths(dataDir string, reg *registry.Registry) func(string) relocationPaths { |
| return func(host string) relocationPaths { |
| if reg == nil { |
| @@ -159,10 +164,8 @@ func relocationHostPaths(dataDir string, reg *registry.Registry) func(string) re |
| } |
| } |
| -// relocationFlushDB is release step 2 (ruling 8): SyncAndWait with every |
| -// writer stopped, then N is the newest level-0 MaxTXID, which must equal |
| -// the plan's MaxTXID. A listing with no LTX file is the stopped writer |
| -// vouching that it never committed: N is 0. |
| +// relocationFlushDB is release step 2 (ruling 8) for the session's own |
| +// replicator: releaseFinalTXID over the claim wake's attempt for the host. |
| func (a ClaimAssembly) relocationFlushDB(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (uint64, error) { |
| return func(ctx context.Context, o hostclaim.Observed) (uint64, error) { |
| if a.Wake == nil || o.Claim == nil || o.Claim.Holder == nil { |
| @@ -172,19 +175,58 @@ func (a ClaimAssembly) relocationFlushDB(cfg RelocationAssemblyConfig) func(cont |
| if att == nil || att.rep == nil || cfg.Versions == nil || cfg.Bucket == "" { |
| return 0, ErrClaimStopUnavailable |
| } |
| - if err := att.rep.SyncAndWait(ctx); err != nil { |
| - return 0, err |
| + sel := func(ctx context.Context, r genselect.Request) (*genselect.Plan, error) { |
| + return genselect.Select(ctx, cfg.Versions, r) |
| } |
| - plan, err := genselect.Select(ctx, cfg.Versions, genselect.Request{Bucket: cfg.Bucket, Region: cfg.Region, Host: o.Host, Holder: *o.Claim.Holder}) |
| - if errors.Is(err, genselect.ErrEmpty) { |
| + return releaseFinalTXID(ctx, att.rep, sel, genselect.Request{Bucket: cfg.Bucket, Region: cfg.Region, Host: o.Host, Holder: *o.Claim.Holder}) |
| + } |
| +} |
| + |
| +// releaseReplica is the part of the session's replicator the release's |
| +// database flush uses (lease.Replicator). |
| +type releaseReplica interface { |
| + SyncAndWait(context.Context) error |
| + Position() (uint64, error) |
| +} |
| + |
| +// releaseFinalTXID is release step 2 (ruling 8): SyncAndWait with every |
| +// writer stopped, then N is the newest level-0 MaxTXID, which must equal |
| +// the plan's MaxTXID. |
| +// |
| +// An empty listing is not evidence of an empty database (fix round 1, I2): |
| +// a wrong bucket, or a listing not yet visible, reads the same. N is 0 |
| +// only when the holder's writer session is 0 (no writer was ever admitted, |
| +// pin.go's "session-0" and ruling 25) and the replicator's own position |
| +// (lease.Replicator.Position, litestream's newest local LTX) is 0 too. |
| +// Any other empty listing refuses: the release is never journaled, and the |
| +// worker marks the op stuck ReleaseDeadline after releasing began, for an |
| +// operator, as a refused pin leaves it. |
| +func releaseFinalTXID(ctx context.Context, rep releaseReplica, sel func(context.Context, genselect.Request) (*genselect.Plan, error), req genselect.Request) (uint64, error) { |
| + if rep == nil || sel == nil { |
| + return 0, ErrClaimStopUnavailable |
| + } |
| + if err := rep.SyncAndWait(ctx); err != nil { |
| + return 0, err |
| + } |
| + pos, err := rep.Position() |
| + if err != nil { |
| + return 0, err |
| + } |
| + plan, err := sel(ctx, req) |
| + if errors.Is(err, genselect.ErrEmpty) { |
| + if req.Holder.Session == 0 && pos == 0 { |
| return 0, nil |
| } |
| - if err != nil { |
| - return 0, err |
| - } |
| - if plan.MaxLevel0TXID != plan.MaxTXID { |
| - return 0, fmt.Errorf("%w: level 0 at %d, the database at %d", ErrClaimStopUnavailable, plan.MaxLevel0TXID, plan.MaxTXID) |
| - } |
| - return plan.MaxTXID, nil |
| + return 0, fmt.Errorf("%w: the listing is empty for writer session %d, whose replicator is at %d", ErrClaimStopUnavailable, req.Holder.Session, pos) |
| + } |
| + if err != nil { |
| + return 0, err |
| + } |
| + if plan == nil { |
| + return 0, ErrClaimStopUnavailable |
| + } |
| + if plan.MaxLevel0TXID != plan.MaxTXID { |
| + return 0, fmt.Errorf("%w: level 0 at %d, the database at %d", ErrClaimStopUnavailable, plan.MaxLevel0TXID, plan.MaxTXID) |
| } |
| + return plan.MaxTXID, nil |
| } |
diff --git a/internal/host/relocationassembly_test.go b/internal/host/relocationassembly_test.go| new file mode 100644 |
| index 000000000..7d5a9bb40 |
| --- /dev/null |
| +++ b/internal/host/relocationassembly_test.go |
| @@ -0,0 +1,259 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "slices" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execctl "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| +) |
| + |
| +// fakeReleaseReplica is the session's replicator as releaseFinalTXID sees |
| +// it: SyncAndWait, then its own position. |
| +type fakeReleaseReplica struct { |
| + calls []string |
| + syncErr error |
| + pos uint64 |
| + posErr error |
| +} |
| + |
| +func (r *fakeReleaseReplica) SyncAndWait(context.Context) error { |
| + r.calls = append(r.calls, "sync") |
| + return r.syncErr |
| +} |
| + |
| +func (r *fakeReleaseReplica) Position() (uint64, error) { |
| + r.calls = append(r.calls, "position") |
| + return r.pos, r.posErr |
| +} |
| + |
| +// Fix round 1, I2: an empty listing is not evidence of an empty database. |
| +// FinalTXID 0 comes only from a writer session 0 whose replicator is at 0 |
| +// too; a wrong bucket (an empty listing while the replicator reports a |
| +// position) refuses, and so does an empty listing for an admitted writer. |
| +// A listing is read only after the replicator synced and reported. |
| +func TestReleaseFinalTXIDNeedsTheReplicatorsOwnEvidence(t *testing.T) { |
| + ctx := context.Background() |
| + failure := errors.New("s3: access denied") |
| + for _, tc := range []struct { |
| + name string |
| + session uint64 |
| + rep fakeReleaseReplica |
| + plan *genselect.Plan |
| + selErr error |
| + want uint64 |
| + refuses bool |
| + calls []string |
| + }{ |
| + {name: "the wrong bucket: an empty listing, the replicator at 41", session: 6, rep: fakeReleaseReplica{pos: 41}, selErr: genselect.ErrEmpty, refuses: true}, |
| + {name: "an empty listing for an admitted writer whose replicator is at 0", session: 6, selErr: genselect.ErrEmpty, refuses: true}, |
| + {name: "an empty listing for session 0 whose replicator is at 3", rep: fakeReleaseReplica{pos: 3}, selErr: genselect.ErrEmpty, refuses: true}, |
| + {name: "a genuinely empty session-0 database", selErr: genselect.ErrEmpty, want: 0}, |
| + {name: "a listing at the database's position", session: 6, rep: fakeReleaseReplica{pos: 41}, plan: &genselect.Plan{MaxTXID: 41, MaxLevel0TXID: 41}, want: 41}, |
| + {name: "level 0 behind the database", session: 6, rep: fakeReleaseReplica{pos: 41}, plan: &genselect.Plan{MaxTXID: 41, MaxLevel0TXID: 40}, refuses: true}, |
| + {name: "a listing that fails", session: 6, rep: fakeReleaseReplica{pos: 41}, selErr: failure, refuses: true}, |
| + {name: "a sync that fails", session: 6, rep: fakeReleaseReplica{syncErr: failure}, refuses: true, calls: []string{"sync"}}, |
| + {name: "a position that cannot be read", session: 0, rep: fakeReleaseReplica{posErr: failure}, refuses: true, calls: []string{"sync", "position"}}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + rep := tc.rep |
| + selected := false |
| + sel := func(_ context.Context, r genselect.Request) (*genselect.Plan, error) { |
| + selected = true |
| + if r.Holder.Session != tc.session || r.Host != "shop.example" { |
| + t.Fatalf("selected for %+v", r) |
| + } |
| + return tc.plan, tc.selErr |
| + } |
| + got, err := releaseFinalTXID(ctx, &rep, sel, genselect.Request{Host: "shop.example", Holder: hostclaim.Holder{Session: tc.session}}) |
| + if tc.refuses { |
| + if err == nil { |
| + t.Fatalf("released at %d", got) |
| + } |
| + } else if err != nil || got != tc.want { |
| + t.Fatalf("got %d %v, want %d", got, err, tc.want) |
| + } |
| + want := tc.calls |
| + if want == nil { |
| + want = []string{"sync", "position"} |
| + } |
| + if !slices.Equal(rep.calls, want) || selected != (tc.calls == nil) { |
| + t.Fatalf("ran %v selected=%v, want %v", rep.calls, selected, want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// relocationFlushDB has nothing to flush without the session's replicator: |
| +// no wake, no attempt for the host, no data bucket. |
| +func TestRelocationFlushDBNeedsTheSessionsReplicator(t *testing.T) { |
| + obs := hostclaim.Observed{Host: "shop.example", Claim: &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Session: 0}}} |
| + cfg := RelocationAssemblyConfig{Bucket: "data"} |
| + for name, a := range map[string]ClaimAssembly{"no wake": {}, "no attempt": {Wake: &ClaimWakeAssembly{}}} { |
| + if _, err := a.relocationFlushDB(cfg)(context.Background(), obs); !errors.Is(err, ErrClaimStopUnavailable) { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + } |
| + if _, err := (ClaimAssembly{Wake: &ClaimWakeAssembly{}}).relocationFlushDB(cfg)(context.Background(), hostclaim.Observed{Host: "shop.example"}); !errors.Is(err, ErrClaimStopUnavailable) { |
| + t.Fatalf("no claim: %v", err) |
| + } |
| +} |
| + |
| +// relocationRoot is root's isolated launcher as relocationPreflight asks |
| +// it: the protocol, then the capability for one kind. |
| +type relocationRoot struct { |
| + protocol string |
| + kindErr error |
| + asked []string |
| +} |
| + |
| +func (r *relocationRoot) Capability(context.Context) (string, error) { |
| + r.asked = append(r.asked, "protocol") |
| + return r.protocol, nil |
| +} |
| +func (*relocationRoot) Launch(context.Context, hostbox.RoleLaunchRequest) (hostbox.RoleLaunchGrant, error) { |
| + return hostbox.RoleLaunchGrant{}, errors.New("unused") |
| +} |
| +func (*relocationRoot) Stop(context.Context, hostbox.RoleLaunchRequest) error { return nil } |
| +func (r *relocationRoot) KindCapability(_ context.Context, kind string) error { |
| + r.asked = append(r.asked, "kind "+kind) |
| + return r.kindErr |
| +} |
| + |
| +// Fix round 1, M4: relocationPreflight (ruling 48) asks root for the |
| +// isolated launch protocol, then for kind relocating; either refusal is |
| +// the answer. After P's opening, until claim ask C11, a later attempt is |
| +// ErrLaterAttemptUnlaunchable without asking root (ruling 58). |
| +func TestRelocationPreflightAsksRootForTheRelocatingKind(t *testing.T) { |
| + ctx := context.Background() |
| + refused := errors.New("root: no attempt reader for relocating") |
| + probing := func(opened bool) hostclaim.Observed { |
| + j := map[string]string{"prepared": "P1"} |
| + if opened { |
| + j["prepared_opened"] = "P1" |
| + } |
| + return hostclaim.Observed{Host: "shop.example", Claim: &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Session: 7}, |
| + Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, Journal: j}}} |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + root *relocationRoot |
| + opened bool |
| + later bool |
| + want error |
| + asked []string |
| + }{ |
| + {name: "root answers both", root: &relocationRoot{protocol: hostbox.RoleLaunchProtocol}, asked: []string{"protocol", "kind relocating"}}, |
| + {name: "root speaks another protocol", root: &relocationRoot{protocol: "old"}, want: activator.ErrClaimIsolationUnavailable, asked: []string{"protocol"}}, |
| + {name: "root refuses the kind", root: &relocationRoot{protocol: hostbox.RoleLaunchProtocol, kindErr: refused}, want: refused, asked: []string{"protocol", "kind relocating"}}, |
| + {name: "a later attempt before C11", root: &relocationRoot{protocol: hostbox.RoleLaunchProtocol}, opened: true, want: hostrelocate.ErrLaterAttemptUnlaunchable}, |
| + {name: "a later attempt once C11 is answered", root: &relocationRoot{protocol: hostbox.RoleLaunchProtocol}, opened: true, later: true, asked: []string{"protocol", "kind relocating"}}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + a := ClaimAssembly{Wake: &ClaimWakeAssembly{Roles: &ClaimRoleAssembly{Root: tc.root}}} |
| + err := a.relocationPreflight(RelocationAssemblyConfig{LaterProbeAttempts: tc.later})(ctx, probing(tc.opened)) |
| + if (tc.want == nil && err != nil) || (tc.want != nil && !errors.Is(err, tc.want)) { |
| + t.Fatalf("err = %v, want %v", err, tc.want) |
| + } |
| + if !slices.Equal(tc.root.asked, tc.asked) { |
| + t.Fatalf("asked root %v, want %v", tc.root.asked, tc.asked) |
| + } |
| + }) |
| + } |
| + if err := (ClaimAssembly{}).relocationPreflight(RelocationAssemblyConfig{})(ctx, probing(false)); !errors.Is(err, activator.ErrClaimIsolationUnavailable) { |
| + t.Fatalf("no roles: %v", err) |
| + } |
| +} |
| + |
| +// Fix round 1, M4 (D5d): Acknowledge's one binding check. Main's |
| +// controller must be for exactly the observed op, incarnation and host; |
| +// any other is ErrLost, never an acknowledgement. |
| +func TestRelocationAcknowledgeBindsTheObservedOperation(t *testing.T) { |
| + ctx := context.Background() |
| + e, obs, _, _ := effectsFixture(t) |
| + ack, err := e.Acknowledge(ctx, obs) |
| + if err != nil || ack.Op != "rel-1" || ack.ControllerID != "c9" || ack.LeaseETag != "lease-1" || ack.Enrollment != "enr" || ack.BootID != "boot" { |
| + t.Fatalf("the bound acknowledgement: %+v %v", ack, err) |
| + } |
| + for name, change := range map[string]func(*execctl.State){ |
| + "another op": func(s *execctl.State) { s.Op = "rel-0" }, |
| + "another incarnation": func(s *execctl.State) { s.Incarnation = "inc-0" }, |
| + "another host": func(s *execctl.State) { s.Host = "other.example" }, |
| + } { |
| + e, obs, _, _ := effectsFixture(t) |
| + inner := e.Participation |
| + e.Participation = func(ctx context.Context, o hostclaim.Observed) (execctl.State, error) { |
| + st, err := inner(ctx, o) |
| + change(&st) |
| + return st, err |
| + } |
| + if ack, err := e.Acknowledge(ctx, obs); !errors.Is(err, execctl.ErrLost) { |
| + t.Fatalf("%s: acknowledged %+v %v", name, ack, err) |
| + } |
| + if err := e.Ready(ctx, obs); !errors.Is(err, execctl.ErrLost) { |
| + t.Fatalf("%s: ready %v", name, err) |
| + } |
| + } |
| +} |
| + |
| +// Fix round 1, M4: validOpID is the one op-id check for a name under the |
| +// data root. Release positions refuse every id it refuses. |
| +func TestValidOpIDRefusesEveryPathThatIsNotOneName(t *testing.T) { |
| + for _, op := range []string{"", ".", "..", "a/b", `a\b`, "a\x00b", "../rel-1", "rel-1/.."} { |
| + if validOpID(op) { |
| + t.Errorf("%q accepted", op) |
| + } |
| + f := ReleasePositionsFile{Dir: t.TempDir()} |
| + r := StopRequest{Host: "shop.example", OperationID: op} |
| + if err := f.Record(context.Background(), ReleasePosition{Request: r, PID: 1, ProcessStart: "1"}); !errors.Is(err, ErrClaimStopUnavailable) { |
| + t.Errorf("%q recorded: %v", op, err) |
| + } |
| + if _, _, err := f.Recorded(context.Background(), r); !errors.Is(err, ErrClaimStopUnavailable) { |
| + t.Errorf("%q read: %v", op, err) |
| + } |
| + } |
| + for _, op := range []string{"rel-1", ".rel", "rel..1", "a.b"} { |
| + if !validOpID(op) { |
| + t.Errorf("%q refused", op) |
| + } |
| + } |
| +} |
| + |
| +// Fix round 1, M4: the release's Collect provider is a recovery request |
| +// (StopRequest.Recover) exactly when the release offers the recovery |
| +// drain, after a restart; the process that flushed asks with its own |
| +// producers and no Recover. |
| +func TestRelocationCollectRecoversAfterARestart(t *testing.T) { |
| + ctx := context.Background() |
| + e, obs, _, _ := effectsFixture(t) |
| + var asked []StopRequest |
| + a := ClaimAssembly{collectStop: func(ctx context.Context, r StopRequest, d StopDrainer) (StopResult, error) { |
| + asked = append(asked, r) |
| + if !r.Recover { |
| + if _, err := d.DrainClaim(ctx, r, "fence"); err != nil { |
| + return StopResult{}, err |
| + } |
| + } |
| + return StopResult{Key: "control/hosts/shop.example/evidence/rel-1/src/r.json", Digest: strings.Repeat("a", 64)}, nil |
| + }} |
| + e.Collect = a.RelocationEffects(RelocationAssemblyConfig{}, nil).Collect |
| + if _, err := e.Release(ctx, obs); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.Identity = func(context.Context) (RelocationIdentity, error) { |
| + return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: "enr", Revision: 4}, BootID: "boot-2", ControllerID: "ctl-2", PID: 200, ProcessStart: "9000"}, nil |
| + } |
| + if _, err := e.Release(ctx, obs); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(asked) != 2 || asked[0].Recover || !asked[1].Recover || asked[1].BootID != "boot" || asked[1].ControllerID != "ctl" { |
| + t.Fatalf("asked root %+v", asked) |
| + } |
| +} |
diff --git a/internal/host/relocationeffects.go b/internal/host/relocationeffects.go| index 0be97f348..960df4242 100644 |
| --- a/internal/host/relocationeffects.go |
| +++ b/internal/host/relocationeffects.go |
| @@ -4,8 +4,6 @@ import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| - "os" |
| - "path/filepath" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| execctl "github.com/carlosframework/platform/internal/hostexec" |
| @@ -20,33 +18,6 @@ import ( |
| // claimLaunchOwner.local rule. |
| type RelocationIdentity = claimStopLocal |
| -// writeFileSynced writes path whole: a synced temporary file renamed into |
| -// place, then the directory synced, so a committed entry survives a crash. |
| -func writeFileSynced(path string, body []byte, mode os.FileMode) error { |
| - tmp := path + ".tmp" |
| - if err := os.WriteFile(tmp, body, mode); err != nil { |
| - return err |
| - } |
| - f, err := os.Open(tmp) |
| - if err != nil { |
| - return err |
| - } |
| - if err := f.Sync(); err != nil { |
| - _ = f.Close() |
| - return err |
| - } |
| - _ = f.Close() |
| - if err := os.Rename(tmp, path); err != nil { |
| - return err |
| - } |
| - dir, err := os.Open(filepath.Dir(path)) |
| - if err != nil { |
| - return err |
| - } |
| - defer dir.Close() |
| - return dir.Sync() |
| -} |
| - |
| // ReleasePosition is one recorded release: the exact stop request, the |
| // process that flushed it (PID and start time, beside the request's boot |
| // and controller), and the positions its flush reached. |
diff --git a/internal/host/relocationflush.go b/internal/host/relocationflush.go| index ef88ff0a1..5a73cf959 100644 |
| --- a/internal/host/relocationflush.go |
| +++ b/internal/host/relocationflush.go |
| @@ -49,6 +49,12 @@ func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (i |
| if h.Generation == "" || h.Location == "" || !controlname.ValidHostShape(obs.Host) { |
| return 0, ErrClaimStopUnavailable |
| } |
| + // No paths is no flush, refused before anything is staged: the host |
| + // has no route (relocationHostPaths), or its route could not be read. |
| + envPath, filesDir := f.EnvPath(obs.Host), f.FilesDir(obs.Host) |
| + if envPath == "" || filesDir == "" { |
| + return 0, fmt.Errorf("%w: no files directory or env file for %s", ErrClaimStopUnavailable, obs.Host) |
| + } |
| p, err := hostfiles.Open(ctx, f.Data, h.Location+"/files/"+h.Generation+"/") |
| if err != nil { |
| return 0, err |
| @@ -60,7 +66,7 @@ func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (i |
| if err := hostfiles.StageWake(ctx, p, ws); err != nil { |
| return 0, err |
| } |
| - env, err := os.ReadFile(f.EnvPath(obs.Host)) |
| + env, err := os.ReadFile(envPath) |
| switch { |
| case err == nil: |
| if err := p.StagePlatform(ctx, hostfiles.EnvItem, env); err != nil { |
| @@ -76,22 +82,36 @@ func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (i |
| return 0, err |
| } |
| // O5 (instance-key plan rev 3): the adoption record beside the env |
| - // file is staged in the same commit when it parses and binds exactly |
| - // the releasing writer; otherwise its deletion is staged, so a stale |
| - // record, or none, is never published as this writer's. |
| + // file is staged in the same commit when it binds exactly the |
| + // releasing writer. A record of another writer (an earlier session or |
| + // generation), or none, has its deletion staged, so it is never |
| + // published as this writer's. A record that is present but does not |
| + // parse refuses the flush: it may be this writer's, and absence is |
| + // never evidence (fix round 1, M9). |
| writer := instancekey.Binding{Host: obs.Host, Incarnation: h.Incarnation, Session: h.Session, Generation: h.Generation} |
| - rec, err := os.ReadFile(filepath.Join(filepath.Dir(f.EnvPath(obs.Host)), obs.Host+instancekey.AdoptionSuffix)) |
| - if err != nil && !errors.Is(err, os.ErrNotExist) { |
| - return 0, err |
| - } |
| - if a, perr := instancekey.ParseAdoption(rec); err == nil && perr == nil && a.Binds(writer) { |
| - if err := p.StagePlatform(ctx, instancekey.AdoptionItem, rec); err != nil { |
| + rec, err := os.ReadFile(filepath.Join(filepath.Dir(envPath), obs.Host+instancekey.AdoptionSuffix)) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + if err := p.StagePlatformDelete(instancekey.AdoptionItem); err != nil { |
| return 0, err |
| } |
| - } else if err := p.StagePlatformDelete(instancekey.AdoptionItem); err != nil { |
| + case err != nil: |
| return 0, err |
| + default: |
| + a, err := instancekey.ParseAdoption(rec) |
| + if err != nil { |
| + return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err) |
| + } |
| + if a.Binds(writer) { |
| + err = p.StagePlatform(ctx, instancekey.AdoptionItem, rec) |
| + } else { |
| + err = p.StagePlatformDelete(instancekey.AdoptionItem) |
| + } |
| + if err != nil { |
| + return 0, err |
| + } |
| } |
| - st, err := hostfiles.SyncDir(ctx, p, f.FilesDir(obs.Host), f.Skip) |
| + st, err := hostfiles.SyncDir(ctx, p, filesDir, f.Skip) |
| if err != nil { |
| return 0, err |
| } |
| @@ -144,31 +164,9 @@ func (f ReleasePositionsFile) Record(_ context.Context, p ReleasePosition) error |
| if err != nil { |
| return err |
| } |
| - tmp := path + ".tmp" |
| - w, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) |
| - if err != nil { |
| - return err |
| - } |
| - if _, err := w.Write(raw); err != nil { |
| - _ = w.Close() |
| - return err |
| - } |
| - if err := w.Sync(); err != nil { |
| - _ = w.Close() |
| - return err |
| - } |
| - if err := w.Close(); err != nil { |
| - return err |
| - } |
| - if err := os.Rename(tmp, path); err != nil { |
| - return err |
| - } |
| - dir, err := os.Open(f.Dir) |
| - if err != nil { |
| - return err |
| - } |
| - defer dir.Close() |
| - return dir.Sync() |
| + // Moves' atomic write (moveslandinstall.go): a synced temporary in |
| + // the same directory, renamed into place, the directory synced. |
| + return writeFileAtomic(path, raw, 0o600) |
| } |
| // sameStop is the installation and session, not the controller. |
diff --git a/internal/host/relocationflush_adoption_test.go b/internal/host/relocationflush_adoption_test.go| index eda94903d..35417a975 100644 |
| --- a/internal/host/relocationflush_adoption_test.go |
| +++ b/internal/host/relocationflush_adoption_test.go |
| @@ -2,6 +2,7 @@ package host |
| import ( |
| "context" |
| + "errors" |
| "os" |
| "path/filepath" |
| "testing" |
| @@ -15,20 +16,23 @@ import ( |
| // O5 (instance-key plan rev 3): the release flush publishes the adoption |
| // record in the same commit as the env item only when it binds exactly the |
| -// releasing writer; a stale record, or none, publishes none. |
| +// releasing writer; a stale record, or none, publishes none. A record that |
| +// does not parse refuses the flush (fix round 1, M9): it may be this |
| +// writer's, and absence is never evidence. |
| func TestReleaseFlushPublishesOnlyTheReleasingWritersAdoption(t *testing.T) { |
| ctx := context.Background() |
| const host = "shop.example" |
| writer := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 6, Generation: "g6"} |
| for _, tc := range []struct { |
| - name string |
| - record []byte // nil is no file |
| - want bool |
| + name string |
| + record []byte // nil is no file |
| + want bool |
| + refuses bool |
| }{ |
| - {"the releasing writer's record", instancekey.AdoptionRecord(writer, instancekey.SourcePlatform, instancekey.Digest("k1")), true}, |
| - {"a stale record from session 5", instancekey.AdoptionRecord(instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 5, Generation: "g5"}, instancekey.SourcePlatform, instancekey.Digest("k1")), false}, |
| - {"a malformed record", []byte(`{"v":1}`), false}, |
| - {"no record", nil, false}, |
| + {"the releasing writer's record", instancekey.AdoptionRecord(writer, instancekey.SourcePlatform, instancekey.Digest("k1")), true, false}, |
| + {"a stale record from session 5", instancekey.AdoptionRecord(instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 5, Generation: "g5"}, instancekey.SourcePlatform, instancekey.Digest("k1")), false, false}, |
| + {"a malformed record", []byte(`{"v":1}`), false, true}, |
| + {"no record", nil, false, false}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| data := release.NewDirStore(t.TempDir()) |
| @@ -52,6 +56,15 @@ func TestReleaseFlushPublishesOnlyTheReleasingWritersAdoption(t *testing.T) { |
| f := ReleaseFlushFiles{Data: data, Registry: reg, FilesDir: func(string) string { return dir }, EnvPath: func(h string) string { return filepath.Join(envDir, h+".env") }} |
| obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host, Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Incarnation: "inc-1", Session: 6, Generation: "g6"}}} |
| seq, err := f.Flush(ctx, obs) |
| + if tc.refuses { |
| + if !errors.Is(err, ErrClaimStopUnavailable) { |
| + t.Fatalf("a malformed record was flushed: %v", err) |
| + } |
| + if _, ok, err := hostfiles.Load(ctx, data, "instances/"+host+"/inc/files/g6/"); ok || err != nil { |
| + t.Fatalf("a refused flush committed a manifest: %v %v", ok, err) |
| + } |
| + return |
| + } |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -70,3 +83,29 @@ func TestReleaseFlushPublishesOnlyTheReleasingWritersAdoption(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// Fix round 1, M6: a host with no paths (no route, or a route that could |
| +// not be read) is refused before anything is staged, never flushed with an |
| +// empty env path read as "no env file". |
| +func TestReleaseFlushRefusesAHostWithNoPaths(t *testing.T) { |
| + ctx := context.Background() |
| + const host = "shop.example" |
| + data := release.NewDirStore(t.TempDir()) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + paths := relocationHostPaths(t.TempDir(), reg) |
| + if p := paths(host); p.FilesDir != "" || p.EnvPath != "" { |
| + t.Fatalf("paths without a route: %+v", p) |
| + } |
| + f := ReleaseFlushFiles{Data: data, Registry: reg, FilesDir: func(h string) string { return paths(h).FilesDir }, EnvPath: func(h string) string { return paths(h).EnvPath }} |
| + obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host, Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Incarnation: "inc-1", Session: 6, Generation: "g6"}}} |
| + if _, err := f.Flush(ctx, obs); !errors.Is(err, ErrClaimStopUnavailable) { |
| + t.Fatalf("flushed with no paths: %v", err) |
| + } |
| + if _, ok, err := hostfiles.Load(ctx, data, "instances/"+host+"/inc/files/g6/"); ok || err != nil { |
| + t.Fatalf("a refused flush committed a manifest: %v %v", ok, err) |
| + } |
| +} |
diff --git a/internal/host/relocationsweep.go b/internal/host/relocationsweep.go| index ae550ac6a..fdbd84ea8 100644 |
| --- a/internal/host/relocationsweep.go |
| +++ b/internal/host/relocationsweep.go |
| @@ -4,23 +4,53 @@ import ( |
| "context" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| + "log" |
| "os" |
| "path/filepath" |
| "strings" |
| + "sync" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// sweepRelocationJournals is the lifecycle half of N9 (ruling 61): every |
| -// region HA journal under <DataDir>/relocate names its host inside and its |
| -// op in its path, and is removed once the host's claim no longer runs that |
| -// op (completed and marked discharged, replaced by a successor, or the |
| -// claim gone). Positions, |
| -// attempts and install journals are needed only while their op runs; |
| -// the build scratch only while its op is installing. A journal that |
| -// cannot be read is left in place and reported: it is census-opaque, so |
| -// it blocks nothing, and absence is never evidence. |
| +// relocationOpJournals are the files under relocate/ops/<op>/ that name |
| +// their host inside: the install and attempt journals and the probe stop's |
| +// fence owner. |
| +var relocationOpJournals = []string{"install.json", "attempts.json", "probe-fence.json"} |
| + |
| +// relocationStraySaid throttles the sweep's line about an entry under |
| +// relocate/ that is no relocation journal to once per path per process. |
| +var relocationStraySaid sync.Map |
| + |
| +func sayRelocationStray(path string) { |
| + if _, said := relocationStraySaid.LoadOrStore(path, true); !said { |
| + log.Printf("relocation sweep: %s is not a relocation journal; left in place for an operator", path) |
| + } |
| +} |
| + |
| +// sweepRelocationJournals is the lifecycle half of N9 (ruling 61). Every |
| +// region HA journal under <DataDir>/relocate names its op in its path and |
| +// its host inside (the build scratch names its host in its file name): the |
| +// install, attempt and probe-fence journals under ops/<op>/, the release |
| +// positions at release/<op>.json and the build's scratch under build/<op>/. |
| +// Each is removed once the host's claim no longer runs that op: replaced |
| +// by a successor, the claim gone, or completed and marked discharged (a |
| +// completed op's journals stay until that mark, ruling 76). While the op |
| +// runs, every one of them stays. |
| +// |
| +// A journal that cannot be read is left in place and reported, and so is |
| +// a claim whose completed relocation cannot be read: both are |
| +// census-opaque, so they block nothing, and absence is never evidence. |
| +// What no committed journal can be is removed: an ops directory holding |
| +// no host-bearing journal, an atomic write's leftover temporary under |
| +// release/, a build directory left empty. An entry the sweep does not know |
| +// is left in place and logged once. |
| +// |
| +// The caller runs it once per pass, between the pass's own steps, never |
| +// beside a relocation journal write: an ops directory and a temporary are |
| +// only ever a write's leftovers then. |
| func sweepRelocationJournals(ctx context.Context, dataDir string, claims release.Store) error { |
| root := filepath.Join(dataDir, "relocate") |
| var errs []error |
| @@ -35,7 +65,13 @@ func sweepRelocationJournals(ctx context.Context, dataDir string, claims release |
| // Ruling 76: a completed relocation's journals, the probe stop's |
| // fence owner among them, stay until the box it completed on has |
| // discharged, released its hold, lifted its fence and marked it. |
| - if done, ok := hostclaim.ReadCompletedRelocation(obs.Claim); ok && done.Op == op && !done.Discharged { |
| + // A record that cannot be read keeps every journal: it may be this |
| + // op's, and absence is never evidence. |
| + done, ok, err := hostclaim.ReadCompletedRelocation(obs.Claim) |
| + if err != nil { |
| + return false, fmt.Errorf("%s: %w", host, err) |
| + } |
| + if ok && done.Op == op && !done.Discharged { |
| return false, nil |
| } |
| return obs.Claim.Op.ID != op, nil |
| @@ -50,87 +86,139 @@ func sweepRelocationJournals(ctx context.Context, dataDir string, claims release |
| Request struct{ Host string } |
| } |
| if err := json.Unmarshal(raw, &probe); err != nil { |
| - return "", err |
| + return "", fmt.Errorf("%s: %w", path, err) |
| } |
| if probe.Host != "" { |
| return probe.Host, nil |
| } |
| - return probe.Request.Host, nil |
| + if probe.Request.Host != "" { |
| + return probe.Request.Host, nil |
| + } |
| + return "", fmt.Errorf("%s names no host", path) |
| } |
| - // ops/<op>/{install,attempts}.json |
| + // ops/<op>/{install,attempts,probe-fence}.json |
| ops, err := os.ReadDir(filepath.Join(root, "ops")) |
| if err != nil && !errors.Is(err, os.ErrNotExist) { |
| return err |
| } |
| for _, e := range ops { |
| dir := filepath.Join(root, "ops", e.Name()) |
| - host, readErr := "", error(nil) |
| - for _, name := range []string{"install.json", "attempts.json", "probe-fence.json"} { |
| + if !e.IsDir() { |
| + sayRelocationStray(dir) |
| + continue |
| + } |
| + host, found, readErr := "", false, error(nil) |
| + for _, name := range relocationOpJournals { |
| h, err := hostOf(filepath.Join(dir, name)) |
| - if err == nil && h != "" { |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + found = true |
| + if err == nil { |
| host = h |
| break |
| } |
| - if err != nil && !errors.Is(err, os.ErrNotExist) { |
| - readErr = errors.Join(readErr, err) |
| - } |
| - } |
| - if host == "" { |
| - errs = append(errs, readErr) |
| - continue |
| + readErr = errors.Join(readErr, err) |
| } |
| - if done, err := gone(host, e.Name()); err != nil { |
| - errs = append(errs, err) |
| - } else if done { |
| + switch { |
| + case host != "": |
| + if done, err := gone(host, e.Name()); err != nil { |
| + errs = append(errs, err) |
| + } else if done { |
| + errs = append(errs, os.RemoveAll(dir)) |
| + } |
| + case found: |
| + errs = append(errs, readErr) // unreadable: kept and reported |
| + default: |
| + // No host-bearing journal was ever committed here: what is |
| + // left is a write's temporary, which no op can need. |
| errs = append(errs, os.RemoveAll(dir)) |
| } |
| } |
| - // release/<op>.json |
| + // release/<op>.json, and an atomic write's leftover temporaries. |
| positions, err := os.ReadDir(filepath.Join(root, "release")) |
| if err != nil && !errors.Is(err, os.ErrNotExist) { |
| - return err |
| + return errors.Join(append(errs, err)...) |
| } |
| for _, e := range positions { |
| - op, ok := strings.CutSuffix(e.Name(), ".json") |
| - if !ok { |
| - continue |
| - } |
| path := filepath.Join(root, "release", e.Name()) |
| - host, err := hostOf(path) |
| - if err != nil || host == "" { |
| - errs = append(errs, err) |
| - continue |
| - } |
| - if done, err := gone(host, op); err != nil { |
| - errs = append(errs, err) |
| - } else if done { |
| + op, isJSON := strings.CutSuffix(e.Name(), ".json") |
| + switch { |
| + case e.IsDir(): |
| + sayRelocationStray(path) |
| + case isJSON: |
| + host, err := hostOf(path) |
| + if err != nil { |
| + errs = append(errs, err) |
| + continue |
| + } |
| + if done, err := gone(host, op); err != nil { |
| + errs = append(errs, err) |
| + } else if done { |
| + errs = append(errs, os.Remove(path)) |
| + } |
| + case relocationWriteLeftover(e.Name()): |
| errs = append(errs, os.Remove(path)) |
| + default: |
| + sayRelocationStray(path) |
| } |
| } |
| - // build/<op>/<host>.db: the scratch of a build whose op is over. |
| + // build/<op>/<host>.db and its SQLite sidecars: the scratch of a build |
| + // whose op is over. |
| builds, err := os.ReadDir(filepath.Join(root, "build")) |
| if err != nil && !errors.Is(err, os.ErrNotExist) { |
| return errors.Join(append(errs, err)...) |
| } |
| for _, e := range builds { |
| dir := filepath.Join(root, "build", e.Name()) |
| + if !e.IsDir() { |
| + sayRelocationStray(dir) |
| + continue |
| + } |
| files, err := os.ReadDir(dir) |
| if err != nil { |
| errs = append(errs, err) |
| continue |
| } |
| + host := "" |
| for _, f := range files { |
| - host, ok := strings.CutSuffix(f.Name(), ".db") |
| - if !ok { |
| - continue |
| + if h, ok := buildScratchHost(f.Name()); ok { |
| + host = h |
| + break |
| } |
| + } |
| + switch { |
| + case host != "": |
| if done, err := gone(host, e.Name()); err != nil { |
| errs = append(errs, err) |
| } else if done { |
| errs = append(errs, os.RemoveAll(dir)) |
| } |
| - break |
| + case len(files) == 0: |
| + errs = append(errs, os.Remove(dir)) |
| + default: |
| + sayRelocationStray(dir) |
| } |
| } |
| return errors.Join(errs...) |
| } |
| + |
| +// relocationWriteLeftover is a temporary an interrupted atomic write left |
| +// under release/: writeFileAtomic's ".<op>.json.<random>", or a fixed |
| +// "<op>.json.tmp". |
| +func relocationWriteLeftover(name string) bool { |
| + return strings.HasSuffix(name, ".json.tmp") || (strings.HasPrefix(name, ".") && strings.Contains(name, ".json.")) |
| +} |
| + |
| +// buildScratchHost is the host a build scratch file names: <host>.db, or |
| +// one of SQLite's sidecars beside it (-wal, -shm, -journal). |
| +func buildScratchHost(name string) (string, bool) { |
| + for _, sidecar := range []string{"-wal", "-shm", "-journal"} { |
| + if base, ok := strings.CutSuffix(name, sidecar); ok { |
| + name = base |
| + break |
| + } |
| + } |
| + host, ok := strings.CutSuffix(name, ".db") |
| + return host, ok && host != "" |
| +} |
diff --git a/internal/host/relocationsweep_test.go b/internal/host/relocationsweep_test.go| index 4f36ce4cd..83e4f14e4 100644 |
| --- a/internal/host/relocationsweep_test.go |
| +++ b/internal/host/relocationsweep_test.go |
| @@ -3,6 +3,7 @@ package host |
| import ( |
| "encoding/json" |
| "errors" |
| + "log" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -153,3 +154,121 @@ func TestRelocationJournalsOfACompletedOpStayUntilItsDischarge(t *testing.T) { |
| t.Fatalf("the discharged op's journals survived: %v", err) |
| } |
| } |
| + |
| +// sweepClaim is shop.example's claim, adopted on "dst", with its op |
| +// replaced by op. |
| +func sweepClaim(t *testing.T, op hostclaim.Op) *release.DirStore { |
| + t.Helper() |
| + ctx := t.Context() |
| + store := release.NewDirStore(t.TempDir()) |
| + created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) |
| + record := provision.Record{Schema: 1, Host: "shop.example", Slug: "shop", App: "app", Account: "acct", Region: "ie", Channel: "stable", CreatedAt: created} |
| + if err := provision.CreateRecord(ctx, store, record); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Read(ctx, store, record.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err = hostclaim.Update(ctx, store, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: provision.RecordKey(record.Account, record.App, record.Host), |
| + Account: record.Account, App: record.App, Host: record.Host, Region: record.Region, CreatedAt: created}, "dst", nil, nil, "adopt", func() string { return "id" })) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(ctx, store, obs, func(c *hostclaim.Claim) error { c.Op = op; return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + return store |
| +} |
| + |
| +func writeSweepFiles(t *testing.T, data string, files map[string]string) { |
| + t.Helper() |
| + for path, body := range files { |
| + full := filepath.Join(data, filepath.FromSlash(path)) |
| + if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(full, []byte(body), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +// Fix round 1, I1: a serving claim whose completed relocation cannot be |
| +// read keeps every journal of its host, the probe stop's fence owner |
| +// included, and the sweep reports it: it may be that op's, and absence is |
| +// never evidence. |
| +func TestRelocationJournalsStayWhileTheCompletionCannotBeRead(t *testing.T) { |
| + for name, raw := range map[string]string{"unparseable": `{"op":"rel-done",`, "no box": `{"op":"rel-done","stop":{"session":12}}`} { |
| + t.Run(name, func(t *testing.T) { |
| + store := sweepClaim(t, hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}, Journal: map[string]string{hostclaim.JournalCompleted: raw}}) |
| + data := t.TempDir() |
| + files := map[string]string{ |
| + "relocate/ops/rel-done/install.json": `{"v":1,"host":"shop.example","op":"rel-done"}`, |
| + "relocate/ops/rel-done/probe-fence.json": `{"host":"shop.example","session":12,"owner":"0123"}`, |
| + "relocate/release/rel-done.json": `{"request":{"Host":"shop.example"}}`, |
| + "relocate/build/rel-done/shop.example.db": "scratch", |
| + } |
| + writeSweepFiles(t, data, files) |
| + if err := sweepRelocationJournals(t.Context(), data, store); !errors.Is(err, hostclaim.ErrRelocation) { |
| + t.Fatalf("an unreadable completion was not reported: %v", err) |
| + } |
| + for path := range files { |
| + if _, err := os.Stat(filepath.Join(data, filepath.FromSlash(path))); err != nil { |
| + t.Errorf("%s was swept over an unreadable completion: %v", path, err) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Fix round 1, M5: what no committed journal can be is removed (an ops |
| +// directory with no host-bearing journal, an atomic write's leftover |
| +// temporaries under release/, a build directory holding only SQLite |
| +// sidecars of an ended op, or nothing); the live op's sidecar stays; an |
| +// entry the sweep does not know stays and is logged once, not every pass, |
| +// and is no error. |
| +func TestRelocationSweepRemovesLeftoversAndLogsAStrayOnce(t *testing.T) { |
| + store := sweepClaim(t, hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-live", Phase: hostclaim.PhaseInstalling, Exec: "dst", Participants: []string{"src", "dst"}, |
| + Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "dst"}}) |
| + data := t.TempDir() |
| + writeSweepFiles(t, data, map[string]string{ |
| + "relocate/ops/rel-tmp/.install.json.123456": `{"v":1,"host":`, |
| + "relocate/release/rel-x.json.tmp": `{"request":`, |
| + "relocate/release/.rel-y.json.987654": `{"req`, |
| + "relocate/build/rel-wal/shop.example.db-wal": "wal", |
| + "relocate/build/rel-jrn/shop.example.db-journal": "journal", |
| + "relocate/build/rel-live/shop.example.db-wal": "wal", |
| + "relocate/build/stray.txt": "?", |
| + "relocate/release/notes.txt": "?", |
| + }) |
| + for _, dir := range []string{"relocate/ops/rel-empty", "relocate/build/rel-empty"} { |
| + if err := os.MkdirAll(filepath.Join(data, filepath.FromSlash(dir)), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + var logged strings.Builder |
| + log.SetOutput(&logged) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + for pass := 0; pass < 2; pass++ { |
| + if err := sweepRelocationJournals(t.Context(), data, store); err != nil { |
| + t.Fatalf("pass %d: %v", pass, err) |
| + } |
| + } |
| + for _, gone := range []string{"relocate/ops/rel-tmp", "relocate/ops/rel-empty", "relocate/release/rel-x.json.tmp", "relocate/release/.rel-y.json.987654", |
| + "relocate/build/rel-wal", "relocate/build/rel-jrn", "relocate/build/rel-empty"} { |
| + if _, err := os.Stat(filepath.Join(data, filepath.FromSlash(gone))); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("%s survived the sweep: %v", gone, err) |
| + } |
| + } |
| + for _, kept := range []string{"relocate/build/rel-live/shop.example.db-wal", "relocate/build/stray.txt", "relocate/release/notes.txt"} { |
| + if _, err := os.Stat(filepath.Join(data, filepath.FromSlash(kept))); err != nil { |
| + t.Errorf("%s was removed: %v", kept, err) |
| + } |
| + } |
| + for _, stray := range []string{"stray.txt", "notes.txt"} { |
| + if n := strings.Count(logged.String(), stray); n != 1 { |
| + t.Errorf("%s logged %d times over two passes, want once: %q", stray, n, logged.String()) |
| + } |
| + } |
| +} |
diff --git a/internal/hostclaim/relocation.go b/internal/hostclaim/relocation.go| index b6b9d3e03..9d9d71348 100644 |
| --- a/internal/hostclaim/relocation.go |
| +++ b/internal/hostclaim/relocation.go |
| @@ -165,9 +165,11 @@ type LocalInstall struct { |
| type Probe struct { |
| Box string `json:"box"` |
| Session uint64 `json:"session"` |
| - // At is when the probe passed (rev 4, ruling 67): the start of the |
| - // bound on obtaining its stop receipt. Zero in a probe journaled before |
| - // rev 4; the worker then counts from probing_since. |
| + // At is when the probe passed (rev 4, ruling 67): the worker marks the |
| + // op stuck ReleaseDeadline after At if no stop receipt is journaled. |
| + // Zero in a probe journaled before rev 4: the worker then takes the |
| + // probe as passed at probing_since plus ProbeDeadline, the latest it |
| + // can have passed, and counts ReleaseDeadline from there. |
| At time.Time `json:"at,omitzero"` |
| } |
| @@ -1080,7 +1082,7 @@ func CompleteRelocation(opID string, newID func() string) func(*Claim) error { |
| if c.Op.Journal["conversion"] == "committed-landing" { |
| // M1 on amadan #236: the move this relocation finished is named |
| // on the serving op, so moves' close can find it. |
| - next.Journal["released_move"] = opID |
| + next.Journal[journalReleasedMove] = opID |
| } |
| c.Op = next |
| return nil |
diff --git a/internal/hostclaim/relocation_probestop.go b/internal/hostclaim/relocation_probestop.go| index 31ebf7051..a2fa9c528 100644 |
| --- a/internal/hostclaim/relocation_probestop.go |
| +++ b/internal/hostclaim/relocation_probestop.go |
| @@ -1,6 +1,7 @@ |
| package hostclaim |
| import ( |
| + "encoding/json" |
| "fmt" |
| "slices" |
| "time" |
| @@ -74,13 +75,28 @@ type CompletedRelocation struct { |
| // JournalCompleted is the serving op's journal key for CompletedRelocation. |
| const JournalCompleted = "completed_relocation" |
| -// ReadCompletedRelocation reads the serving op's record; false when none. |
| -func ReadCompletedRelocation(c *Claim) (CompletedRelocation, bool) { |
| +// ReadCompletedRelocation reads the serving op's record. ok is false, with |
| +// no error, only when there is none: the op is not serving, or its journal |
| +// has no JournalCompleted entry. An entry that is present but does not |
| +// decode, or names no op, box or session, is an error, never "none" |
| +// (absence is never evidence): the barrier refuses over it and the sweep |
| +// keeps the op's journals. |
| +func ReadCompletedRelocation(c *Claim) (CompletedRelocation, bool, error) { |
| + if c == nil || c.Op.Kind != KindServing { |
| + return CompletedRelocation{}, false, nil |
| + } |
| + raw, present := c.Op.Journal[JournalCompleted] |
| + if !present { |
| + return CompletedRelocation{}, false, nil |
| + } |
| var r CompletedRelocation |
| - if c == nil || c.Op.Kind != KindServing || !readJournal(c.Op, JournalCompleted, &r) || r.Op == "" || r.Stop.Box == "" || r.Stop.Session == 0 { |
| - return CompletedRelocation{}, false |
| + if err := json.Unmarshal([]byte(raw), &r); err != nil { |
| + return CompletedRelocation{}, false, fmt.Errorf("%w: the completed relocation cannot be read: %v", ErrRelocation, err) |
| + } |
| + if r.Op == "" || r.Stop.Box == "" || r.Stop.Session == 0 { |
| + return CompletedRelocation{}, false, fmt.Errorf("%w: the completed relocation names no op, box or session", ErrRelocation) |
| } |
| - return r, true |
| + return r, true, nil |
| } |
| // completionBarrier is region HA ruling 77, the one barrier every start |
| @@ -91,9 +107,14 @@ func ReadCompletedRelocation(c *Claim) (CompletedRelocation, bool) { |
| // successor operation would answer ErrLost, so nothing starts. A |
| // completion box on lost, or the one the start itself cites as fenced |
| // (fencedSource, a failover's source), holds nothing a successor needs: a |
| -// dead box's slot binds nothing on another box. |
| +// dead box's slot binds nothing on another box. A record that cannot be |
| +// read refuses every start, a failover's included: which box it binds is |
| +// unknown. |
| func completionBarrier(c *Claim, fencedSource string) error { |
| - done, ok := ReadCompletedRelocation(c) |
| + done, ok, err := ReadCompletedRelocation(c) |
| + if err != nil { |
| + return err |
| + } |
| if !ok || done.Discharged || slices.Contains(c.Lost, done.Stop.Box) || (fencedSource != "" && fencedSource == done.Stop.Box) { |
| return nil |
| } |
| @@ -104,10 +125,13 @@ func completionBarrier(c *Claim, fencedSource string) error { |
| // relocation opID completed on has discharged its participation. It |
| // changes nothing else; a repeat is ErrAlreadyRecorded. A serving op with |
| // no completed relocation is ErrWrongState, and one that records another |
| -// op is ErrRelocation. |
| +// op, or a record that cannot be read, is ErrRelocation. |
| func MarkCompletedDischarged(opID string) func(*Claim) error { |
| return func(c *Claim) error { |
| - r, ok := ReadCompletedRelocation(c) |
| + r, ok, err := ReadCompletedRelocation(c) |
| + if err != nil { |
| + return err |
| + } |
| if !ok { |
| return fmt.Errorf("%w: no completed relocation on the serving op", ErrWrongState) |
| } |
diff --git a/internal/hostclaim/relocation_probestop_test.go b/internal/hostclaim/relocation_probestop_test.go| index 9ce573b59..914e4f792 100644 |
| --- a/internal/hostclaim/relocation_probestop_test.go |
| +++ b/internal/hostclaim/relocation_probestop_test.go |
| @@ -108,7 +108,7 @@ func TestCompleteRelocationRequiresTheProbeStop(t *testing.T) { |
| t.Fatalf("a second, different stop: %v", err) |
| } |
| o = apply(CompleteRelocation("rel-1", counter())) |
| - done, ok := ReadCompletedRelocation(o.Claim) |
| + done, ok, _ := ReadCompletedRelocation(o.Claim) |
| if !ok || done.Op != "rel-1" || done.Stop != stop || done.Stamp != o.Claim.Holder.Stamp || done.Phase != PhaseRelocatingProbing || done.Generation != "P1" { |
| t.Fatalf("completed relocation: %+v %v", done, ok) |
| } |
| @@ -127,7 +127,7 @@ func TestCompleteRelocationJournalsReleasedMoveForAConvertedLanding(t *testing.T |
| if o.Claim.Op.Journal["released_move"] != "rel-1" { |
| t.Fatalf("serving op journal: %v", o.Claim.Op.Journal) |
| } |
| - if _, ok := ReadCompletedRelocation(o.Claim); !ok { |
| + if _, ok, err := ReadCompletedRelocation(o.Claim); !ok || err != nil { |
| t.Fatal("no completed relocation beside the released move") |
| } |
| } |
| @@ -190,7 +190,7 @@ func TestRelocationSuccessorWaitsForTheCompletedDischarge(t *testing.T) { |
| if _, err := Update(ctx, s, o, MarkCompletedDischarged("rel-1")); !errors.Is(err, ErrAlreadyRecorded) { |
| t.Fatalf("a repeat: %v", err) |
| } |
| - if done, ok := ReadCompletedRelocation(o.Claim); !ok || !done.Discharged || done.Stop != stopOf(o) { |
| + if done, ok, err := ReadCompletedRelocation(o.Claim); err != nil || !ok || !done.Discharged || done.Stop != stopOf(o) { |
| t.Fatalf("the mark changed the record: %+v", done) |
| } |
| next, err := StartRelocation(ctx, s, o, successor(ReasonDrain, nil), relAuthority{}) |
| @@ -227,7 +227,7 @@ func completedDrain(t *testing.T) (release.Store, Observed) { |
| s, o := probedDrainIn(t) |
| o = step(t, s, o, RecordProbeStop("rel-1", stopOf(o))) |
| o = step(t, s, o, CompleteRelocation("rel-1", counter())) |
| - if done, ok := ReadCompletedRelocation(o.Claim); !ok || done.Discharged || o.Claim.Op.Exec != relTarget { |
| + if done, ok, err := ReadCompletedRelocation(o.Claim); err != nil || !ok || done.Discharged || o.Claim.Op.Exec != relTarget { |
| t.Fatalf("A to B did not complete undischarged: %+v %v", done, ok) |
| } |
| return s, o |
| @@ -265,3 +265,48 @@ func TestMoveSuccessorWaitsForTheCompletedDischarge(t *testing.T) { |
| t.Fatalf("move op %+v", next.Claim.Op) |
| } |
| } |
| + |
| +// Fix round 1, I1: a completed_relocation entry that is present but cannot |
| +// be read is an error, never "none". Every start from serving refuses over |
| +// it (a failover citing a fence included, since which box the record binds |
| +// is unknown), the discharge mark refuses, and the claim is left as it is. |
| +func TestAnUnreadableCompletedRelocationRefusesEveryStart(t *testing.T) { |
| + ctx := context.Background() |
| + for name, raw := range map[string]string{ |
| + "unparseable": `{"op":"rel-1","stop":`, |
| + "no box": `{"op":"rel-1","stop":{"session":4,"receipt":"fence-1"}}`, |
| + "no session": `{"op":"rel-1","stop":{"box":"` + relTarget + `","receipt":"fence-1"}}`, |
| + "no op": `{"stop":{"box":"` + relTarget + `","session":4,"receipt":"fence-1"}}`, |
| + "empty": ``, |
| + "another shape": `["rel-1"]`, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + s, o := completedDrain(t) |
| + o = step(t, s, o, func(c *Claim) error { c.Op.Journal[JournalCompleted] = raw; return nil }) |
| + if _, ok, err := ReadCompletedRelocation(o.Claim); ok || !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("read as %v, %v", ok, err) |
| + } |
| + successor := RelocationStart{OpID: "rel-2", By: "worker", Now: relObserved.Add(time.Hour), Region: "ie", Reason: ReasonDrain, |
| + SourceBox: relTarget, SourceEnrollment: relEnrollment(relTarget), TargetBox: relSpare, TargetEnrollment: relEnrollment(relSpare), |
| + DirectoryRevision: 9, AdmissionID: "adm-2"} |
| + if _, err := StartRelocation(ctx, s, o, successor, relAuthority{}); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("a drain started over an unreadable completion: %v", err) |
| + } |
| + ref := putFence(t, s, relTarget) |
| + failover := successor |
| + failover.Reason, failover.Fence = ReasonFailover, &ref |
| + if _, err := StartRelocation(ctx, s, o, failover, relAuthority{}); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("a failover started over an unreadable completion: %v", err) |
| + } |
| + if err := StartGuard(copyOf(t, o.Claim), []string{"uk"}, []string{relSpare}); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("StartGuard: %v", err) |
| + } |
| + if err := MarkCompletedDischarged("rel-1")(copyOf(t, o.Claim)); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("the discharge mark over an unreadable completion: %v", err) |
| + } |
| + if now, err := Read(ctx, s, o.Claim.Host); err != nil || now.ETag != o.ETag { |
| + t.Fatalf("a refused start wrote the claim: %v", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostclaim/relocation_test.go b/internal/hostclaim/relocation_test.go| index 783ce9d3e..2359995d4 100644 |
| --- a/internal/hostclaim/relocation_test.go |
| +++ b/internal/hostclaim/relocation_test.go |
| @@ -279,7 +279,7 @@ func TestRelocationFailoverCitesTheFenceAndDeclaresTheLoss(t *testing.T) { |
| l.Session != sourceSession || l.Generation != "g1" || l.Discharge.Qualified != qualified || l.Timers != TimersUnknown || l.Reported { |
| t.Fatalf("retained loss: %+v", l) |
| } |
| - done, ok := ReadCompletedRelocation(c) |
| + done, ok, _ := ReadCompletedRelocation(c) |
| if len(c.Op.Journal) != 1 || !ok || done.Op != "rel-1" || done.Stop.Session != c.Holder.Session || done.Phase != PhaseRelocatingProbing || !slices.Contains(c.Lost, relSource) { |
| t.Fatalf("serving kept the journal, or lost the box: %+v %v", c.Op, c.Lost) |
| } |
diff --git a/internal/hostrelocate/participant_gate_test.go b/internal/hostrelocate/participant_gate_test.go| index 273a9b467..ddec620a5 100644 |
| --- a/internal/hostrelocate/participant_gate_test.go |
| +++ b/internal/hostrelocate/participant_gate_test.go |
| @@ -161,3 +161,52 @@ func TestParticipantRecordsNoProbeStopWhileRootRefuses(t *testing.T) { |
| t.Fatalf("the stop once root answered: %v", err) |
| } |
| } |
| + |
| +// Fix round 1, M4 (ruling 62): sameInstallation accepts a fresh read that |
| +// differs only in an effect's own bookkeeping, and refuses one whose |
| +// installation moved: another stamp, install stamp, prepared P, session |
| +// or phase, or a stuck mark. (The claim store itself refuses a changed |
| +// incarnation, or an installed op's executor moved off its target.) |
| +func TestSameInstallationRefusesAMovedInstallation(t *testing.T) { |
| + ctx := context.Background() |
| + installed := func(t *testing.T) (*fixture, hostclaim.Observed, Participant) { |
| + f := newFixture(t) |
| + releasedAndInstalling(t, f) |
| + f.worker(ErrNotYet) // P is not built yet |
| + p := f.claim().Claim.Op.Journal["prepared"] |
| + f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.built"] = p; return nil }) |
| + if o := f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseInstalled { |
| + t.Fatalf("not installed: %s", o.Claim.Op.Phase) |
| + } |
| + return f, f.claim(), Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: &fakeBox{f: f, box: "dst"}, Now: f.clock} |
| + } |
| + t.Run("the effect's own bookkeeping", func(t *testing.T) { |
| + f, obs, p := installed(t) |
| + f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.inventory"] = "dst"; return nil }) |
| + fresh, err := p.sameInstallation(ctx, obs, hostclaim.PhaseInstalled) |
| + if err != nil || fresh.ETag == obs.ETag || fresh.Claim.Op.Journal["test.inventory"] != "dst" { |
| + t.Fatalf("a fresh read of the same installation: %v", err) |
| + } |
| + }) |
| + for name, change := range map[string]func(*hostclaim.Claim){ |
| + "another stamp": func(c *hostclaim.Claim) { c.Holder.Stamp++ }, |
| + "another install stamp": func(c *hostclaim.Claim) { c.Op.Journal["install_stamp"] += "0" }, |
| + "another prepared P": func(c *hostclaim.Claim) { c.Op.Journal["prepared"] = "P9" }, |
| + "another session": func(c *hostclaim.Claim) { c.Holder.Session++ }, |
| + "stuck": func(c *hostclaim.Claim) { c.Op.Journal["stuck"] = "operator" }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f, obs, p := installed(t) |
| + f.participant(func(c *hostclaim.Claim) error { change(c); return nil }) |
| + if _, err := p.sameInstallation(ctx, obs, hostclaim.PhaseInstalled); !errors.Is(err, ErrRefused) || !errors.Is(err, ErrNotYet) { |
| + t.Fatalf("a moved installation was accepted: %v", err) |
| + } |
| + }) |
| + } |
| + t.Run("another phase", func(t *testing.T) { |
| + _, obs, p := installed(t) |
| + if _, err := p.sameInstallation(ctx, obs, hostclaim.PhaseRelocatingProbing); !errors.Is(err, ErrRefused) { |
| + t.Fatalf("another phase was accepted: %v", err) |
| + } |
| + }) |
| +} |
diff --git a/internal/hostrelocate/participant_test.go b/internal/hostrelocate/participant_test.go| index 06d151f40..7ccba9449 100644 |
| --- a/internal/hostrelocate/participant_test.go |
| +++ b/internal/hostrelocate/participant_test.go |
| @@ -65,7 +65,8 @@ func (b *fakeBox) Finish(ctx context.Context, h, op string) error { |
| if err != nil { |
| return err |
| } |
| - if done, ok := hostclaim.ReadCompletedRelocation(obs.Claim); ok && done.Op == op && done.Stop.Box == b.box && !done.Discharged { |
| + done, ok, err := hostclaim.ReadCompletedRelocation(obs.Claim) |
| + if err == nil && ok && done.Op == op && done.Stop.Box == b.box && !done.Discharged { |
| _, err = hostclaim.Update(ctx, b.f.s, obs, hostclaim.MarkCompletedDischarged(op)) |
| } |
| return err |
diff --git a/internal/hostrelocate/worker.go b/internal/hostrelocate/worker.go| index cd447030e..c49786246 100644 |
| --- a/internal/hostrelocate/worker.go |
| +++ b/internal/hostrelocate/worker.go |
| @@ -406,9 +406,11 @@ func (w Worker) Step(ctx context.Context, host string) (hostclaim.Observed, erro |
| // 67: bounded. If root's stop receipt cannot be had (the |
| // execution lease lost after RecordProbe, so the participant |
| // never passes Ready; a stop root keeps refusing), the op is |
| - // stuck ReleaseDeadline after the probe passed, for an operator. |
| - // Nothing is released: the participation's denial, the row's |
| - // hold and the session stay as they are. |
| + // stuck ReleaseDeadline after the probe passed (Probe.At; for an |
| + // untimed probe journaled before rev 4, probing_since plus |
| + // ProbeDeadline, the latest it can have passed), for an |
| + // operator. Nothing is released: the participation's denial, |
| + // the row's hold and the session stay as they are. |
| var p hostclaim.Probe |
| passed := since.Add(ProbeDeadline) |
| if json.Unmarshal([]byte(c.Op.Journal["probe"]), &p) == nil && !p.At.IsZero() { |
| @@ -435,8 +437,13 @@ func (w Worker) Step(ctx context.Context, host string) (hostclaim.Observed, erro |
| // ProbeDeadline (a retired stamp, a participation main denies) is |
| // stuck for an operator: no automatic replacement of a live target |
| // is made here. |
| + // A missing or unreadable installed_at is an error, never "no |
| + // bound" (absence is never evidence), as probing_since is above. |
| at, err := time.Parse(time.RFC3339Nano, c.Op.Journal["installed_at"]) |
| - if err == nil && w.Now().Sub(at) > ProbeDeadline { |
| + if err != nil { |
| + return obs, fmt.Errorf("installed_at of %s: %w", id, err) |
| + } |
| + if w.Now().Sub(at) > ProbeDeadline { |
| return w.update(ctx, obs, hostclaim.MarkStuck(id, fmt.Sprintf("%s did not enter probing within %s of installed", c.Op.Exec, ProbeDeadline))) |
| } |
| return obs, ErrNotYet |
diff --git a/internal/hostrelocate/worker_bounds_test.go b/internal/hostrelocate/worker_bounds_test.go| index 71dd1034f..0b4c4bd28 100644 |
| --- a/internal/hostrelocate/worker_bounds_test.go |
| +++ b/internal/hostrelocate/worker_bounds_test.go |
| @@ -3,6 +3,7 @@ package hostrelocate |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "strings" |
| "testing" |
| "time" |
| @@ -127,3 +128,56 @@ func TestWorkerBoundsARefusedProbe(t *testing.T) { |
| t.Fatalf("not stuck on dst for an operator: %+v", o.Claim.Op) |
| } |
| } |
| + |
| +// Fix round 1, M3: a timed probe's bound counts from Probe.At, not from |
| +// probing_since and not from probing_since plus ProbeDeadline. The probe |
| +// passes 7 minutes into probing: ReleaseDeadline after At the op waits, |
| +// a second later it is stuck. |
| +func TestWorkerBoundsATimedProbeFromItsPassNotProbingSince(t *testing.T) { |
| + f := newFixture(t) |
| + id := releasedAndInstalling(t, f) |
| + f.installAndProbe("dst") // probing_since is f.now |
| + passed := f.now.Add(7 * time.Minute) |
| + o := f.claim() |
| + f.participant(hostclaim.RecordProbe(id, hostclaim.Probe{Box: "dst", Session: o.Claim.Holder.Session, At: passed})) |
| + f.now = passed.Add(ReleaseDeadline) |
| + if o := f.worker(ErrNotYet); o.Claim.Op.Journal["stuck"] != "" { |
| + t.Fatal("stuck before ReleaseDeadline after the probe passed: the bound counted from probing_since") |
| + } |
| + f.now = f.now.Add(time.Second) |
| + if o := f.worker(nil); o.Claim.Op.Journal["stuck"] == "" { |
| + t.Fatalf("not stuck ReleaseDeadline after the probe passed: %+v", o.Claim.Op) |
| + } |
| +} |
| + |
| +// Fix round 1, M2: installed's bound needs its start. A missing or |
| +// unreadable installed_at is an error, never a pass with no bound; the op |
| +// is neither stuck nor waiting silently. |
| +func TestWorkerRefusesAnInstalledWithoutItsStart(t *testing.T) { |
| + for name, value := range map[string]*string{"missing": nil, "unreadable": new("yesterday")} { |
| + t.Run(name, func(t *testing.T) { |
| + f := newFixture(t) |
| + releasedAndInstalling(t, f) |
| + f.worker(ErrNotYet) // P is not built yet |
| + p := f.claim().Claim.Op.Journal["prepared"] |
| + f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.built"] = p; return nil }) |
| + f.worker(nil) // installed |
| + f.participant(func(c *hostclaim.Claim) error { |
| + if value == nil { |
| + delete(c.Op.Journal, "installed_at") |
| + } else { |
| + c.Op.Journal["installed_at"] = *value |
| + } |
| + return nil |
| + }) |
| + f.now = f.now.Add(ProbeDeadline + time.Hour) |
| + o, err := f.w.Step(context.Background(), host) |
| + if err == nil || errors.Is(err, ErrNotYet) { |
| + t.Fatalf("an installed without its start: %v", err) |
| + } |
| + if o.Claim.Op.Journal["stuck"] != "" || f.claim().Claim.Op.Phase != hostclaim.PhaseInstalled { |
| + t.Fatalf("the op moved: %+v", f.claim().Claim.Op) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/lease/lease.go b/internal/lease/lease.go| index 61788e219..24c109ac1 100644 |
| --- a/internal/lease/lease.go |
| +++ b/internal/lease/lease.go |
| @@ -392,6 +392,21 @@ func (r *Replicator) SyncAndWait(ctx context.Context) error { |
| return r.db.SyncAndWait(ctx) |
| } |
| +// Position is the replicator's own position: the newest TXID litestream |
| +// has turned into a local level-0 LTX file (litestream's DB.Pos, which |
| +// keeps the newest L0 file through retention). After a SyncAndWait that |
| +// returned nil the replica holds everything up to it. Zero, with a nil |
| +// error, only when litestream holds no LTX file at all: nothing was ever |
| +// committed through this replicator's database. An unreadable LTX |
| +// directory or file is an error, never zero. |
| +func (r *Replicator) Position() (uint64, error) { |
| + pos, err := r.db.Pos() |
| + if err != nil { |
| + return 0, err |
| + } |
| + return uint64(pos.TXID), nil |
| +} |
| + |
| // Err reports whether lease renewal has failed (leadership lost). |
| func (r *Replicator) Err() error { return r.rn.Err() } |
diff --git a/internal/lease/position_test.go b/internal/lease/position_test.go| new file mode 100644 |
| index 000000000..1c9e34eba |
| --- /dev/null |
| +++ b/internal/lease/position_test.go |
| @@ -0,0 +1,63 @@ |
| +package lease |
| + |
| +import ( |
| + "database/sql" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/benbjohnson/litestream/file" |
| + _ "modernc.org/sqlite" |
| +) |
| + |
| +// Fix round 1 (region HA I2): Position is the replicator's own position. |
| +// It is zero while litestream holds no LTX file, and after commits and a |
| +// SyncAndWait it names the newest committed TXID, which the replica holds. |
| +func TestReplicatorPositionIsItsOwnNewestLTX(t *testing.T) { |
| + ctx := t.Context() |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "app.db") |
| + h := &Held{replicaClient: file.NewReplicaClient(filepath.Join(dir, "replica"))} |
| + store, db, err := h.newStore(path, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r := &Replicator{store: store, db: db} |
| + if pos, err := r.Position(); err != nil || pos != 0 { |
| + t.Fatalf("before any LTX: %d %v", pos, err) |
| + } |
| + conn, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer conn.Close() |
| + if _, err := conn.Exec(`CREATE TABLE t (v INTEGER)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := store.Open(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer store.Close(ctx) |
| + if _, err := conn.Exec(`INSERT INTO t VALUES (1)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.SyncAndWait(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + first, err := r.Position() |
| + if err != nil || first == 0 { |
| + t.Fatalf("after a commit: %d %v", first, err) |
| + } |
| + if _, err := conn.Exec(`INSERT INTO t VALUES (2)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.SyncAndWait(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + second, err := r.Position() |
| + if err != nil || second <= first { |
| + t.Fatalf("after a second commit: %d (was %d) %v", second, first, err) |
| + } |
| + if remote := uint64(db.Replica.Pos().TXID); remote != second { |
| + t.Fatalf("the replica is at %d, the replicator reports %d", remote, second) |
| + } |
| +} |