c8a87f1733
executus CI / test (pull_request) Successful in 1m34s
All 3 cloud models converged on real concurrency bugs in the SQLite stores: - AppendVersion (HIGH): the seq key was `SELECT MAX(seq)+1` then INSERT in two un-transacted statements with a NON-unique index, AND the Scan error was swallowed (seq stayed 0 on failure). Concurrent appends could both land the same seq, silently breaking newest-first ordering. Now: one transaction, the Scan error is propagated, the (skill_id, seq) index is UNIQUE (the loser of a race fails loudly), and an empty SkillID is rejected. - MarkScheduledRun / MarkAgentScheduledRun (all 3): replaced the Get→mutate→Save read-modify-write (lost-update window) with a single atomic UPDATE using json_set, so a concurrent Mark/edit can't clobber it. json_set keeps the JSON blob's NextRunAt/LastScheduledRunAt consistent with the indexed column; RFC3339Nano matches Go's time encoding so the blob still round-trips (tested). - Open: actually applies PRAGMA busy_timeout=5000 (the doc advertised it but it was never set) — a contended writer waits instead of erroring SQLITE_BUSY. - budgetStore.Add: rejects NaN/Inf secondsUsed (would irrecoverably poison the column). Triaged-but-kept: plaintext webhook secret (documented design, high-entropy URL key, pre-existing); SQL()/free-form `where` helpers (no untrusted input reaches them — defense-in-depth notes only). Core go.sum still free of host/DB deps; contrib/store green (incl. a json_set blob-round-trip test). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
3.2 KiB
Go
106 lines
3.2 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/executus/budget"
|
|
)
|
|
|
|
// budgetStore is the SQLite-backed budget.BudgetStorage.
|
|
type budgetStore struct{ db *sql.DB }
|
|
|
|
// Budget returns a durable budget.BudgetStorage backed by this database.
|
|
func (d *DB) Budget() budget.BudgetStorage { return &budgetStore{db: d.sql} }
|
|
|
|
var _ budget.BudgetStorage = (*budgetStore)(nil)
|
|
|
|
func (s *budgetStore) Initialize(ctx context.Context) error {
|
|
_, err := s.db.ExecContext(ctx, `
|
|
CREATE TABLE IF NOT EXISTS skill_budgets (
|
|
user_id TEXT PRIMARY KEY,
|
|
window_start INTEGER NOT NULL, -- unix seconds
|
|
seconds_used REAL NOT NULL,
|
|
runs_count INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
)`)
|
|
if err != nil {
|
|
return fmt.Errorf("budgetStore.Initialize: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *budgetStore) Get(ctx context.Context, userID string) (*budget.SkillBudget, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT window_start, seconds_used, runs_count, updated_at FROM skill_budgets WHERE user_id = ?`, userID)
|
|
var ws, ua int64
|
|
var used float64
|
|
var runs int
|
|
switch err := row.Scan(&ws, &used, &runs, &ua); {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return nil, nil // no row yet — documented (nil, nil)
|
|
case err != nil:
|
|
return nil, fmt.Errorf("budgetStore.Get: %w", err)
|
|
}
|
|
return &budget.SkillBudget{
|
|
UserID: userID,
|
|
WindowStart: time.Unix(ws, 0).UTC(),
|
|
SecondsUsed: used,
|
|
RunsCount: runs,
|
|
UpdatedAt: time.Unix(ua, 0).UTC(),
|
|
}, nil
|
|
}
|
|
|
|
// Add increments usage atomically, rolling the 7-day window over inside one
|
|
// transaction so concurrent Adds can't race the read-modify-write.
|
|
func (s *budgetStore) Add(ctx context.Context, userID string, secondsUsed float64, now time.Time) error {
|
|
// A NaN/Inf would poison the seconds_used column irrecoverably (NaN
|
|
// propagates through every later add), so reject it at the boundary.
|
|
if math.IsNaN(secondsUsed) || math.IsInf(secondsUsed, 0) {
|
|
return fmt.Errorf("budgetStore.Add: invalid secondsUsed %v", secondsUsed)
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("budgetStore.Add: begin: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // no-op after Commit
|
|
|
|
var ws int64
|
|
var used float64
|
|
var runs int
|
|
err = tx.QueryRowContext(ctx,
|
|
`SELECT window_start, seconds_used, runs_count FROM skill_budgets WHERE user_id = ?`, userID).
|
|
Scan(&ws, &used, &runs)
|
|
switch {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
ws, used, runs = now.Unix(), 0, 0
|
|
case err != nil:
|
|
return fmt.Errorf("budgetStore.Add: select: %w", err)
|
|
}
|
|
// Roll the window over if older than 7 days.
|
|
if now.Sub(time.Unix(ws, 0)) >= 7*24*time.Hour {
|
|
ws, used, runs = now.Unix(), 0, 0
|
|
}
|
|
used += secondsUsed
|
|
runs++
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO skill_budgets (user_id, window_start, seconds_used, runs_count, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
window_start = excluded.window_start,
|
|
seconds_used = excluded.seconds_used,
|
|
runs_count = excluded.runs_count,
|
|
updated_at = excluded.updated_at`,
|
|
userID, ws, used, runs, now.Unix()); err != nil {
|
|
return fmt.Errorf("budgetStore.Add: upsert: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("budgetStore.Add: commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|