diff --git a/server/app/goauto/shopeeproduct/auto_match_batch.go b/server/app/goauto/shopeeproduct/auto_match_batch.go index 469ffac..d43bf72 100644 --- a/server/app/goauto/shopeeproduct/auto_match_batch.go +++ b/server/app/goauto/shopeeproduct/auto_match_batch.go @@ -380,7 +380,14 @@ func (service *Service) renewAutoMatchRun(ctx context.Context, run models.Shopee if err := lockAutoMatchRun(ctx, tx, run); err != nil { return err } - return autoMatchOwnedUpdate(autoMatchOwnedRun(tx, run).Update("lease_expires_at", time.Now().UTC().Add(autoMatchLeaseDuration))) + result := autoMatchOwnedRun(tx, run).Update("lease_expires_at", time.Now().UTC().Add(autoMatchLeaseDuration)) + if result.Error == nil && result.RowsAffected == 0 { + // MySQL's changed-row count can be zero when datetime precision + // rounds a rapid renewal to the stored value. Under the same row + // lock, distinguish that no-op from an expired or lost lease. + return lockAutoMatchRun(ctx, tx, run) + } + return autoMatchOwnedUpdate(result) }) } diff --git a/server/app/goauto/shopeeproduct/auto_match_scan_test.go b/server/app/goauto/shopeeproduct/auto_match_scan_test.go index e8451ee..214f4a4 100644 --- a/server/app/goauto/shopeeproduct/auto_match_scan_test.go +++ b/server/app/goauto/shopeeproduct/auto_match_scan_test.go @@ -739,3 +739,46 @@ func TestAutoMatchDeadlineDuringCheckpointReadCannotCommitFalseHead(t *testing.T t.Fatalf("unknown cursor committed: %+v", latest) } } + +func TestAutoMatchRenewZeroChangedRowsRequiresLiveOwnership(t *testing.T) { + for _, change := range []string{"unchanged", "expired", "owner", "multiple_rows"} { + t.Run(change, func(t *testing.T) { + db := openScanTestDB(t) + s := NewService(db) + run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20) + if err != nil { + t.Fatal(err) + } + if err := db.Callback().Update().After("gorm:update").Register("test_renew_zero_changed", func(tx *gorm.DB) { + if tx.Statement.Table != "shopee_spec_auto_match_run" || tx.Error != nil { + return + } + // MySQL reports changed rows by default: datetime(3) may round a + // same-millisecond renewal to the value already stored. + switch change { + case "expired": + err = tx.Session(&gorm.Session{NewDB: true}).Exec("UPDATE shopee_spec_auto_match_run SET lease_expires_at = ? WHERE id = ?", time.Now().UTC().Add(-time.Second), run.ID).Error + case "owner": + err = tx.Session(&gorm.Session{NewDB: true}).Exec("UPDATE shopee_spec_auto_match_run SET lease_owner = ? WHERE id = ?", "replacement-owner", run.ID).Error + } + if err != nil { + tx.AddError(err) + } + tx.RowsAffected = 0 + if change == "multiple_rows" { + tx.RowsAffected = 2 + } + }); err != nil { + t.Fatal(err) + } + err = s.renewAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun) + if change == "unchanged" { + if err != nil { + t.Fatalf("live no-op renewal rejected: %v", err) + } + } else if err != errAutoMatchLeaseLost { + t.Fatalf("lost lease accepted after zero changed rows: %v", err) + } + }) + } +}