self-serve job: the open record's PUT runs under the lease (final review F11)
selfServeLanded checks the lease afresh and writes the open record under selfServeWriteCtx (the lease's deadline); offlineOpenRecord takes a guard it calls immediately before its create-only PUT, so a console that lost the lease starts no PUT. The operator door passes no guard. Witness: TestOpenRecordWriteNeedsTheLease (e2e), with faultStore.seen inspecting the PUT's context deadline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+111
−4
internal/console/apiinstancemove_offline.go+11 −2internal/console/selfserve_job.go+17 −1internal/e2e/selfserve/job_world_test.go+7 −1internal/e2e/selfserve/open_record_lease_test.go+76 −0
diff --git a/internal/console/apiinstancemove_offline.go b/internal/console/apiinstancemove_offline.go| index 58e93d930..44c5241ea 100644 |
| --- a/internal/console/apiinstancemove_offline.go |
| +++ b/internal/console/apiinstancemove_offline.go |
| @@ -1558,7 +1558,7 @@ func (s *Server) offlineVerbDoor(verb string) http.HandlerFunc { |
| if verb == offlineVerbOpen { |
| // The open record goes first (create-only, or this move's own |
| // adopted), and the open lands at its stamp. |
| - open, err := s.offlineOpenRecord(ctx, d.rec, mv, ev, provision.ProposeStamp(d.rec, time.Now())) |
| + open, err := s.offlineOpenRecord(ctx, d.rec, mv, ev, provision.ProposeStamp(d.rec, time.Now()), nil) |
| if errors.Is(err, errOpenRecordInTheWay) { |
| jsonError(w, http.StatusConflict, msgOpenRecordInTheWay) |
| return |
| @@ -1588,7 +1588,11 @@ var errOpenRecordInTheWay = errors.New("console: offline move: another move's op |
| // adopted (a retry after a crash between the record and the open): its |
| // OpenedAt is the stamp the open carries. Anything else at the key is |
| // errOpenRecordInTheWay. A read or write that fails is the error. |
| -func (s *Server) offlineOpenRecord(ctx context.Context, rec *provision.Record, mv *provision.MoveState, ev offlineEvidence, proposed time.Time) (moves.OpenRecord, error) { |
| +// |
| +// guard, when set, is checked immediately before the PUT (the self-serve |
| +// job's fresh lease check, final review F11); its error is the answer and |
| +// nothing is written. The operator door passes nil. |
| +func (s *Server) offlineOpenRecord(ctx context.Context, rec *provision.Record, mv *provision.MoveState, ev offlineEvidence, proposed time.Time, guard func() error) (moves.OpenRecord, error) { |
| key := moves.OpenRecordKey(rec.Host, mv.ID) |
| if key == "" { |
| return moves.OpenRecord{}, errOpenRecordInTheWay |
| @@ -1614,6 +1618,11 @@ func (s *Server) offlineOpenRecord(ctx context.Context, rec *provision.Record, m |
| if err != nil { |
| return moves.OpenRecord{}, err |
| } |
| + if guard != nil { |
| + if err := guard(); err != nil { |
| + return moves.OpenRecord{}, err |
| + } |
| + } |
| if _, err := s.cfg.Releases.Put(ctx, key, body, ""); err != nil { |
| if !errors.Is(err, release.ErrCASConflict) { |
| return moves.OpenRecord{}, err |
diff --git a/internal/console/selfserve_job.go b/internal/console/selfserve_job.go| index c75c4019f..75299e38c 100644 |
| --- a/internal/console/selfserve_job.go |
| +++ b/internal/console/selfserve_job.go |
| @@ -1952,7 +1952,23 @@ func (s *Server) selfServeLanded(ctx context.Context, t *leaseToken, row *SelfSe |
| } |
| } |
| TestHooks.point("open:record") |
| - open, err := s.offlineOpenRecord(ctx, rec, mv, ev, provision.ProposeStamp(rec, now)) |
| + // The open record's PUT under the record transitions' lease-bound |
| + // context, the lease checked afresh immediately before it (F11). |
| + guard := func() error { |
| + if !t.valid(s.selfServeNow()) { |
| + return errSelfServeLeaseLost |
| + } |
| + return nil |
| + } |
| + if err := guard(); err != nil { |
| + return err |
| + } |
| + wctx, cancel := s.selfServeWriteCtx(ctx, t) |
| + open, err := s.offlineOpenRecord(wctx, rec, mv, ev, provision.ProposeStamp(rec, now), guard) |
| + cancel() |
| + if errors.Is(err, errSelfServeLeaseLost) { |
| + return err |
| + } |
| if errors.Is(err, errOpenRecordInTheWay) { |
| return s.selfServeAbort(row, msgOpenRecordInTheWay, now) |
| } |
diff --git a/internal/e2e/selfserve/job_world_test.go b/internal/e2e/selfserve/job_world_test.go| index c11f290da..0b5ebaa6e 100644 |
| --- a/internal/e2e/selfserve/job_world_test.go |
| +++ b/internal/e2e/selfserve/job_world_test.go |
| @@ -48,6 +48,9 @@ type faultStore struct { |
| unreadable bool |
| bumps int |
| puts map[string]int |
| + // seen, when set, sees every Put of the console (any key) with its |
| + // context, before the Put runs. |
| + seen func(ctx context.Context, key string) |
| } |
| type putFault int |
| @@ -68,7 +71,7 @@ var errLostReply = errors.New("faultStore: the reply was lost") |
| func (f *faultStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| f.mu.Lock() |
| - decide := f.put |
| + decide, seen := f.put, f.seen |
| if key == release.SerialKey { |
| f.bumps++ |
| } |
| @@ -76,6 +79,9 @@ func (f *faultStore) Put(ctx context.Context, key string, body []byte, ifMatch s |
| f.puts = map[string]int{} |
| } |
| f.mu.Unlock() |
| + if seen != nil { |
| + seen(ctx, key) |
| + } |
| fault := putRun |
| if decide != nil && provision.IsRecordKey(key) { |
| fault = decide(key, body) |
diff --git a/internal/e2e/selfserve/open_record_lease_test.go b/internal/e2e/selfserve/open_record_lease_test.go| new file mode 100644 |
| index 000000000..49437687f |
| --- /dev/null |
| +++ b/internal/e2e/selfserve/open_record_lease_test.go |
| @@ -0,0 +1,76 @@ |
| +//go:build e2e |
| + |
| +package selfserve |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/host" |
| + "github.com/carlosframework/platform/internal/moves" |
| +) |
| + |
| +// TestOpenRecordWriteNeedsTheLease (final review F11): the open record's |
| +// create-only PUT runs under the lease-bound context the record |
| +// transitions use, its deadline the lease's, and a fresh lease check |
| +// immediately before it. A console that loses the lease while it is about |
| +// to write the open record (renewal fails, the lease runs out, another |
| +// console takes it) starts no PUT afterwards; the console holding the |
| +// lease writes it with the lease's deadline on its context. |
| +func TestOpenRecordWriteNeedsTheLease(t *testing.T) { |
| + w := newJobWorld(t) |
| + r := w.cutTo("landed") |
| + key := moves.OpenRecordKey(host.E2EHost, r.ID) |
| + var ( |
| + lost bool |
| + afterPut int |
| + deadline time.Time |
| + ) |
| + w.fs.seen = func(ctx context.Context, k string) { |
| + if k != key { |
| + return |
| + } |
| + if lost { |
| + afterPut++ |
| + } |
| + deadline, _ = ctx.Deadline() |
| + } |
| + w.at("open:record", func() { |
| + // Another console takes the lease once this one's runs out; this |
| + // console's clock passes its lease. |
| + body, _ := json.Marshal(map[string]any{"console": "console-other", "until": time.Now().Add(w.skew + 10*time.Minute).UTC()}) |
| + _, etag, _ := w.raw.Get(w.ctx, "control/offline-moves/console.json") |
| + if _, err := w.raw.Put(w.ctx, "control/offline-moves/console.json", body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + console.TestHooks.Advance(2 * time.Minute) |
| + lost = true |
| + }) |
| + w.consoleUntil(3, "at the open record", func() bool { return lost }) |
| + if afterPut != 0 { |
| + t.Fatalf("%d open record PUTs started after the lease was lost", afterPut) |
| + } |
| + if _, _, err := w.raw.Get(w.ctx, key); err == nil { |
| + t.Fatal("the open record was written after the lease was lost") |
| + } |
| + |
| + // The lease back (the other console's has run out): the open record |
| + // is written under the lease's deadline. |
| + w.restart() |
| + lost = false |
| + body, _ := json.Marshal(map[string]any{"console": "console-other", "until": time.Now().Add(w.skew - time.Second).UTC()}) |
| + _, etag, _ := w.raw.Get(w.ctx, "control/offline-moves/console.json") |
| + if _, err := w.raw.Put(w.ctx, "control/offline-moves/console.json", body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + w.run(20, "the open record written", r.ID, func(*console.SelfServeMove) bool { |
| + _, _, err := w.raw.Get(w.ctx, key) |
| + return err == nil |
| + }) |
| + if deadline.IsZero() || deadline.After(time.Now().Add(w.skew+70*time.Second)) { |
| + t.Fatalf("the open record's PUT ran with deadline %v, want the lease's (60 s)", deadline) |
| + } |
| +} |