offline abort: before the seal a pinned move waits for its pinned sources (final review F2)
offlineStepFor's reverting check takes the required source boxes of a move aborted before the seal from the record's pinned Participants when it has them (only the self-serve queue pins them), never the current fleet, so a box that joined the source region since cannot hold the abort open. An operator move keeps the fleet's boxes, byte for byte. Witness: TestAbortUsesPinnedSourcesAfterFleetGrowth (e2e).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+68
−4
internal/console/apiinstancemove_offline.go+11 −4internal/e2e/selfserve/abort_pinned_test.go+57 −0
diff --git a/internal/console/apiinstancemove_offline.go b/internal/console/apiinstancemove_offline.go| index 44c5241ea..f789a8b93 100644 |
| --- a/internal/console/apiinstancemove_offline.go |
| +++ b/internal/console/apiinstancemove_offline.go |
| @@ -1301,11 +1301,18 @@ func (s *Server) offlineStepFor(ctx context.Context, d offlineDoor, mv *provisio |
| return offlineStep{mut: offlineFinishMut(mv.ID, now)}, nil |
| case provision.OfflineReverting: |
| // Every box the seal named, and the sealer. A move aborted before |
| - // the seal named neither: the boxes are the source region's as the |
| - // edges files publish them now, and there is no sealer (it never |
| - // took a tombstone). |
| + // the seal named neither, and there is no sealer (it never took a |
| + // tombstone): a move that pinned its participants waits for its |
| + // pinned sources, never a box that joined the region since (final |
| + // review F2); an operator move's boxes are the source region's as |
| + // the edges files publish them now. |
| boxes, sealerPhase := mv.Boxes, moves.OfflinePhaseReverted |
| - if mv.Sealer == "" { |
| + if mv.Sealer == "" && mv.Participants != nil && len(mv.Participants.Source) > 0 { |
| + boxes, sealerPhase = nil, "" |
| + for _, p := range mv.Participants.Source { |
| + boxes = append(boxes, p.Label) |
| + } |
| + } else if mv.Sealer == "" { |
| fleet, ok := s.readOfflineFleet(ctx) |
| if !ok { |
| return offlineStep{}, &offlineRefusal{status: http.StatusConflict, msg: "the fleet's edges files could not all be read. try again", transient: true} |
diff --git a/internal/e2e/selfserve/abort_pinned_test.go b/internal/e2e/selfserve/abort_pinned_test.go| new file mode 100644 |
| index 000000000..b87283174 |
| --- /dev/null |
| +++ b/internal/e2e/selfserve/abort_pinned_test.go |
| @@ -0,0 +1,57 @@ |
| +//go:build e2e |
| + |
| +package selfserve |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/host" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// TestAbortUsesPinnedSourcesAfterFleetGrowth (final review F2): an abort |
| +// before the seal waits for the pinned sources' reports, never the current |
| +// fleet's. Prepared with source A, the move reaches ready; a second box B |
| +// joins the source region (its edges file); the member cancels. A reports |
| +// its revert and B, never a participant, reports nothing: the source |
| +// reopens, the move ends aborted and its reservations are released. |
| +func TestAbortUsesPinnedSourcesAfterFleetGrowth(t *testing.T) { |
| + w := newJobWorld(t) |
| + row := w.admit() |
| + w.watch = row.ID |
| + region := w.record().Region |
| + w.phase(row.ID, "ready", 60) |
| + |
| + // B: the source box's edges file under another box's name. |
| + raw, _, err := w.raw.Get(w.ctx, "control/edges/"+host.E2ESourceBox+".json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var f map[string]any |
| + if err := json.Unmarshal(raw, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + f["box"] = "ie-joined" |
| + body, _ := json.Marshal(f) |
| + if _, err := w.raw.Put(w.ctx, "control/edges/ie-joined.json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + w.cancel(row.ID) |
| + r := w.finished(row.ID, 60) |
| + if r.Outcome != provision.OfflineOutcomeAborted || r.SourceReopenedAt.IsZero() { |
| + t.Fatalf("the abort did not finish: %s, reopened %v", describe(r), r.SourceReopenedAt) |
| + } |
| + if rec := w.record(); rec.Move != nil || rec.Region != region { |
| + t.Fatalf("the record after the abort: region %q move %+v", rec.Region, rec.Move) |
| + } |
| + for k, e := range w.reservations() { |
| + if e.ID == row.ID { |
| + t.Fatalf("the reservation %s is still held: %+v", k, e) |
| + } |
| + } |
| + if _, ok := w.report(row.ID, "source", "ie-joined"); ok { |
| + t.Fatal("the joined box reported, so the witness proves nothing") |
| + } |
| +} |