feat(tasks): implement atomic claims and leases

This commit is contained in:
QiuSW
2026-07-26 16:16:36 +08:00
parent 49db5b8305
commit ce875af889
50 changed files with 7589 additions and 207 deletions
@@ -3,6 +3,7 @@ package sqlite
import (
"context"
"database/sql"
"errors"
"strings"
"time"
@@ -145,7 +146,10 @@ func (s *Store) ListTasks(
query.WriteString(`SELECT
id, creator_subject, created_by_user_id, source_ref, title, description, sku,
image_asset_id, quantity, max_budget_cents, currency, status,
version, cancel_reason, canceled_at, created_at, updated_at
version, claimed_by_user_id, claimed_by_device_id, claim_generation,
claim_token_hash, claim_issued_at, claim_expires_at, cancel_reason,
cancel_requested_at, cancel_requested_by_user_id, canceled_at,
created_at, updated_at
FROM purchase_tasks
WHERE creator_subject = ?`)
arguments := []any{filter.CreatorSubject}
@@ -242,7 +246,9 @@ func (s *Store) GetTaskDetail(
}
rows, err := tx.QueryContext(
ctx,
`SELECT id, task_id, actor_user_id, event_type, message, occurred_at
`SELECT
id, task_id, actor_user_id, actor_device_id,
event_type, message, occurred_at
FROM task_events
WHERE task_id = ?
ORDER BY occurred_at ASC, id ASC`,
@@ -256,11 +262,13 @@ func (s *Store) GetTaskDetail(
for rows.Next() {
var event domain.TaskEvent
var actorUserID sql.NullString
var actorDeviceID sql.NullString
var occurredAt string
if err := rows.Scan(
&event.ID,
&event.TaskID,
&actorUserID,
&actorDeviceID,
&event.Type,
&event.Message,
&occurredAt,
@@ -270,6 +278,9 @@ func (s *Store) GetTaskDetail(
if actorUserID.Valid {
event.ActorUserID = &actorUserID.String
}
if actorDeviceID.Valid {
event.ActorDeviceID = &actorDeviceID.String
}
event.OccurredAt, err = parseTimestamp(occurredAt)
if err != nil {
return domain.TaskDetail{}, err
@@ -282,10 +293,31 @@ func (s *Store) GetTaskDetail(
if err := rows.Close(); err != nil {
return domain.TaskDetail{}, repositoryFailure(err)
}
execution, err := scanExecution(tx.QueryRowContext(
ctx,
`SELECT
id, task_id, attempt_no, claim_generation, user_id, device_id,
current_step, order_submitted, started_at, last_heartbeat_at,
finished_at
FROM task_executions
WHERE task_id = ?
ORDER BY attempt_no DESC
LIMIT 1`,
taskID,
))
var executionPointer *domain.TaskExecution
if errors.Is(err, sql.ErrNoRows) {
executionPointer = nil
} else if err != nil {
return domain.TaskDetail{}, repositoryFailure(err)
} else {
executionPointer = &execution
}
detail := domain.TaskDetail{
Task: task,
Asset: asset,
Events: events,
Task: task,
Asset: asset,
Execution: executionPointer,
Events: events,
}
if err := tx.Commit(); err != nil {
return domain.TaskDetail{}, repositoryFailure(err)
@@ -293,7 +325,7 @@ func (s *Store) GetTaskDetail(
return detail, nil
}
func (s *Store) CancelPendingTask(
func (s *Store) CancelTask(
ctx context.Context,
creatorSubject string,
taskID string,
@@ -313,25 +345,66 @@ func (s *Store) CancelPendingTask(
if !domain.CanCancel(task.Status) {
return domain.PurchaseTask{}, usecase.ErrTaskStateConflict
}
result, err := tx.ExecContext(
ctx,
`UPDATE purchase_tasks
SET status = 'CANCELED',
version = version + 1,
cancel_reason = NULLIF(?, ''),
canceled_at = ?,
updated_at = ?
WHERE id = ?
AND creator_subject = ?
AND status = 'PENDING'
AND version = ?`,
reason,
formatTimestamp(canceledAt),
formatTimestamp(canceledAt),
taskID,
creatorSubject,
task.Version,
)
if domain.CanRequestCancel(task.Status) &&
task.CancelRequestedAt != nil {
if err := tx.Commit(); err != nil {
return domain.PurchaseTask{}, repositoryFailure(err)
}
return task, nil
}
var result sql.Result
if domain.CanAdminCancelImmediately(task.Status) {
result, err = tx.ExecContext(
ctx,
`UPDATE purchase_tasks
SET status = 'CANCELED',
version = version + 1,
claimed_by_user_id = NULL,
claimed_by_device_id = NULL,
claim_token_hash = NULL,
claim_issued_at = NULL,
claim_expires_at = NULL,
cancel_reason = NULLIF(?, ''),
cancel_requested_at = NULL,
cancel_requested_by_user_id = NULL,
canceled_at = ?,
updated_at = ?
WHERE id = ?
AND creator_subject = ?
AND status IN ('PENDING', 'CLAIMED')
AND version = ?`,
reason,
formatTimestamp(canceledAt),
formatTimestamp(canceledAt),
taskID,
creatorSubject,
task.Version,
)
} else {
event.Type = "TASK_CANCEL_REQUESTED"
event.Message = "task cancellation requested"
result, err = tx.ExecContext(
ctx,
`UPDATE purchase_tasks
SET version = version + 1,
cancel_reason = NULLIF(?, ''),
cancel_requested_at = ?,
cancel_requested_by_user_id = ?,
updated_at = ?
WHERE id = ?
AND creator_subject = ?
AND status IN ('RUNNING', 'WAITING_CONFIRMATION')
AND cancel_requested_at IS NULL
AND version = ?`,
reason,
formatTimestamp(canceledAt),
nullableString(event.ActorUserID),
formatTimestamp(canceledAt),
taskID,
creatorSubject,
task.Version,
)
}
if err != nil {
return domain.PurchaseTask{}, repositoryFailure(err)
}
@@ -363,11 +436,13 @@ func insertTaskEvent(
_, err := tx.ExecContext(
ctx,
`INSERT INTO task_events (
id, task_id, actor_user_id, event_type, message, occurred_at
) VALUES (?, ?, ?, ?, ?, ?)`,
id, task_id, actor_user_id, actor_device_id,
event_type, message, occurred_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
event.ID,
event.TaskID,
nullableString(event.ActorUserID),
nullableString(event.ActorDeviceID),
event.Type,
event.Message,
formatTimestamp(event.OccurredAt),