carlos / platform Public

moves-phase1-6 Task 3 fix round 4 (4): a real CAS conflict inside the release

TestReleaseCASConflictPublishesNothing: a concurrent writer rewrites the
claim between the grant's observation and g.Claim's CAS (a put hook on the
fixture's store), so the release fails with ErrCASConflict; nothing is
published, the row stays unconfirmed, and the next pass releases, confirms
and publishes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev abb2e2347541e723384e60fc0b470e64a4df2799 parent 66e100d
2 files changed, +79 −0
  • internal/console/movesrecovery_test.go +69 −0
  • internal/console/view_test.go +10 −0
diff --git a/internal/console/movesrecovery_test.go b/internal/console/movesrecovery_test.go
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)
+}
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index c830df918..faaeffc31 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -20,6 +20,16 @@ import (
type deletableStore struct {
release.Store
root string
+ // putHook, when set, runs before each Put reaches the store: a test's
+ // concurrent writer.
+ putHook func(ctx context.Context, key string)
+}
+
+func (d *deletableStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if h := d.putHook; h != nil {
+ h(ctx, key)
+ }
+ return d.Store.Put(ctx, key, body, ifMatch)
}
// DeleteIfMatch passes the conditional delete through to the wrapped store,