fix: undo locks its history entries and marks them only once (WR-11)

Two concurrent undos of one entry both passed the already-undone check and
both restored. Undo and UndoBatch now read the entries FOR UPDATE, and the
final mark is conditional on undone_at IS NULL, refusing with
already_undone when another undo got there first.
This commit is contained in:
2026-10-27 15:30:13 +01:00
parent c2f136ad7d
commit d20f14fe67
2 changed files with 71 additions and 3 deletions

View File

@@ -15,6 +15,7 @@ import (
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The undo refusals (D-13, T-32-27).
@@ -68,8 +69,11 @@ func Undoable(r models.Revision) bool {
// later change touched the same values (undo_stale, naming who and when),
// when the restore would create a redirect loop (undo_loop) or when the
// record it belongs to is gone (undo_gone).
//
// The entry is locked (FOR UPDATE) until tx ends, so a second undo of the
// same entry waits and is then refused as already undone (WR-11).
func (s *Service) Undo(ctx context.Context, tx *gorm.DB, id uint) ([]uint, error) {
rev, err := LoadRevision(tx, id)
rev, err := LoadRevision(tx.Clauses(clause.Locking{Strength: "UPDATE"}), id)
if err != nil {
return nil, err
}
@@ -87,7 +91,7 @@ func (s *Service) Undo(ctx context.Context, tx *gorm.DB, id uint) ([]uint, error
// has nothing left to undo.
func (s *Service) UndoBatch(ctx context.Context, tx *gorm.DB, batch string) ([]uint, error) {
var revs []models.Revision
if err := tx.Where("batch_id = ? AND undone_at IS NULL", batch).Order("id DESC").Find(&revs).Error; err != nil {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("batch_id = ? AND undone_at IS NULL", batch).Order("id DESC").Find(&revs).Error; err != nil {
return nil, err
}
if len(revs) == 0 {
@@ -339,7 +343,14 @@ func (s *Service) undoOne(ctx context.Context, tx *gorm.DB, rev models.Revision)
} else if p, ok := mcpPrincipal(ctx); ok && p.ID != 0 {
updates["undone_by_id"] = p.ID
}
return tx.Model(&models.Revision{}).Where("id = ?", rev.ID).Updates(updates).Error
marked := tx.Model(&models.Revision{}).Where("id = ? AND undone_at IS NULL", rev.ID).Updates(updates)
if marked.Error != nil {
return marked.Error
}
if marked.RowsAffected == 0 {
return &UndoRefusal{Code: UndoDone, Key: "golem15.seo::lang.history.undo_done"}
}
return nil
}
// kindLabels are the phrase keys of the history subjects in the undo_gone