| index b910b484d..bd35ba42c 100644 |
| --- a/internal/console/movesrecovery_test.go |
| +++ b/internal/console/movesrecovery_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "crypto/rand" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "os" |
| "path/filepath" |
| @@ -17,6 +18,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| // The console's move pass (moves Plan 5 Task 7; claim Plan 10's |
| @@ -238,6 +240,17 @@ func (w *movePassWorld) pass(t *testing.T) { |
| } |
| } |
| |
| +// passErr is one recovery pass, returning its error. |
| +func (w *movePassWorld) passErr() error { |
| + a := w.fx.srv.cfg.ClaimAuthority |
| + nop := func(context.Context) error { return nil } |
| + nopObs := func(context.Context, hostclaim.Observed) error { return nil } |
| + r := hostops.Recovery{Moves: w.fx.srv.movesRecovery, MoveLogs: w.fx.srv.drainMoveLog, MoveFenceAuthority: w.auth, |
| + Controller: hostops.Controller{Store: w.fx.srv.cfg.Releases, Control: a.Control, Authority: a, Now: time.Now, NewID: claimOperationID}, |
| + Purge: nopObs, Placement: nopObs, SweepRecords: nop, SweepPurges: nop, SweepAdmissions: nop} |
| + return a.Control.RecoveryPass(context.Background(), r) |
| +} |
| + |
| func (w *movePassWorld) claim(t *testing.T) *hostclaim.Claim { |
| t.Helper() |
| o, err := hostclaim.Read(context.Background(), w.fx.rel, "acme.go.tito.io") |
| @@ -998,3 +1011,59 @@ func TestUnresolvedMoveLogRowsDoNotStarveTheBatch(t *testing.T) { |
| t.Fatalf("%d hour-old warnings, want one per unresolved row (%d)", n, moveLogDrainBatch) |
| } |
| } |
| + |
| +// Fix round 4, ruling (b): a real CAS conflict inside g.Claim. Another writer |
| +// updates the claim between the grant's observation and the release's CAS, |
| +// so the release fails: nothing is published, the row stays unconfirmed, |
| +// and a later pass releases, confirms and publishes it. |
| +func TestReleaseCASConflictPublishesNothing(t *testing.T) { |
| + w := landingMovePass(t, "code-mvpass-release-cas") |
| + committedAndOpened(t, w) |
| + w.pass(t) |
| + w.advance(moveCloseGrace + time.Minute) |
| + w.pass(t) // the close |
| + d := w.fx.rel.(*deletableStore) |
| + claimKey, err := hostclaim.Key("acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + conflicts := 0 |
| + d.putHook = func(ctx context.Context, key string) { |
| + if key != claimKey || conflicts > 0 { |
| + return |
| + } |
| + conflicts++ |
| + body, etag, err := d.Store.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The concurrent writer: the same claim, rewritten, so its version |
| + // moves on under the release's CAS. |
| + if _, err := d.Store.Put(ctx, key, append(body, '\n'), etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + err = w.passErr() |
| + d.putHook = nil |
| + if conflicts != 1 || !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("the release's claim CAS: conflicts=%d err=%v, want a CAS conflict", conflicts, err) |
| + } |
| + if c := w.claim(t); c.Op.Kind == hostclaim.KindServing { |
| + t.Fatal("released despite the conflict") |
| + } |
| + if _, ok := moveLogOf(t, w)["claim-released"]; ok { |
| + t.Fatal("claim_released published for a release that lost its CAS") |
| + } |
| + if p := pendingLog(t, w); strings.Join(p, " ") != "claim-released(unconfirmed)" { |
| + t.Fatalf("pending %v", p) |
| + } |
| + w.advance(2 * time.Hour) // past the unresolved row's backoff |
| + w.pass(t) |
| + if c := w.claim(t); c.Op.Kind != hostclaim.KindServing { |
| + t.Fatalf("not released on the next pass: %+v", c.Op) |
| + } |
| + if ev := moveLogOf(t, w)["claim-released"]; ev.Event != "claim_released" { |
| + t.Fatalf("not published after the release: %+v", moveLogOf(t, w)) |
| + } |
| + noPendingLog(t, w) |
| +} |