offline-move: create-only writer for the immutable seal record
putOfflineCreateOnly writes a key only if it is absent and otherwise returns what is stored, unwritten: the sealer writes seal.json once (moves.OfflineSealKey, added on Task 6's branch) and a re-run compares instead of overwriting. The stub contracts for Tasks 4 and 5 name it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+57
−0
internal/host/movesoffline.go+25 −0internal/host/movesoffline_land.go+4 −0internal/host/movesoffline_seal.go+6 −0internal/host/movesoffline_test.go+22 −0
diff --git a/internal/host/movesoffline.go b/internal/host/movesoffline.go| index 47bb7e23c..1fef1c720 100644 |
| --- a/internal/host/movesoffline.go |
| +++ b/internal/host/movesoffline.go |
| @@ -317,6 +317,31 @@ func writeOfflineReport(ctx context.Context, s release.Store, r moves.OfflineRep |
| return moves.PutReplace(ctx, s, key, body) |
| } |
| +// putOfflineCreateOnly writes body at key only if nothing is there yet: |
| +// the immutable seal record (control/offline-moves/<host>/<id>/seal.json, |
| +// moves.OfflineSealKey, written once by the sealer and never rewritten). |
| +// It is an ensure: when the key already exists it writes nothing and |
| +// returns what is stored, with created false, so a sealer re-running after |
| +// a crash compares rather than overwrites, and a reader (flip, the landing) |
| +// always sees the first write. key must be non-empty. |
| +func putOfflineCreateOnly(ctx context.Context, s release.Store, key string, body []byte) (stored []byte, created bool, err error) { |
| + if key == "" { |
| + return nil, false, errors.New("offline move: no key to write") |
| + } |
| + _, err = s.Put(ctx, key, body, "") |
| + if err == nil { |
| + return body, true, nil |
| + } |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return nil, false, fmt.Errorf("write %s: %w", key, err) |
| + } |
| + stored, _, err = s.Get(ctx, key) |
| + if err != nil { |
| + return nil, false, fmt.Errorf("read %s after a create-only write found it: %w", key, err) |
| + } |
| + return stored, false, nil |
| +} |
| + |
| // readOfflineReports reads every report of one role for one move, keyed by |
| // box. A report that does not validate is an error, never skipped. |
| func readOfflineReports(ctx context.Context, s release.Store, host, op, role string) (map[string]moves.OfflineReport, error) { |
diff --git a/internal/host/movesoffline_land.go b/internal/host/movesoffline_land.go| index b4c2687ce..d3ea217f5 100644 |
| --- a/internal/host/movesoffline_land.go |
| +++ b/internal/host/movesoffline_land.go |
| @@ -20,6 +20,10 @@ import ( |
| // ensure; an error is logged by the pass, throttled per host, and the next |
| // pass runs the step again. |
| // |
| +// The landing compares against the immutable seal record |
| +// (moves.OfflineSealKey(host, id), added on Task 6's branch), read with |
| +// s.Get; never against the sealer's per-pass report, which keeps changing. |
| +// |
| // Task 3 leaves it a no-op. |
| func (w *movesWorker) offlineTargetStep(ctx context.Context, s release.Store, rec provision.Record) error { |
| return nil |
diff --git a/internal/host/movesoffline_seal.go b/internal/host/movesoffline_seal.go| index 2e3b2d6af..e13489c57 100644 |
| --- a/internal/host/movesoffline_seal.go |
| +++ b/internal/host/movesoffline_seal.go |
| @@ -16,6 +16,12 @@ import ( |
| // Every step must be an ensure; an error is logged by the pass, throttled |
| // per host, and the next pass runs the step again. |
| // |
| +// The immutable seal record (moves.OfflineSealKey(host, id), added on |
| +// Task 6's branch) is written once through putOfflineCreateOnly, never |
| +// rewritten: on a re-run it returns the stored body with created false, and |
| +// the step compares instead of overwriting. The per-pass sealer report goes |
| +// through writeOfflineReport, which replaces. |
| +// |
| // Task 3 leaves it a no-op. |
| func (w *movesWorker) offlineSealerStep(ctx context.Context, s release.Store, rec provision.Record) error { |
| return nil |
diff --git a/internal/host/movesoffline_test.go b/internal/host/movesoffline_test.go| index 6b767b38f..191a73fe8 100644 |
| --- a/internal/host/movesoffline_test.go |
| +++ b/internal/host/movesoffline_test.go |
| @@ -1012,6 +1012,28 @@ func TestOfflineDormantIsMain(t *testing.T) { |
| } |
| } |
| +// The seal record's writer is create-only: the first body stays, a re-run |
| +// gets it back and writes nothing, and the report writer beside it replaces. |
| +func TestOfflineCreateOnlyKeepsTheFirstWrite(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + key := moves.OfflineReportPrefix(offHost, offID) + "seal.json" |
| + got, created, err := putOfflineCreateOnly(ctx, s, key, []byte(`{"n":1}`)) |
| + if err != nil || !created || string(got) != `{"n":1}` { |
| + t.Fatalf("first write: %q %v %v", got, created, err) |
| + } |
| + got, created, err = putOfflineCreateOnly(ctx, s, key, []byte(`{"n":2}`)) |
| + if err != nil || created || string(got) != `{"n":1}` { |
| + t.Fatalf("second write: %q %v %v, want the first body back, nothing written", got, created, err) |
| + } |
| + if body, _, err := s.Get(ctx, key); err != nil || string(body) != `{"n":1}` { |
| + t.Fatalf("stored: %q %v", body, err) |
| + } |
| + if _, _, err := putOfflineCreateOnly(ctx, s, "", nil); err == nil { |
| + t.Fatal("an empty key was written") |
| + } |
| +} |
| + |
| // keep's table: the source keeps until finishing, the target until the |
| // move is cleared, a third region never. |
| func TestOfflineKeep(t *testing.T) { |