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
@@ -14,7 +14,7 @@ import (
sqlite3 "github.com/mattn/go-sqlite3"
)
const timestampLayout = time.RFC3339Nano
const storageTimestampLayout = "2006-01-02T15:04:05.000000000Z"
type queryRower interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
@@ -52,7 +52,14 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
var createdByUserID sql.NullString
var sourceRef sql.NullString
var maxBudget sql.NullInt64
var claimedByUserID sql.NullString
var claimedByDeviceID sql.NullString
var claimTokenHash sql.NullString
var claimIssuedAt sql.NullString
var claimExpiresAt sql.NullString
var cancelReason sql.NullString
var cancelRequestedAt sql.NullString
var cancelRequestedByUserID sql.NullString
var canceledAt sql.NullString
var createdAt string
var updatedAt string
@@ -70,7 +77,15 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
&task.Currency,
&task.Status,
&task.Version,
&claimedByUserID,
&claimedByDeviceID,
&task.ClaimGeneration,
&claimTokenHash,
&claimIssuedAt,
&claimExpiresAt,
&cancelReason,
&cancelRequestedAt,
&cancelRequestedByUserID,
&canceledAt,
&createdAt,
&updatedAt,
@@ -87,15 +102,36 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
if maxBudget.Valid {
task.MaxBudgetCents = &maxBudget.Int64
}
if claimedByUserID.Valid {
task.ClaimedByUserID = &claimedByUserID.String
}
if claimedByDeviceID.Valid {
task.ClaimedByDeviceID = &claimedByDeviceID.String
}
if claimTokenHash.Valid {
task.ClaimTokenHash = &claimTokenHash.String
}
task.ClaimIssuedAt, err = parseNullableTimestamp(claimIssuedAt)
if err != nil {
return domain.PurchaseTask{}, err
}
task.ClaimExpiresAt, err = parseNullableTimestamp(claimExpiresAt)
if err != nil {
return domain.PurchaseTask{}, err
}
if cancelReason.Valid {
task.CancelReason = &cancelReason.String
}
if canceledAt.Valid {
value, err := parseTimestamp(canceledAt.String)
if err != nil {
return domain.PurchaseTask{}, err
}
task.CanceledAt = &value
task.CancelRequestedAt, err = parseNullableTimestamp(cancelRequestedAt)
if err != nil {
return domain.PurchaseTask{}, err
}
if cancelRequestedByUserID.Valid {
task.CancelRequestedByUserID = &cancelRequestedByUserID.String
}
task.CanceledAt, err = parseNullableTimestamp(canceledAt)
if err != nil {
return domain.PurchaseTask{}, err
}
task.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
@@ -108,6 +144,42 @@ func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
return task, nil
}
func scanExecution(scanner rowScanner) (domain.TaskExecution, error) {
var execution domain.TaskExecution
var lastHeartbeatAt sql.NullString
var finishedAt sql.NullString
var startedAt string
err := scanner.Scan(
&execution.ID,
&execution.TaskID,
&execution.AttemptNo,
&execution.ClaimGeneration,
&execution.UserID,
&execution.DeviceID,
&execution.CurrentStep,
&execution.OrderSubmitted,
&startedAt,
&lastHeartbeatAt,
&finishedAt,
)
if err != nil {
return domain.TaskExecution{}, err
}
execution.StartedAt, err = parseTimestamp(startedAt)
if err != nil {
return domain.TaskExecution{}, err
}
execution.LastHeartbeatAt, err = parseNullableTimestamp(lastHeartbeatAt)
if err != nil {
return domain.TaskExecution{}, err
}
execution.FinishedAt, err = parseNullableTimestamp(finishedAt)
if err != nil {
return domain.TaskExecution{}, err
}
return execution, nil
}
func getAssetByID(
ctx context.Context,
queryer queryRower,
@@ -144,7 +216,10 @@ func getTaskByID(
`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 = ? AND id = ?`,
creatorSubject,
@@ -218,11 +293,11 @@ func insertIdempotency(
}
func formatTimestamp(value time.Time) string {
return value.UTC().Format(timestampLayout)
return value.UTC().Format(storageTimestampLayout)
}
func parseTimestamp(value string) (time.Time, error) {
parsed, err := time.Parse(timestampLayout, value)
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return time.Time{}, fmt.Errorf(
"%w: invalid stored timestamp",
@@ -232,6 +307,17 @@ func parseTimestamp(value string) (time.Time, error) {
return parsed.UTC(), nil
}
func parseNullableTimestamp(value sql.NullString) (*time.Time, error) {
if !value.Valid {
return nil, nil
}
parsed, err := parseTimestamp(value.String)
if err != nil {
return nil, err
}
return &parsed, nil
}
func nullableString(value *string) any {
if value == nil {
return nil