fix(server): isolate offline scan contention per device (#378)

This commit is contained in:
QiuSW
2026-10-10 10:19:44 +08:00
parent 010e9b90ae
commit d84728dde9
3 changed files with 545 additions and 105 deletions
@@ -112,48 +112,31 @@ func TestMySQLAgentDiagnostics(t *testing.T) {
})
return s, d, token
}
t.Run("v4", func(t *testing.T) { runOfflineV4MySQLTests(t, db, seed) })
t.Run("scan_revalidates_heartbeat_after_lock_wait", func(t *testing.T) {
s, d, _ := seed(t)
lock := db.Begin()
if lock.Error != nil {
t.Fatal("begin failed")
}
defer lock.Rollback()
var device models.AgentDevice
if lock.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, d.DeviceID).Error != nil {
t.Fatal("lock failed")
}
done := make(chan struct {
n int64
err error
}, 1)
go func() {
n, e := s.MarkStaleDevicesOffline(context.Background(), DefaultOfflineThreshold)
done <- struct {
n int64
err error
}{n, e}
}()
select {
case <-done:
t.Fatal("scan did not wait for device lock")
case <-time.After(100 * time.Millisecond):
}
if lock.Model(&models.AgentDevice{}).Where("id = ?", d.DeviceID).Update("last_heartbeat_at", s.Now()).Error != nil {
t.Fatal("refresh failed")
}
if lock.Commit().Error != nil {
t.Fatal("commit failed")
}
select {
case result := <-done:
if result.err != nil || result.n != 0 {
t.Fatalf("fresh device falsely marked offline: count=%d error=%v", result.n, result.err != nil)
t.Run("scan_revalidates_real_heartbeat_after_candidate_read", func(t *testing.T) {
s, d, token := seed(t)
type scanMarker struct{}
callback := "v4_heartbeat_before_scan_lock"
if db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Context.Value(scanMarker{}) != true || tx.Statement.Table != "agent_device" {
return
}
case <-time.After(5 * time.Second):
t.Fatal("scan stuck")
if _, ok := tx.Statement.Clauses["FOR"]; !ok {
return
}
if _, err := s.Heartbeat(context.Background(), HeartbeatRequest{RequestID: uuid.NewString()}, token); err != nil {
tx.AddError(errors.New("concurrent heartbeat failed"))
}
}) != nil {
t.Fatal("register heartbeat barrier failed")
}
defer db.Callback().Query().Remove(callback)
n, err := s.MarkStaleDevicesOffline(context.WithValue(context.Background(), scanMarker{}, true), DefaultOfflineThreshold)
if err != nil || n != 0 {
t.Fatal("fresh heartbeat falsely marked offline")
}
requireOfflineDevice(t, db, d.DeviceID, models.DeviceStatusOnline, 0)
})
t.Run("concurrent_scans_and_resume_are_unique", func(t *testing.T) {
s, d, testDeviceToken := seed(t)
@@ -317,12 +300,8 @@ func TestMySQLAgentDiagnostics(t *testing.T) {
}
t.Fatal("reset lock failed")
}
var mysqlErr *driver.MySQLError
if scanErr == nil || !errors.As(scanErr, &mysqlErr) || mysqlErr.Number != 3572 {
if mysqlErr != nil {
t.Fatalf("scan must decline busy task without deadlock; mysql_errno=%d", mysqlErr.Number)
}
t.Fatal("scan must decline busy task")
if scanErr != nil {
t.Fatal("scan must skip busy task without returning contention")
}
var eventCount int64
db.Model(&models.AgentDeviceStatusEvent{}).Where("device_id = ?", d.DeviceID).Count(&eventCount)
+89 -60
View File
@@ -9,9 +9,12 @@ import (
"go-admin/app/goauto/models"
log "github.com/go-admin-team/go-admin-core/logger"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
const (
@@ -137,8 +140,8 @@ func tokenInvalidError() error {
return &ServiceError{Code: CodeTokenInvalid, Message: "设备 Token 无效", Retryable: false}
}
// MarkStaleDevicesOffline atomically marks stale devices offline and fails any
// running task held by those devices. It returns the number of devices changed.
// MarkStaleDevicesOffline commits each device, its running tasks and its event
// independently. The count always describes committed device transitions.
func (service *Service) MarkStaleDevicesOffline(ctx context.Context, threshold time.Duration) (int64, error) {
if threshold <= 0 {
return 0, fmt.Errorf("offline threshold must be positive")
@@ -146,7 +149,6 @@ func (service *Service) MarkStaleDevicesOffline(ctx context.Context, threshold t
now := service.Now()
cutoff := now.Add(-threshold)
var changed int64
var events []models.AgentDeviceStatusEvent
var deviceIDs []uint64
if err := service.DB.WithContext(ctx).Model(&models.AgentDevice{}).
Where("status = ? AND COALESCE(last_heartbeat_at, created_at) < ?", models.DeviceStatusOnline, cutoff).
@@ -156,15 +158,20 @@ func (service *Service) MarkStaleDevicesOffline(ctx context.Context, threshold t
if len(deviceIDs) == 0 {
return 0, nil
}
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Lock primary keys in deterministic order, not the mutable status index:
// a status-range FOR UPDATE can deadlock concurrent scans on gap locks.
// Candidate selection is only a hint; revalidate the current locked row.
for _, deviceID := range deviceIDs {
for _, deviceID := range deviceIDs {
if err := ctx.Err(); err != nil {
return changed, err
}
var event *models.AgentDeviceStatusEvent
// Expected NOWAIT contention must not be printed by GORM with SQL or
// parameters. The fixed warning below is the only contention log.
err := service.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Transaction(func(tx *gorm.DB) error {
var device models.AgentDevice
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, deviceID).Error; err != nil {
// The unlocked candidate list is only a hint. Lock the exact primary
// key without waiting, then revalidate its current state and heartbeat.
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).First(&device, deviceID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
return nil
}
return internalError(err)
}
@@ -173,77 +180,103 @@ func (service *Service) MarkStaleDevicesOffline(ctx context.Context, threshold t
last = *device.LastHeartbeatAt
}
if device.Status != models.DeviceStatusOnline || !last.Before(cutoff) {
continue
return nil
}
// Existing Admin reset paths lock task -> device, while Agent paths
// lock device -> task. Never wait for a task while holding this device:
// contention aborts this atomic scan and the next scan retries it.
if err := lockOfflineTasksNowait(tx, device.ID); err != nil {
return err
}
result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
Where("device_id = ? AND status = ?", device.ID, models.TaskStatusRunning).
Updates(map[string]any{
"status": models.TaskStatusFailed, "active_slot": gorm.Expr("NULL"), "device_run_slot": gorm.Expr("NULL"),
"lease_expires_at": gorm.Expr("NULL"), "error_code": "DEVICE_OFFLINE",
"error_message": "设备心跳超时,任务失败且不会自动重试", "finished_at": now,
})
if result.Error != nil {
return internalError(result.Error)
}
failed, unknown, err := markOfflinePurchaseTasks(tx, []uint64{device.ID}, now)
// Existing Admin reset paths lock task -> device, while collection
// Claim locks device -> task. Never wait for a task holding this device:
// contention rolls back only this device; a later scan retries it.
tasks, err := lockOfflineTasksNowait(tx, device.ID)
if err != nil {
return err
}
event := models.AgentDeviceStatusEvent{DeviceID: device.ID, OccurredAt: now, FromStatus: device.Status, ToStatus: models.DeviceStatusOffline, Reason: "heartbeat_timeout", LastHeartbeatAt: device.LastHeartbeatAt, FailedTaskCount: result.RowsAffected + failed, OrderResultUnknownCount: unknown}
result = tx.Model(&models.AgentDevice{}).Where("id = ? AND status = ?", device.ID, models.DeviceStatusOnline).
var collectionFailed int64
if len(tasks.CollectionIDs) > 0 {
result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
Where("id IN ? AND status = ?", tasks.CollectionIDs, models.TaskStatusRunning).
Updates(map[string]any{
"status": models.TaskStatusFailed, "active_slot": gorm.Expr("NULL"), "device_run_slot": gorm.Expr("NULL"),
"lease_expires_at": gorm.Expr("NULL"), "error_code": "DEVICE_OFFLINE",
"error_message": "设备心跳超时,任务失败且不会自动重试", "finished_at": now,
})
if result.Error != nil {
return internalError(result.Error)
}
collectionFailed = result.RowsAffected
}
failed, unknown, err := markOfflinePurchaseTasks(tx, tasks, now)
if err != nil {
return err
}
event = &models.AgentDeviceStatusEvent{DeviceID: device.ID, OccurredAt: now, FromStatus: device.Status, ToStatus: models.DeviceStatusOffline, Reason: "heartbeat_timeout", LastHeartbeatAt: device.LastHeartbeatAt, FailedTaskCount: collectionFailed + failed, OrderResultUnknownCount: unknown}
result := tx.Model(&models.AgentDevice{}).Where("id = ? AND status = ?", device.ID, models.DeviceStatusOnline).
Update("status", models.DeviceStatusOffline)
if result.Error != nil {
return internalError(result.Error)
}
if err := insertDeviceStatusEvent(tx, &event); err != nil {
return err
return insertDeviceStatusEvent(tx, event)
})
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return changed, ctxErr
}
events = append(events, event)
changed += result.RowsAffected
var mysqlErr *mysqlDriver.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 3572 {
log.Warnf("agent offline scan skipped: device_id=%d classification=lock_busy", deviceID)
continue
}
return changed, err
}
if event != nil {
changed++
logDeviceStatusEvent(*event)
}
return nil
})
if err != nil {
return 0, err
}
for _, event := range events {
logDeviceStatusEvent(event)
}
return changed, err
return changed, nil
}
func lockOfflineTasksNowait(tx *gorm.DB, deviceID uint64) error {
var collectionIDs, purchaseIDs []uint64
type offlineTasks struct {
CollectionIDs []uint64
PurchaseRunningIDs []uint64
PurchaseSubmitStartedIDs []uint64
}
func lockOfflineTasksNowait(tx *gorm.DB, deviceID uint64) (offlineTasks, error) {
var tasks offlineTasks
locking := clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}
if err := tx.Model(&models.CollectionTask{}).Clauses(locking).
Where("device_id = ? AND status = ?", deviceID, models.TaskStatusRunning).
Order("id").Pluck("id", &collectionIDs).Error; err != nil {
return internalError(err)
Order("id").Pluck("id", &tasks.CollectionIDs).Error; err != nil {
return tasks, internalError(err)
}
if err := tx.Model(&models.PurchaseTask{}).Clauses(locking).
var purchases []struct {
ID uint64
Status string
}
if err := tx.Model(&models.PurchaseTask{}).Select("id, status").Clauses(locking).
Where("device_id = ? AND status IN ?", deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}).
Order("id").Pluck("id", &purchaseIDs).Error; err != nil {
return internalError(err)
Order("id").Find(&purchases).Error; err != nil {
return tasks, internalError(err)
}
return nil
for _, task := range purchases {
if task.Status == models.PurchaseTaskStatusRunning {
tasks.PurchaseRunningIDs = append(tasks.PurchaseRunningIDs, task.ID)
} else {
tasks.PurchaseSubmitStartedIDs = append(tasks.PurchaseSubmitStartedIDs, task.ID)
}
}
return tasks, nil
}
func markOfflinePurchaseTasks(tx *gorm.DB, deviceIDs []uint64, now time.Time) (int64, int64, error) {
func markOfflinePurchaseTasks(tx *gorm.DB, tasks offlineTasks, now time.Time) (int64, int64, error) {
failed, err := markOfflinePurchaseStatus(
tx, deviceIDs, models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusFailed,
tx, tasks.PurchaseRunningIDs, models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusFailed,
"failed", "设备心跳超时,采购任务失败且不会自动重试", now,
)
if err != nil {
return 0, 0, err
}
unknown, err := markOfflinePurchaseStatus(
tx, deviceIDs, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderResultUnknown,
tx, tasks.PurchaseSubmitStartedIDs, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderResultUnknown,
"order_result_unknown", "设备心跳超时,订单结果未知,请人工核对,禁止自动重试", now,
)
return failed, unknown, err
@@ -251,19 +284,15 @@ func markOfflinePurchaseTasks(tx *gorm.DB, deviceIDs []uint64, now time.Time) (i
func markOfflinePurchaseStatus(
tx *gorm.DB,
deviceIDs []uint64,
taskIDs []uint64,
fromStatus string,
toStatus string,
resultType string,
errorMessage string,
now time.Time,
) (int64, error) {
var taskIDs []uint64
if err := tx.Model(&models.PurchaseTask{}).
Where("device_id IN ? AND status = ?", deviceIDs, fromStatus).
Pluck("id", &taskIDs).Error; err != nil {
return 0, internalError(err)
}
// Reuse the IDs and source states from the locking current read. An
// ordinary SELECT here could see an older REPEATABLE READ snapshot.
if len(taskIDs) == 0 {
return 0, nil
}
@@ -0,0 +1,432 @@
package device_test
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
"go-admin/app/goauto/task"
driver "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type offlineSeed func(*testing.T) (*device.Service, device.RegisterResponse, string)
func offlineCollection(t *testing.T, db *gorm.DB, deviceID uint64, status string) models.CollectionTask {
t.Helper()
product := models.PDDProduct{GoodsID: strings.ReplaceAll(uuid.NewString(), "-", ""), URL: "https://example.invalid/test"}
rule := models.CollectionRule{Name: "test", ContentJSON: `{"steps":[]}`}
if err := db.Create(&product).Error; err != nil {
t.Fatal("synthetic product failed")
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal("synthetic rule failed")
}
task := models.CollectionTask{PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: &deviceID, Status: status, URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON}
if err := db.Create(&task).Error; err != nil {
t.Fatal("synthetic collection task failed")
}
return task
}
func offlinePurchase(t *testing.T, db *gorm.DB, deviceID uint64, status string) models.PurchaseTask {
t.Helper()
product := models.PDDProduct{GoodsID: strings.ReplaceAll(uuid.NewString(), "-", ""), URL: "https://example.invalid/test"}
if err := db.Create(&product).Error; err != nil {
t.Fatal("synthetic product failed")
}
task := models.PurchaseTask{PDDProductID: product.ID, DeviceID: &deviceID, ExecutionMode: models.PurchaseExecutionModeRehearsal, Status: status, PDDURLSnapshot: product.URL, PDDGoodsIDSnapshot: product.GoodsID, Quantity: 1, ReferenceUnitPriceCent: 100, MinUnitPriceCent: 20, MaxUnitPriceCent: 150, Currency: "CNY", RuleType: "pddPurchase", RuleSchemaVersion: 1, RequiredCapabilitiesJSON: `[]`, RuleSnapshot: `{}`, CreateRequestID: uuid.NewString()}
if err := db.Create(&task).Error; err != nil {
t.Fatal("synthetic purchase task failed")
}
return task
}
func requireOfflineDevice(t *testing.T, db *gorm.DB, id uint64, status string, events int64) {
t.Helper()
var row models.AgentDevice
if db.First(&row, id).Error != nil || row.Status != status {
t.Fatalf("device %d status=%s want=%s", id, row.Status, status)
}
var count int64
if db.Model(&models.AgentDeviceStatusEvent{}).Where("device_id = ?", id).Count(&count).Error != nil || count != events {
t.Fatalf("device %d events=%d want=%d", id, count, events)
}
}
func runOfflineV4MySQLTests(t *testing.T, db *gorm.DB, seed offlineSeed) {
for _, busyKind := range []string{"device", "collection", "purchase"} {
t.Run("busy_"+busyKind+"_does_not_block_other_devices", func(t *testing.T) {
s, first, _ := seed(t)
_, busy, _ := seed(t)
_, last, _ := seed(t)
var lockedModel any
var lockedID uint64
switch busyKind {
case "device":
lockedModel = &models.AgentDevice{}
lockedID = busy.DeviceID
case "collection":
task := offlineCollection(t, db, busy.DeviceID, models.TaskStatusRunning)
lockedModel = &models.CollectionTask{}
lockedID = task.ID
case "purchase":
task := offlinePurchase(t, db, busy.DeviceID, models.PurchaseTaskStatusRunning)
lockedModel = &models.PurchaseTask{}
lockedID = task.ID
}
holder := db.Begin()
if holder.Error != nil {
t.Fatal("begin failed")
}
defer holder.Rollback()
if holder.Clauses(clause.Locking{Strength: "UPDATE"}).First(lockedModel, lockedID).Error != nil {
t.Fatal("synthetic lock failed")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
n, err := s.MarkStaleDevicesOffline(ctx, device.DefaultOfflineThreshold)
if err != nil || n != 2 {
t.Fatalf("busy %s prevented unrelated devices: changed=%d failed=%v", busyKind, n, err != nil)
}
requireOfflineDevice(t, db, first.DeviceID, models.DeviceStatusOffline, 1)
requireOfflineDevice(t, db, busy.DeviceID, models.DeviceStatusOnline, 0)
requireOfflineDevice(t, db, last.DeviceID, models.DeviceStatusOffline, 1)
if holder.Rollback().Error != nil {
t.Fatal("release failed")
}
n, err = s.MarkStaleDevicesOffline(context.Background(), device.DefaultOfflineThreshold)
if err != nil || n != 1 {
t.Fatal("busy device not retried on next scan")
}
})
}
for _, target := range []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted} {
t.Run("second_device_current_read_"+target, func(t *testing.T) {
s, first, _ := seed(t)
_, second, _ := seed(t)
offlinePurchase(t, db, first.DeviceID, models.PurchaseTaskStatusRunning)
task := offlinePurchase(t, db, second.DeviceID, models.PurchaseTaskStatusPending)
attempt := models.PurchaseTaskAttempt{TaskID: task.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &second.DeviceID, RuleSnapshotHash: "test", SpecDecisionSnapshot: `{}`}
if db.Create(&attempt).Error != nil {
t.Fatal("synthetic attempt failed")
}
callback := "v4_advance_second_device"
if db.Callback().Create().After("gorm:create").Register(callback, func(tx *gorm.DB) {
event, ok := tx.Statement.Dest.(*models.AgentDeviceStatusEvent)
if !ok || event.DeviceID != first.DeviceID {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := db.WithContext(ctx).Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": target, "device_run_slot": 1}).Error; err != nil {
tx.AddError(errors.New("synthetic second device advancement failed"))
}
}) != nil {
t.Fatal("register advancement failed")
}
defer db.Callback().Create().Remove(callback)
n, err := s.MarkStaleDevicesOffline(context.Background(), device.DefaultOfflineThreshold)
if err != nil || n != 2 {
t.Fatalf("scan changed=%d failed=%v", n, err != nil)
}
var stored models.PurchaseTask
var storedAttempt models.PurchaseTaskAttempt
var event models.AgentDeviceStatusEvent
if db.First(&stored, task.ID).Error != nil || db.First(&storedAttempt, attempt.ID).Error != nil || db.Where("device_id = ?", second.DeviceID).First(&event).Error != nil {
t.Fatal("read outcome failed")
}
expected := models.PurchaseTaskStatusFailed
failed, unknown := int64(1), int64(0)
if target == models.PurchaseTaskStatusOrderSubmitStarted {
expected = models.PurchaseTaskStatusOrderResultUnknown
failed, unknown = 0, 1
}
if stored.Status != expected || storedAttempt.Status != models.PurchaseAttemptStatusFailed || event.FailedTaskCount != failed || event.OrderResultUnknownCount != unknown {
t.Fatalf("stale task snapshot: status=%s attempt=%s failed=%d unknown=%d", stored.Status, storedAttempt.Status, event.FailedTaskCount, event.OrderResultUnknownCount)
}
})
}
t.Run("event_failure_preserves_prior_device_commit", func(t *testing.T) {
s, first, _ := seed(t)
_, second, _ := seed(t)
_, last, _ := seed(t)
task := offlineCollection(t, db, second.DeviceID, models.TaskStatusRunning)
statement := fmt.Sprintf("CREATE TRIGGER v4_reject_event BEFORE INSERT ON agent_device_status_event FOR EACH ROW BEGIN IF NEW.device_id = %d THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'synthetic_failure'; END IF; END", second.DeviceID)
if db.Exec(statement).Error != nil {
t.Fatal("test trigger failed")
}
defer db.Exec("DROP TRIGGER IF EXISTS v4_reject_event")
n, err := s.MarkStaleDevicesOffline(context.Background(), device.DefaultOfflineThreshold)
if err == nil || n != 1 {
t.Fatalf("must return earlier committed count on event failure: changed=%d failed=%v", n, err != nil)
}
requireOfflineDevice(t, db, first.DeviceID, models.DeviceStatusOffline, 1)
requireOfflineDevice(t, db, second.DeviceID, models.DeviceStatusOnline, 0)
requireOfflineDevice(t, db, last.DeviceID, models.DeviceStatusOnline, 0)
var stored models.CollectionTask
if db.First(&stored, task.ID).Error != nil || stored.Status != models.TaskStatusRunning {
t.Fatal("current task escaped rollback")
}
if db.Exec("DROP TRIGGER v4_reject_event").Error != nil {
t.Fatal("remove trigger failed")
}
n, err = s.MarkStaleDevicesOffline(context.Background(), device.DefaultOfflineThreshold)
if err != nil || n != 2 {
t.Fatal("next scan retry failed")
}
})
for _, kind := range []string{"1213", "1205", "text_3572", "cancel", "cancel_with_3572"} {
t.Run("non_contention_error_"+kind, func(t *testing.T) {
s, first, _ := seed(t)
_, second, _ := seed(t)
_, last, _ := seed(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
callback := "v4_inject_non_contention"
if db.Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) {
row, ok := tx.Statement.Dest.(*models.AgentDevice)
if !ok || row.ID != second.DeviceID {
return
}
if _, locking := tx.Statement.Clauses["FOR"]; !locking {
return
}
switch kind {
case "1213":
tx.AddError(&driver.MySQLError{Number: 1213, Message: "synthetic"})
case "1205":
tx.AddError(&driver.MySQLError{Number: 1205, Message: "synthetic"})
case "text_3572":
tx.AddError(errors.New("synthetic 3572"))
case "cancel":
cancel()
tx.AddError(context.Canceled)
case "cancel_with_3572":
cancel()
tx.AddError(&driver.MySQLError{Number: 3572, Message: "synthetic"})
}
}) != nil {
t.Fatal("register injection failed")
}
defer db.Callback().Query().Remove(callback)
n, err := s.MarkStaleDevicesOffline(ctx, device.DefaultOfflineThreshold)
if err == nil || n != 1 {
t.Fatalf("non-contention error swallowed or lost prior commit: changed=%d failed=%v", n, err != nil)
}
if (kind == "cancel" || kind == "cancel_with_3572") && !errors.Is(err, context.Canceled) {
t.Fatal("cancellation not preserved")
}
requireOfflineDevice(t, db, first.DeviceID, models.DeviceStatusOffline, 1)
requireOfflineDevice(t, db, second.DeviceID, models.DeviceStatusOnline, 0)
requireOfflineDevice(t, db, last.DeviceID, models.DeviceStatusOnline, 0)
})
}
for _, domain := range []string{"collection_claim", "purchase_replayed_claim"} {
t.Run("actual_"+domain+"_does_not_deadlock_scan", func(t *testing.T) {
s, d, token := seed(t)
_, other, _ := seed(t)
type claimMarker struct{}
locked, release := make(chan struct{}), make(chan struct{})
var once sync.Once
callback := "v4_actual_claim_barrier"
if db.Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Context.Value(claimMarker{}) != true {
return
}
lockTable := "agent_device"
if domain == "purchase_replayed_claim" {
lockTable = "purchase_task"
}
if tx.Statement.Table != lockTable {
return
}
if _, ok := tx.Statement.Clauses["FOR"]; !ok {
return
}
once.Do(func() { close(locked); <-release })
}) != nil {
t.Fatal("register actual claim barrier failed")
}
defer db.Callback().Query().Remove(callback)
ctx, cancel := context.WithTimeout(context.WithValue(context.Background(), claimMarker{}, true), 5*time.Second)
defer cancel()
done := make(chan error, 1)
var taskID uint64
if domain == "collection_claim" {
record := offlineCollection(t, db, d.DeviceID, models.TaskStatusPending)
taskID = record.ID
service := task.NewService(db)
service.Now = s.Now
go func() {
_, err := service.Claim(ctx, record.ID, task.ActionRequest{RequestID: uuid.NewString()}, token)
done <- err
}()
} else {
record := offlinePurchase(t, db, d.DeviceID, models.PurchaseTaskStatusRunning)
taskID = record.ID
requestID := uuid.NewString()
if db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", record.ID).Update("claim_request_id", requestID).Error != nil {
t.Fatal("replay fixture failed")
}
service := purchase.NewService(db)
service.Now = s.Now
go func() {
_, err := service.Claim(ctx, record.ID, purchase.ActionRequest{RequestID: requestID}, token)
done <- err
}()
}
select {
case <-locked:
case <-ctx.Done():
close(release)
t.Fatal("actual claim lock not reached")
}
scanCtx, stopScan := context.WithTimeout(context.Background(), 2*time.Second)
n, err := s.MarkStaleDevicesOffline(scanCtx, device.DefaultOfflineThreshold)
stopScan()
close(release)
claimErr := <-done
if err != nil || claimErr != nil || n != 1 {
t.Fatalf("claim/scan conflict: changed=%d scanFailed=%v claimFailed=%v", n, err != nil, claimErr != nil)
}
requireOfflineDevice(t, db, d.DeviceID, models.DeviceStatusOnline, 0)
requireOfflineDevice(t, db, other.DeviceID, models.DeviceStatusOffline, 1)
if domain == "collection_claim" {
var stored models.CollectionTask
if db.First(&stored, taskID).Error != nil || stored.Status != models.TaskStatusPending || stored.LeaseExpiresAt == nil || stored.ClaimRequestID == nil {
t.Fatal("claim partially persisted")
}
}
})
}
t.Run("real_heartbeat_waiting_for_scan_resumes_once", func(t *testing.T) {
s, d, token := seed(t)
type scanMarker struct{}
type heartbeatMarker struct{}
locked, release, heartbeatStarted := make(chan struct{}), make(chan struct{}), make(chan struct{})
scanCallback, heartbeatCallback := "v4_scan_heartbeat_barrier", "v4_heartbeat_scan_barrier"
if db.Callback().Query().After("gorm:query").Register(scanCallback, func(tx *gorm.DB) {
if tx.Statement.Context.Value(scanMarker{}) == true && tx.Statement.Table == "agent_device" {
if _, ok := tx.Statement.Clauses["FOR"]; ok {
close(locked)
<-release
}
}
}) != nil {
t.Fatal("register scan barrier failed")
}
defer db.Callback().Query().Remove(scanCallback)
if db.Callback().Query().Before("gorm:query").Register(heartbeatCallback, func(tx *gorm.DB) {
if tx.Statement.Context.Value(heartbeatMarker{}) == true && tx.Statement.Table == "agent_device" {
if _, ok := tx.Statement.Clauses["FOR"]; ok {
close(heartbeatStarted)
}
}
}) != nil {
t.Fatal("register heartbeat barrier failed")
}
defer db.Callback().Query().Remove(heartbeatCallback)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
scanDone, heartbeatDone := make(chan error, 1), make(chan error, 1)
go func() {
n, err := s.MarkStaleDevicesOffline(context.WithValue(ctx, scanMarker{}, true), device.DefaultOfflineThreshold)
if err == nil && n != 1 {
err = errors.New("wrong scan count")
}
scanDone <- err
}()
select {
case <-locked:
case <-ctx.Done():
close(release)
t.Fatal("scan lock not reached")
}
go func() {
_, err := s.Heartbeat(context.WithValue(ctx, heartbeatMarker{}, true), device.HeartbeatRequest{RequestID: uuid.NewString()}, token)
heartbeatDone <- err
}()
select {
case <-heartbeatStarted:
case <-ctx.Done():
close(release)
t.Fatal("heartbeat not started")
}
close(release)
if err := <-scanDone; err != nil {
t.Fatal("scan failed")
}
if err := <-heartbeatDone; err != nil {
t.Fatal("heartbeat failed")
}
requireOfflineDevice(t, db, d.DeviceID, models.DeviceStatusOnline, 2)
})
t.Run("explain_actual_lock_queries", func(t *testing.T) {
s, d, _ := seed(t)
offlineCollection(t, db, d.DeviceID, models.TaskStatusRunning)
offlinePurchase(t, db, d.DeviceID, models.PurchaseTaskStatusRunning)
type captured struct {
sql string
vars []any
}
queries := map[string]captured{}
callback := "v4_capture_lock_sql"
if db.Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) {
if _, ok := tx.Statement.Clauses["FOR"]; ok {
queries[tx.Statement.Table] = captured{tx.Statement.SQL.String(), append([]any(nil), tx.Statement.Vars...)}
}
}) != nil {
t.Fatal("capture SQL failed")
}
n, err := s.MarkStaleDevicesOffline(context.Background(), device.DefaultOfflineThreshold)
db.Callback().Query().Remove(callback)
if err != nil || n != 1 {
t.Fatal("capture scan failed")
}
for _, table := range []string{"agent_device", "collection_task", "purchase_task"} {
query, ok := queries[table]
if !ok {
t.Fatalf("missing production lock query %s", table)
}
var plan []struct {
Type string
Key *string
PossibleKeys *string
Rows int64
Extra string
}
if db.Raw("EXPLAIN "+query.sql, query.vars...).Scan(&plan).Error != nil {
t.Fatal("EXPLAIN failed")
}
t.Logf("synthetic fixture only table=%s sql=%s", table, query.sql)
for _, row := range plan {
key := "NULL"
if row.Key != nil {
key = *row.Key
}
possible := "NULL"
if row.PossibleKeys != nil {
possible = *row.PossibleKeys
}
t.Logf("type=%s key=%s possible_keys=%s estimated_rows=%d extra=%s", row.Type, key, possible, row.Rows, row.Extra)
}
}
})
}