carlos / platform Public

region ha: the probe stop stops the writers, flushes the database and files and records positions through the release's own steps before root's stop (ruling 87).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 6a4eadde630d4b50ae1e0a7bef75ce83c7201907 parent 6daf41a
3 files changed, +416 −31
  • internal/host/relocationeffects.go +117 −28
  • internal/host/relocationowners.go +15 −3
  • internal/host/relocationprobeflush_unix_test.go +284 −0

CI cancelled — superseded by a newer push — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m

amadan: job cancelled — a newer push superseded it
diff --git a/internal/host/relocationeffects.go b/internal/host/relocationeffects.go
index 7e105c56e..3a8dbdd7f 100644
--- a/internal/host/relocationeffects.go
+++ b/internal/host/relocationeffects.go
@@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
+ "time"
"github.com/carlosframework/platform/internal/hostclaim"
execctl "github.com/carlosframework/platform/internal/hostexec"
+ "github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/hostrelocate"
"github.com/carlosframework/platform/internal/release"
)
@@ -153,35 +155,10 @@ func (e *RelocationEffects) Release(ctx context.Context, obs hostclaim.Observed)
}
h := obs.Claim.Holder
req := releaseStopRequest(obs, id)
- recorded, flushed, err := e.Positions.Recorded(ctx, req)
+ recorded, err := e.flushStopped(ctx, obs, id, req)
if err != nil {
return hostclaim.Release{}, err
}
- if !flushed {
- drained, seen, err := e.WritersStopped(ctx, obs)
- if err != nil {
- return hostclaim.Release{}, err
- }
- if err := drained.For(obs); err != nil {
- return hostclaim.Release{}, err
- }
- if seen.Host != obs.Host || seen.Incarnation != h.Incarnation || seen.InstallationStamp != h.Stamp || seen.WriterSession != h.Session ||
- seen.Box != id.Actor.Box || seen.Enrollment != id.Actor.Enrollment || seen.CheckedAt.IsZero() {
- return hostclaim.Release{}, fmt.Errorf("%w: writers-stopped evidence names another session", ErrClaimStopIdentity)
- }
- txid, err := e.FlushDB(ctx, obs)
- if err != nil {
- return hostclaim.Release{}, err
- }
- seq, err := e.FlushFiles(ctx, obs)
- if err != nil {
- return hostclaim.Release{}, err
- }
- recorded = ReleasePosition{Request: req, PID: id.PID, ProcessStart: id.ProcessStart, TXID: txid, Seq: seq}
- if err := e.Positions.Record(ctx, recorded); err != nil {
- return hostclaim.Release{}, err
- }
- }
// Root fences a relocation-release stop only while the claim owes it
// (hostguard.StopAuthorityMatches): queued now, at the flushed claim.
if obs, err = e.Releases.Queue(ctx, obs); err != nil {
@@ -210,6 +187,85 @@ func (e *RelocationEffects) Release(ctx context.Context, obs hostclaim.Observed)
FinalTXID: recorded.TXID, ManifestSeq: recorded.Seq, Receipt: result.Key, ReceiptSHA256: result.Digest}, nil
}
+// flushStopped is release steps 1 to 3 and the record (ruling 28), the
+// one implementation the release and the probe stop share (ruling 87):
+// writers stopped and observed for the claim as observed, then the
+// database flushed (FlushDB), then the files (FlushFiles), then the
+// positions recorded durably for req. A position already recorded for req
+// (a retry after a later step failed, or after root's stop closed the
+// session's replicator) is returned with nothing done again. Any failure
+// returns its error with nothing recorded, so the stop that follows is
+// never asked and the next pass flushes again: a flush is never taken as
+// done without its record. A context already done refuses before the
+// writers are touched.
+func (e *RelocationEffects) flushStopped(ctx context.Context, obs hostclaim.Observed, id RelocationIdentity, req StopRequest) (ReleasePosition, error) {
+ recorded, flushed, err := e.Positions.Recorded(ctx, req)
+ if err != nil {
+ return ReleasePosition{}, err
+ }
+ if flushed {
+ return recorded, nil
+ }
+ if err := ctx.Err(); err != nil {
+ return ReleasePosition{}, err
+ }
+ h := obs.Claim.Holder
+ drained, seen, err := e.WritersStopped(ctx, obs)
+ if err != nil {
+ return ReleasePosition{}, err
+ }
+ if err := drained.For(obs); err != nil {
+ return ReleasePosition{}, err
+ }
+ if seen.Host != obs.Host || seen.Incarnation != h.Incarnation || seen.InstallationStamp != h.Stamp || seen.WriterSession != h.Session ||
+ seen.Box != id.Actor.Box || seen.Enrollment != id.Actor.Enrollment || seen.CheckedAt.IsZero() {
+ return ReleasePosition{}, fmt.Errorf("%w: writers-stopped evidence names another session", ErrClaimStopIdentity)
+ }
+ txid, err := e.FlushDB(ctx, obs)
+ if err != nil {
+ return ReleasePosition{}, err
+ }
+ seq, err := e.FlushFiles(ctx, obs)
+ if err != nil {
+ return ReleasePosition{}, err
+ }
+ recorded = ReleasePosition{Request: req, PID: id.PID, ProcessStart: id.ProcessStart, TXID: txid, Seq: seq}
+ if err := e.Positions.Record(ctx, recorded); err != nil {
+ return ReleasePosition{}, err
+ }
+ return recorded, nil
+}
+
+// probeStopRequest is the probe stop's request for obs's passed probe
+// session (ruling 87): the release's request shape at relocating/probing,
+// under root's writer-session stop obligation for that session, so its
+// recorded positions are never a release's.
+func probeStopRequest(obs hostclaim.Observed, id RelocationIdentity) StopRequest {
+ r := releaseStopRequest(obs, id)
+ r.ObligationID = hostguard.WriterStopObligation(obs.Claim.Holder.Session)
+ return r
+}
+
+// probeStopDeadline is ruling 67's bound on the probe stop, as the worker
+// counts it: ReleaseDeadline after the probe passed (Probe.At; for an
+// untimed probe, probing_since plus ProbeDeadline). A probe or a
+// probing_since that cannot be read is an error, never "no bound".
+func probeStopDeadline(c *hostclaim.Claim) (time.Time, error) {
+ var p hostclaim.Probe
+ if err := json.Unmarshal([]byte(c.Op.Journal["probe"]), &p); err != nil {
+ return time.Time{}, fmt.Errorf("%w: the passed probe cannot be read: %v", hostrelocate.ErrRefused, err)
+ }
+ passed := p.At
+ if passed.IsZero() {
+ since, err := time.Parse(time.RFC3339Nano, c.Op.Journal["probing_since"])
+ if err != nil {
+ return time.Time{}, fmt.Errorf("%w: probing_since cannot be read: %v", hostrelocate.ErrRefused, err)
+ }
+ passed = since.Add(hostrelocate.ProbeDeadline)
+ }
+ return passed.Add(hostrelocate.ReleaseDeadline), nil
+}
+
// releaseStopRequest is the release's stop request for obs's session, as
// this box's identity id asks it.
func releaseStopRequest(obs hostclaim.Observed, id RelocationIdentity) StopRequest {
@@ -392,10 +448,43 @@ func (e *RelocationEffects) Preflight(ctx context.Context, obs hostclaim.Observe
return e.PreflightKind(ctx, obs)
}
-// EndProbe is ruling 49's stop of the passed probe session.
+// EndProbe is ruling 49's stop of the passed probe session, flushed first
+// (ruling 87): the session's writers are stopped, its database and files
+// flushed and the positions recorded (flushStopped, the release's own
+// steps), and only then the probe stop (StopProbe: the activator's owned
+// fence and stop, which closes the session's replicator, then root's
+// writer-session stop). A write the probe session acknowledged is then
+// shipped before the session ends, so the first serving wake's cold
+// admission, whose writes_lost is bounded by the remote's MaxTXID, loses
+// none of it. The flush is bounded by the stop's own deadline (ruling 67,
+// probeStopDeadline): past it the flush refuses and the worker marks the
+// op stuck. A failed flush refuses the stop with nothing asked of the
+// activator or root, and the next pass flushes again.
func (e *RelocationEffects) EndProbe(ctx context.Context, obs hostclaim.Observed) (hostclaim.ProbeStop, error) {
- if e.StopProbe == nil {
+ if e.StopProbe == nil || e.Identity == nil || e.WritersStopped == nil || e.FlushDB == nil || e.FlushFiles == nil || e.Positions == nil || obs.Claim == nil || obs.Claim.Holder == nil {
return hostclaim.ProbeStop{}, ErrClaimStopUnavailable
}
+ id, err := e.Identity(ctx)
+ if err != nil {
+ return hostclaim.ProbeStop{}, err
+ }
+ if id.PID <= 0 || id.ProcessStart == "" {
+ return hostclaim.ProbeStop{}, ErrClaimStopUnavailable
+ }
+ // Only this box's passed probe session is flushed: its writers are
+ // stopped below, so another session is never touched.
+ if err := passedProbeSession(obs.Claim, id.Actor.Box); err != nil {
+ return hostclaim.ProbeStop{}, err
+ }
+ deadline, err := probeStopDeadline(obs.Claim)
+ if err != nil {
+ return hostclaim.ProbeStop{}, err
+ }
+ bounded, cancel := context.WithDeadline(ctx, deadline)
+ _, err = e.flushStopped(bounded, obs, id, probeStopRequest(obs, id))
+ cancel()
+ if err != nil {
+ return hostclaim.ProbeStop{}, err
+ }
return e.StopProbe(ctx, obs)
}
diff --git a/internal/host/relocationowners.go b/internal/host/relocationowners.go
index dbee77079..969eaf40e 100644
--- a/internal/host/relocationowners.go
+++ b/internal/host/relocationowners.go
@@ -290,9 +290,8 @@ func (a ClaimAssembly) relocationStopProbe(cfg RelocationAssemblyConfig) func(co
return hostclaim.ProbeStop{}, err
}
c, h, box := o.Claim, o.Claim.Holder, local.Actor.Box
- var p hostclaim.Probe
- if json.Unmarshal([]byte(c.Op.Journal["probe"]), &p) != nil || p.Box != box || p.Session != h.Session || c.Op.Exec != box || c.Op.Phase != hostclaim.PhaseRelocatingProbing || h.WriterBox != box {
- return hostclaim.ProbeStop{}, hostbox.ErrChanged
+ if err := passedProbeSession(c, box); err != nil {
+ return hostclaim.ProbeStop{}, err
}
marker, err := relocationProbeFencePath(cfg.DataDir, c.Op.ID)
if err != nil {
@@ -320,6 +319,19 @@ func (a ClaimAssembly) relocationStopProbe(cfg RelocationAssemblyConfig) func(co
}
}
+// passedProbeSession checks c is this box's passed probe at
+// relocating/probing, its session the holder's own: the one session the
+// probe stop may flush (ruling 87) and stop (ruling 49). Anything else is
+// hostbox.ErrChanged, with nothing stopped.
+func passedProbeSession(c *hostclaim.Claim, box string) error {
+ var p hostclaim.Probe
+ if c == nil || c.Holder == nil || box == "" || json.Unmarshal([]byte(c.Op.Journal["probe"]), &p) != nil || p.Box != box || p.Session != c.Holder.Session ||
+ c.Op.Exec != box || c.Op.Phase != hostclaim.PhaseRelocatingProbing || c.Holder.WriterBox != box {
+ return hostbox.ErrChanged
+ }
+ return nil
+}
+
// relocationProbeFencePath is where this box's probe stop persists the
// owner of the activator fence it installs, with the session and host:
// <DataDir>/relocate/ops/<op>/probe-fence.json. The journal sweep keeps
diff --git a/internal/host/relocationprobeflush_unix_test.go b/internal/host/relocationprobeflush_unix_test.go
new file mode 100644
index 000000000..c375cac0f
--- /dev/null
+++ b/internal/host/relocationprobeflush_unix_test.go
@@ -0,0 +1,284 @@
+//go:build unix
+
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/genselect"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostrelocate"
+)
+
+// probeRemote is the probe session's database as the region's bucket sees
+// it: acked is the newest transaction the session acknowledged to a
+// client, shipped the newest the replicator has put in the bucket.
+// SyncAndWait ships everything acknowledged; once root's stop closes the
+// replicator (closed) nothing more ships, and an unshipped write is gone
+// with the session. A write is acknowledged only while the writers run.
+type probeRemote struct {
+ acked, shipped uint64
+ stopped, closed bool
+ syncErr error
+}
+
+func (r *probeRemote) ack(t *testing.T) uint64 {
+ t.Helper()
+ if r.stopped || r.closed {
+ t.Fatal("a write acknowledged after the writers stopped")
+ }
+ r.acked++
+ return r.acked
+}
+
+func (r *probeRemote) SyncAndWait(context.Context) error {
+ if r.syncErr != nil {
+ return r.syncErr
+ }
+ if r.closed {
+ return errors.New("the replicator is closed")
+ }
+ r.shipped = r.acked
+ return nil
+}
+
+// plan is the bucket's listing: what a cold restore reads.
+func (r *probeRemote) plan(context.Context, genselect.Request) (*genselect.Plan, error) {
+ return &genselect.Plan{MaxTXID: r.shipped, MaxLevel0TXID: r.shipped}, nil
+}
+
+// probeFlush is the probe stop as RelocationEffects.EndProbe runs it on the
+// stop-probe fixture's target "dst" (the real relocationStopProbe, its
+// activator and root's writer-session stop), with the release's own step
+// providers: WritersStopped stops the session's writers, FlushDB is the
+// release's releaseFinalTXID over the session's replicator, FlushFiles a
+// recorded stand-in, Positions the real positions file. Root's stop
+// closes the replicator. Every step lands in f.order.
+type probeFlush struct {
+ f *stopProbeFixture
+ e *RelocationEffects
+ remote *probeRemote
+ positions ReleasePositionsFile
+ filesErr error
+ recordErr error
+}
+
+func newProbeFlush(t *testing.T, passed time.Time) *probeFlush {
+ t.Helper()
+ f := newStopProbeFixture(t)
+ probe, _ := json.Marshal(hostclaim.Probe{Box: "dst", Session: 12, At: passed})
+ f.obs.Claim.Op.Journal["probe"] = string(probe)
+ p := &probeFlush{f: f, remote: &probeRemote{acked: 40, shipped: 40}, positions: ReleasePositionsFile{Dir: t.TempDir()}}
+ stop := f.a.Wake.Stop
+ f.a.Wake.Stop = func(ctx context.Context, o hostclaim.Observed) error {
+ p.remote.closed = true // root's stop ends the session and its replicator
+ return stop(ctx, o)
+ }
+ p.e = &RelocationEffects{
+ Identity: func(ctx context.Context) (RelocationIdentity, error) { return f.a.localStopIdentity(ctx) },
+ WritersStopped: func(_ context.Context, o hostclaim.Observed) (ClaimWritersDrained, WritersStoppedEvidence, error) {
+ f.order = append(f.order, "writers-stopped")
+ p.remote.stopped = true
+ return probeWritersStopped(o)
+ },
+ FlushDB: func(ctx context.Context, o hostclaim.Observed) (uint64, error) {
+ f.order = append(f.order, "db")
+ return releaseFinalTXID(ctx, p.remote, p.remote.plan, genselect.Request{Host: o.Host, Holder: *o.Claim.Holder})
+ },
+ FlushFiles: func(context.Context, hostclaim.Observed) (int64, error) {
+ f.order = append(f.order, "files")
+ return 3, p.filesErr
+ },
+ Positions: probeFlushPositions{p},
+ StopProbe: f.a.relocationStopProbe(f.cfg),
+ }
+ return p
+}
+
+// probeFlushPositions is the real positions file, its writes in f.order.
+type probeFlushPositions struct{ p *probeFlush }
+
+func (s probeFlushPositions) Record(ctx context.Context, r ReleasePosition) error {
+ s.p.f.order = append(s.p.f.order, "record")
+ if s.p.recordErr != nil {
+ return s.p.recordErr
+ }
+ return s.p.positions.Record(ctx, r)
+}
+
+func (s probeFlushPositions) Recorded(ctx context.Context, r StopRequest) (ReleasePosition, bool, error) {
+ return s.p.positions.Recorded(ctx, r)
+}
+
+// probeWritersStopped is a complete StopWriters witness and root
+// observation for o's writer session on "dst".
+func probeWritersStopped(o hostclaim.Observed) (ClaimWritersDrained, WritersStoppedEvidence, error) {
+ h := o.Claim.Holder
+ at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)
+ target := hostguard.FenceTarget{Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Box: "dst", Enrollment: "enr", BootID: "boot", ControllerID: "ctl"}
+ drained := ClaimWritersDrained{Target: target, Generation: h.Generation, WriterBox: h.WriterBox, FenceClosed: true, LaunchJoined: true, WorkDrained: true, SpawnsSettled: true,
+ CgroupEmpty: true, Cgroup: "carlos/shop", DBPaths: []string{"/data/shop.db"}, CheckedAt: at}
+ seen := WritersStoppedEvidence{Host: o.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, WriterSession: h.Session, Box: "dst", Enrollment: "enr",
+ BootID: "boot", ControllerID: "ctl", ClaimETag: o.ETag, CheckedAt: at}
+ return drained, seen, nil
+}
+
+func (p *probeFlush) end(t *testing.T) (hostclaim.ProbeStop, error) {
+ t.Helper()
+ return p.e.EndProbe(t.Context(), p.f.obs)
+}
+
+// firstServingWake is the serving CAS then main's cold serving admission
+// on "dst", at the bucket's MaxTXID as a cold restore reads it: its
+// writes_lost declares the probe session's writes after that position.
+func (p *probeFlush) firstServingWake(t *testing.T) hostclaim.AdmissionRecord {
+ t.Helper()
+ c := *p.f.obs.Claim
+ h := *c.Holder
+ c.Holder = &h
+ c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}}
+ plan, err := p.remote.plan(t.Context(), genselect.Request{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: 12, ObservedGeneration: "P1", Box: "dst", Mode: "cold", NewGeneration: "S1", Region: "ie", RemoteMaxTXID: plan.MaxTXID})(&c); err != nil {
+ t.Fatal(err)
+ }
+ return c.Holder.Admissions[len(c.Holder.Admissions)-1]
+}
+
+// Ruling 87: a write the probe session acknowledged just before its stop
+// survives the first serving wake. The probe stop stops the writers,
+// flushes the database (the release's releaseFinalTXID: the replicator
+// ships every acknowledged write), flushes the files and records the
+// positions, and only then fences and asks root's stop, which closes the
+// replicator. The first serving wake is cold, as main's is, and its
+// writes_lost names the probe session at the bucket's MaxTXID, which is
+// now the acknowledged write's own: it declares nothing acknowledged lost.
+func TestProbeStopFlushesSoAnAcknowledgedWriteSurvivesTheFirstServingWake(t *testing.T) {
+ p := newProbeFlush(t, time.Now())
+ acked := p.remote.ack(t) // acknowledged; the replicator has not shipped it
+ if p.remote.shipped >= acked {
+ t.Fatal("the write shipped before the stop")
+ }
+ s, err := p.end(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"writers-stopped", "db", "files", "record", "fence-as", "wake-stop"}; !slices.Equal(p.f.order, want) {
+ t.Fatalf("order %v, want %v", p.f.order, want)
+ }
+ if s.Box != "dst" || s.Session != 12 || s.Receipt != "fence-1" {
+ t.Fatalf("stop %+v", s)
+ }
+ req := probeStopRequest(p.f.obs, mustIdentity(t, p.f))
+ rec, ok, err := p.positions.Recorded(t.Context(), req)
+ if err != nil || !ok || rec.TXID != acked || rec.Seq != 3 || rec.Request.ObligationID != hostguard.WriterStopObligation(12) || rec.Request.Phase != hostclaim.PhaseRelocatingProbing {
+ t.Fatalf("recorded %+v %v %v", rec, ok, err)
+ }
+ a := p.firstServingWake(t)
+ if a.Mode != "cold" || a.WritesLost == nil || a.WritesLost.Session != 12 || a.WritesLost.RemoteMaxTXID < acked {
+ t.Fatalf("the first serving wake declares %+v, the probe acknowledged %d", a.WritesLost, acked)
+ }
+ // A repeat (the probe-stop CAS lost) does not flush again: the
+ // replicator is closed, and the recorded positions are the flush.
+ p.f.order = nil
+ again, err := p.end(t)
+ if err != nil || again != s {
+ t.Fatalf("repeat: %+v %v", again, err)
+ }
+ if want := []string{"fence-as", "wake-stop"}; !slices.Equal(p.f.order, want) {
+ t.Fatalf("repeat order %v, want %v", p.f.order, want)
+ }
+}
+
+func mustIdentity(t *testing.T, f *stopProbeFixture) RelocationIdentity {
+ t.Helper()
+ id, err := f.a.localStopIdentity(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return id
+}
+
+// Ruling 87's guards. A failed database flush, a failed files flush and a
+// failed record each refuse the stop: nothing recorded, no fence, root
+// not asked; the next pass flushes again and completes. Past the stop's
+// deadline (ruling 67: ReleaseDeadline after the probe passed) the flush
+// refuses before the writers are touched, yet a flush already recorded
+// still lets the stop finish. Another session than the passed probe's is
+// never flushed or stopped.
+func TestProbeStopRefusesWithoutAFlush(t *testing.T) {
+ failure := errors.New("flush failed")
+ for name, fail := range map[string]func(*probeFlush){
+ "the database": func(p *probeFlush) { p.remote.syncErr = failure },
+ "the files": func(p *probeFlush) { p.filesErr = failure },
+ "the record": func(p *probeFlush) { p.recordErr = failure },
+ } {
+ t.Run(name, func(t *testing.T) {
+ p := newProbeFlush(t, time.Now())
+ acked := p.remote.ack(t)
+ fail(p)
+ if _, err := p.end(t); !errors.Is(err, failure) {
+ t.Fatalf("a failed flush of %s: %v", name, err)
+ }
+ if slices.Contains(p.f.order, "fence-as") || slices.Contains(p.f.order, "wake-stop") || p.remote.closed || p.f.act.svc.Fenced("shop.example") {
+ t.Fatalf("the stop went ahead: %v", p.f.order)
+ }
+ if _, ok, err := p.positions.Recorded(t.Context(), probeStopRequest(p.f.obs, mustIdentity(t, p.f))); ok || err != nil {
+ t.Fatalf("a failed flush was recorded: %v", err)
+ }
+ // Retried once the flush works.
+ p.remote.syncErr, p.filesErr, p.recordErr = nil, nil, nil
+ p.f.order = nil
+ if _, err := p.end(t); err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"writers-stopped", "db", "files", "record", "fence-as", "wake-stop"}; !slices.Equal(p.f.order, want) || p.remote.shipped != acked {
+ t.Fatalf("retry order %v shipped %d", p.f.order, p.remote.shipped)
+ }
+ })
+ }
+ t.Run("past the stop's deadline", func(t *testing.T) {
+ p := newProbeFlush(t, time.Now().Add(-hostrelocate.ReleaseDeadline-time.Minute))
+ if _, err := p.end(t); !errors.Is(err, context.DeadlineExceeded) || len(p.f.order) != 0 {
+ t.Fatalf("past the deadline: %v %v", err, p.f.order)
+ }
+ })
+ t.Run("past the deadline, already flushed", func(t *testing.T) {
+ p := newProbeFlush(t, time.Now())
+ id := mustIdentity(t, p.f)
+ if err := p.positions.Record(t.Context(), ReleasePosition{Request: probeStopRequest(p.f.obs, id), PID: id.PID, ProcessStart: id.ProcessStart, TXID: 40, Seq: 3}); err != nil {
+ t.Fatal(err)
+ }
+ probe, _ := json.Marshal(hostclaim.Probe{Box: "dst", Session: 12, At: time.Now().Add(-hostrelocate.ReleaseDeadline - time.Minute)})
+ p.f.obs.Claim.Op.Journal["probe"] = string(probe)
+ if _, err := p.end(t); err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"fence-as", "wake-stop"}; !slices.Equal(p.f.order, want) {
+ t.Fatalf("order %v, want %v", p.f.order, want)
+ }
+ })
+ t.Run("not the probe's session", func(t *testing.T) {
+ p := newProbeFlush(t, time.Now())
+ p.f.obs.Claim.Holder.Session = 13
+ if _, err := p.end(t); !errors.Is(err, hostbox.ErrChanged) || len(p.f.order) != 0 || p.remote.stopped {
+ t.Fatalf("another session: %v %v", err, p.f.order)
+ }
+ })
+ t.Run("an unreadable probe", func(t *testing.T) {
+ p := newProbeFlush(t, time.Now())
+ p.f.obs.Claim.Op.Journal["probe"] = "{"
+ if _, err := p.end(t); err == nil || len(p.f.order) != 0 {
+ t.Fatalf("an unreadable probe: %v %v", err, p.f.order)
+ }
+ })
+}