136 lines
4.9 KiB
Go
136 lines
4.9 KiB
Go
package tasks
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
const sqliteWriteTimeout = 2 * time.Second
|
|
|
|
type Store interface {
|
|
CreateDraft(context.Context, Draft) (Draft, error)
|
|
ListDrafts(context.Context) ([]Draft, error)
|
|
ListTasks(context.Context, TaskFilter) ([]TaskRow, error)
|
|
StartPurchases(context.Context, StartCommand, string) (StartResult, error)
|
|
}
|
|
type SQLiteStore struct {
|
|
database *sql.DB
|
|
now func() time.Time
|
|
writeGate chan struct{}
|
|
policy StartPolicy
|
|
}
|
|
|
|
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
|
|
if database == nil {
|
|
return nil, errors.New("database is required")
|
|
}
|
|
if _, err := database.Exec("SELECT task_version, start_key, total_price_cap FROM order_authorizations LIMIT 1"); err != nil {
|
|
return nil, fmt.Errorf("tasks migration is not available: %w", err)
|
|
}
|
|
if _, err := database.Exec("SELECT 1 FROM purchase_attempts LIMIT 1"); err != nil {
|
|
return nil, fmt.Errorf("single-pass migration is not available: %w", err)
|
|
}
|
|
return &SQLiteStore{database: database, now: time.Now, writeGate: make(chan struct{}, 1)}, nil
|
|
}
|
|
|
|
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
|
|
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
|
defer cancel()
|
|
// SQLite permits one writer at a time. Serializing this store's short create
|
|
// transaction prevents concurrent retries of one create key from surfacing as busy.
|
|
select {
|
|
case store.writeGate <- struct{}{}:
|
|
defer func() { <-store.writeGate }()
|
|
case <-writeContext.Done():
|
|
return Draft{}, writeContext.Err()
|
|
}
|
|
draft.CreatedAt = store.now().UTC()
|
|
transaction, err := store.database.BeginTx(writeContext, nil)
|
|
if err != nil {
|
|
return Draft{}, err
|
|
}
|
|
defer transaction.Rollback()
|
|
_, err = transaction.ExecContext(writeContext, `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)`, draft.ID, draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.Quantity, draft.MaxTotalPrice, draft.CreatedAt.Format(time.RFC3339Nano), draft.CreatedAt.Format(time.RFC3339Nano))
|
|
if err == nil {
|
|
if err := transaction.Commit(); err != nil {
|
|
return Draft{}, err
|
|
}
|
|
return draft, nil
|
|
}
|
|
existing, found, currentPhase, lookupErr := findDraft(writeContext, transaction, draft.ID)
|
|
if lookupErr != nil {
|
|
return Draft{}, lookupErr
|
|
}
|
|
if found && currentPhase && samePayload(existing, draft) {
|
|
if err := transaction.Commit(); err != nil {
|
|
return Draft{}, err
|
|
}
|
|
return existing, nil
|
|
}
|
|
if found {
|
|
return Draft{}, ErrCreateKeyConflict
|
|
}
|
|
return Draft{}, err
|
|
}
|
|
|
|
func (store *SQLiteStore) ListDrafts(ctx context.Context) ([]Draft, error) {
|
|
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
|
|
// whereas UUID v4 is deliberately not time-sortable.
|
|
rows, err := store.database.QueryContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
result := []Draft{}
|
|
for rows.Next() {
|
|
draft, err := scanDraft(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, draft)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func findDraft(ctx context.Context, transaction *sql.Tx, id string) (Draft, bool, bool, error) {
|
|
row := transaction.QueryRowContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?`, id)
|
|
var draft Draft
|
|
var created, source, status string
|
|
var version int
|
|
err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created, &source, &status, &version)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Draft{}, false, false, nil
|
|
}
|
|
if err != nil {
|
|
return Draft{}, false, false, err
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339Nano, created)
|
|
if err != nil {
|
|
return Draft{}, false, false, err
|
|
}
|
|
draft.CreatedAt = parsed
|
|
return draft, true, source == "MANUAL" && status == "DRAFT" && version == 1, nil
|
|
}
|
|
|
|
type scanner interface{ Scan(...any) error }
|
|
|
|
func scanDraft(row scanner) (Draft, error) {
|
|
var draft Draft
|
|
var created string
|
|
if err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created); err != nil {
|
|
return Draft{}, err
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339Nano, created)
|
|
if err != nil {
|
|
return Draft{}, err
|
|
}
|
|
draft.CreatedAt = parsed
|
|
return draft, nil
|
|
}
|
|
func samePayload(left, right Draft) bool {
|
|
return left.ID == right.ID && left.Title == right.Title && left.GoodsID == right.GoodsID && left.SKUColor == right.SKUColor && left.SKUSize == right.SKUSize && left.Quantity == right.Quantity && left.MaxTotalPrice == right.MaxTotalPrice
|
|
}
|