Commit history detached from cancellation on every path, not just failure (#73)
Build image / build-and-push (push) Successful in 7s
Build image / build-and-push (push) Successful in 7s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #73.
This commit is contained in:
@@ -130,11 +130,7 @@ func (s *Service) WithChangeSet(ctx context.Context, actorID, gardenID int64, op
|
||||
// which is exactly the situation undo exists for. Record what happened,
|
||||
// mark the summary, and still report the failure.
|
||||
sc.summary = partialSummary(sc.summary)
|
||||
// Detached from cancellation on purpose. The commonest reason fn failed is
|
||||
// that ctx was cancelled or timed out — and using that same dead context
|
||||
// to record what committed would fail too, losing the history for changes
|
||||
// that really happened. That is precisely the case this path exists for.
|
||||
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, nil); cerr != nil {
|
||||
if _, cerr := s.commitScope(ctx, sc, nil); cerr != nil {
|
||||
slog.Error("service: partial turn could not be recorded", "error", cerr, "garden", gardenID)
|
||||
}
|
||||
return nil, err
|
||||
@@ -154,11 +150,19 @@ func partialSummary(summary string) string {
|
||||
// commitScope writes a scope's buffered revisions as one change set. revertsID is
|
||||
// set only by RevertChangeSet. A scope with no revisions writes nothing — an
|
||||
// operation that changed nothing doesn't belong in history.
|
||||
//
|
||||
// The write is DETACHED FROM CANCELLATION, always, and that belongs here rather
|
||||
// than at each call site so no caller can be the one that forgets. By the time a
|
||||
// commit runs, the data changes it describes have already been written — so
|
||||
// cancelling it cannot undo anything. It can only lose the record of what
|
||||
// happened and leave real changes with no way to undo them, which is the one
|
||||
// thing the whole change-set design exists to prevent.
|
||||
func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) {
|
||||
revs := sc.taken()
|
||||
if len(revs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
return s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||
GardenID: sc.gardenID,
|
||||
ActorID: sc.actorID,
|
||||
@@ -199,9 +203,13 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
|
||||
sc.append(revs)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||
GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary,
|
||||
}, revs); err != nil {
|
||||
// Auto-scope: one operation, its own change set. Written through the same
|
||||
// detached path as everything else — a REST client that hangs up right after
|
||||
// its PATCH landed must not leave that change without history, and this is
|
||||
// the path virtually every mutation takes.
|
||||
auto := &changeScope{gardenID: gardenID, actorID: actorID, source: domain.SourceUI, summary: summary}
|
||||
auto.append(revs)
|
||||
if _, err := s.commitScope(ctx, auto, nil); err != nil {
|
||||
slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary)
|
||||
}
|
||||
}
|
||||
@@ -312,10 +320,9 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
|
||||
changes, conflict, err := s.applyInverse(ctx, r, applied)
|
||||
if err != nil {
|
||||
// Record what actually landed before surfacing the failure, so the
|
||||
// partial revert is visible and undoable rather than orphaned. Detached
|
||||
// from cancellation for the same reason as WithChangeSet's path: a
|
||||
// timed-out context can't be used to write the record of what it did.
|
||||
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, &target.ID); cerr != nil {
|
||||
// partial revert is visible and undoable rather than orphaned.
|
||||
// (commitScope detaches from cancellation itself.)
|
||||
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
|
||||
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
|
||||
}
|
||||
return nil, nil, err
|
||||
|
||||
@@ -805,3 +805,91 @@ func countsTotal(counts []domain.ChangeCount) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestSucceededTurnRecordsEvenIfTheCallerWentAway is the production bug.
|
||||
//
|
||||
// The failure path was detached from cancellation; the SUCCESS path was not. An
|
||||
// agent turn whose client disconnects can still COMPLETE — and then the success
|
||||
// path committed with a dead context, the write failed, and real changes were
|
||||
// left with no change set and no way to undo them. Found live with 18 plantings
|
||||
// behind no history at all.
|
||||
//
|
||||
// Nothing about a commit needs the caller to still be there: by the time it
|
||||
// runs, the data it describes has already been written.
|
||||
func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
before := len(history(t, s, owner, g.ID))
|
||||
|
||||
// fn does its work and SUCCEEDS, but the caller goes away before it returns.
|
||||
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
|
||||
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
|
||||
}, func(ctx context.Context) error {
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
cancel() // the client disconnects, mid-turn, after the work landed
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithChangeSet: %v", err)
|
||||
}
|
||||
if cs == nil {
|
||||
t.Fatal("the turn changed things but produced no change set")
|
||||
}
|
||||
|
||||
after := history(t, s, owner, g.ID)
|
||||
if len(after) != before+1 {
|
||||
t.Fatalf("recorded %d change sets, want 1", len(after)-before)
|
||||
}
|
||||
if after[0].Summary != "plant beans in the second bed" {
|
||||
t.Errorf("summary = %q; a completed turn shouldn't be marked partial", after[0].Summary)
|
||||
}
|
||||
// And it's undoable, which is the entire point.
|
||||
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("the recorded turn should be undoable: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
active, _ := s.store.ListActivePlantingsForObject(context.Background(), bed.ID)
|
||||
if len(active) != 0 {
|
||||
t.Errorf("%d plantings survived the undo", len(active))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoScopedMutationRecordsEvenIfTheCallerWentAway — the same rule for the
|
||||
// path virtually every mutation takes.
|
||||
//
|
||||
// A plain REST PATCH auto-scopes into its own change set. If the client hangs up
|
||||
// between the row landing and the change set being written, that change is
|
||||
// orphaned exactly as an agent turn's was — and this path is used far more.
|
||||
func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
before := len(history(t, s, owner, g.ID))
|
||||
|
||||
// The row write and the history write share this context; cancelling after
|
||||
// the mutation returns is the client hanging up mid-request.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
|
||||
t.Fatalf("UpdateObject: %v", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
after := history(t, s, owner, g.ID)
|
||||
if len(after) != before+1 {
|
||||
t.Fatalf("recorded %d change sets, want 1 — the move is otherwise un-undoable", len(after)-before)
|
||||
}
|
||||
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, after[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("the recorded move should be undoable: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
back, _ := s.store.GetObject(context.Background(), bed.ID)
|
||||
if back.XCM != bed.XCM {
|
||||
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user