chore: initialize go-sip repository
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
// Package store owns the Dispatcher SQLite authority: inbox, tasks, quota
|
||||
// reservations, controls, and the transactional event outbox. It is not used
|
||||
// by Agent processes; Agent execution state belongs in files.
|
||||
package store
|
||||
@@ -0,0 +1,126 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrFactConflict = errors.New("execution fact content conflict")
|
||||
|
||||
type ExecutionFactRecord struct {
|
||||
FactID string
|
||||
TenantID string
|
||||
TenantKey string
|
||||
ExecutionID string
|
||||
ContentSHA256 string
|
||||
Kind int32
|
||||
BindingJSON []byte
|
||||
PayloadJSON []byte
|
||||
ObservedAt time.Time
|
||||
SourceBootID string
|
||||
SourceSequence uint64
|
||||
EventID string
|
||||
EventType string
|
||||
AggregateType string
|
||||
AggregateID string
|
||||
AggregateVersion int64
|
||||
}
|
||||
|
||||
type ExecutionFactResult struct {
|
||||
Duplicate bool
|
||||
EventID string
|
||||
}
|
||||
|
||||
type FactEventBuilder func(aggregateVersion int64) ([]byte, error)
|
||||
|
||||
// RecordExecutionFact persists an Agent fact and, when eventBuilder is set,
|
||||
// the authoritative MQ event in one SQLite transaction. Aggregate versions are
|
||||
// allocated by Dispatcher from durable state; the Agent cannot select them. A
|
||||
// duplicate fact with the same digest is accepted without creating a second
|
||||
// outbox row; reuse with a different digest is rejected.
|
||||
func (s *Store) RecordExecutionFact(record ExecutionFactRecord, eventExchange, routingKey string, eventBuilder FactEventBuilder) (ExecutionFactResult, error) {
|
||||
if record.FactID == "" || record.TenantID == "" || record.TenantKey == "" || record.ExecutionID == "" || record.ContentSHA256 == "" {
|
||||
return ExecutionFactResult{}, errors.New("fact identity and tenant/execution binding are required")
|
||||
}
|
||||
if len(record.BindingJSON) == 0 || len(record.PayloadJSON) == 0 {
|
||||
return ExecutionFactResult{}, errors.New("fact binding and payload are required")
|
||||
}
|
||||
if record.ObservedAt.IsZero() {
|
||||
return ExecutionFactResult{}, errors.New("fact observed time is required")
|
||||
}
|
||||
if eventBuilder != nil && record.EventID == "" {
|
||||
return ExecutionFactResult{}, errors.New("event ID is required for outbox event")
|
||||
}
|
||||
if eventBuilder != nil && (eventExchange == "" || routingKey == "") {
|
||||
return ExecutionFactResult{}, errors.New("event exchange and routing key are required for outbox event")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return ExecutionFactResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var existingDigest, existingEventID string
|
||||
err = tx.QueryRow(`SELECT content_sha256, event_id FROM execution_facts WHERE fact_id = ?`, record.FactID).Scan(&existingDigest, &existingEventID)
|
||||
if err == nil {
|
||||
if existingDigest != record.ContentSHA256 {
|
||||
return ExecutionFactResult{}, fmt.Errorf("%w: %s", ErrFactConflict, record.FactID)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ExecutionFactResult{}, err
|
||||
}
|
||||
return ExecutionFactResult{Duplicate: true, EventID: existingEventID}, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return ExecutionFactResult{}, err
|
||||
}
|
||||
|
||||
var eventBody []byte
|
||||
if eventBuilder != nil {
|
||||
var latestVersion sql.NullInt64
|
||||
if err := tx.QueryRow(`SELECT MAX(aggregate_version) FROM execution_facts WHERE aggregate_type = ? AND aggregate_id = ?`, record.AggregateType, record.AggregateID).Scan(&latestVersion); err != nil {
|
||||
return ExecutionFactResult{}, err
|
||||
}
|
||||
record.AggregateVersion = latestVersion.Int64 + 1
|
||||
if record.AggregateVersion < 1 {
|
||||
record.AggregateVersion = 1
|
||||
}
|
||||
var buildErr error
|
||||
eventBody, buildErr = eventBuilder(record.AggregateVersion)
|
||||
if buildErr != nil {
|
||||
return ExecutionFactResult{}, buildErr
|
||||
}
|
||||
if len(eventBody) == 0 {
|
||||
return ExecutionFactResult{}, errors.New("event builder returned an empty body")
|
||||
}
|
||||
} else if record.AggregateVersion < 1 {
|
||||
record.AggregateVersion = 1
|
||||
}
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := tx.Exec(`INSERT INTO execution_facts(
|
||||
fact_id, tenant_id, tenant_key, execution_id, content_sha256, kind,
|
||||
binding_json, payload_json, observed_at, source_boot_id, source_sequence,
|
||||
event_id, event_type, aggregate_type, aggregate_id, aggregate_version, received_at
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.FactID, record.TenantID, record.TenantKey, record.ExecutionID, record.ContentSHA256,
|
||||
record.Kind, record.BindingJSON, record.PayloadJSON, record.ObservedAt.UTC().Format(time.RFC3339Nano),
|
||||
record.SourceBootID, record.SourceSequence, record.EventID, record.EventType,
|
||||
record.AggregateType, record.AggregateID, record.AggregateVersion, now); err != nil {
|
||||
return ExecutionFactResult{}, err
|
||||
}
|
||||
if eventBuilder != nil {
|
||||
if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES(?, ?, ?, ?, ?, 'pending', ?)`,
|
||||
record.EventID, record.TenantKey, eventExchange, routingKey, eventBody, now); err != nil {
|
||||
return ExecutionFactResult{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ExecutionFactResult{}, err
|
||||
}
|
||||
return ExecutionFactResult{EventID: record.EventID}, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSingleActiveLeaseAndExpiry(t *testing.T) {
|
||||
now := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)
|
||||
var current = now
|
||||
s, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
s.now = func() time.Time { return current }
|
||||
if _, err := s.AcquireLease("lease-a", "dispatcher", "holder-a", time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.AcquireLease("lease-b", "dispatcher", "holder-b", time.Minute); !errors.Is(err, ErrLeaseHeld) {
|
||||
t.Fatalf("second holder error = %v", err)
|
||||
}
|
||||
current = current.Add(2 * time.Minute)
|
||||
if _, err := s.AcquireLease("lease-b", "dispatcher", "holder-b", time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ReleaseLease("lease-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverOutboxRequeuesClaimedRows(t *testing.T) {
|
||||
s := testStore(t)
|
||||
if _, err := s.DB().Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES('e1', 'tenant', 'agent-call.events.v1', 'rk', '{}', 'dispatching', '2026-09-18T00:00:00Z')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RecoverOutbox(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status, lastError string
|
||||
if err := s.DB().QueryRow(`SELECT status, last_error FROM outbox WHERE event_id = 'e1'`).Scan(&status, &lastError); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "retry" || lastError != "recovered_after_restart" {
|
||||
t.Fatalf("status=%q error=%q", status, lastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxRecoverySurvivesSQLiteReopen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "dispatcher.db")
|
||||
first, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstClosed := false
|
||||
t.Cleanup(func() {
|
||||
if !firstClosed {
|
||||
if err := first.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
result, err := first.DB().Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES('restart-e1', 'tenant', 'agent-call.events.v1', 'rk', '{}', 'dispatching', '2026-09-18T00:00:00Z')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows, err := result.RowsAffected(); err != nil || rows != 1 {
|
||||
t.Fatalf("inserted rows=%d err=%v, want 1", rows, err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstClosed = true
|
||||
|
||||
second, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := second.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
var status, lastError string
|
||||
if err := second.DB().QueryRow(`SELECT status, last_error FROM outbox WHERE event_id = 'restart-e1'`).Scan(&status, &lastError); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "retry" || lastError != "recovered_after_restart" {
|
||||
t.Fatalf("status=%q error=%q after reopen", status, lastError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inbox (
|
||||
command_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
tenant_key TEXT NOT NULL,
|
||||
command_type TEXT NOT NULL,
|
||||
body_hash TEXT NOT NULL,
|
||||
body BLOB NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('received', 'persisted', 'rejected')),
|
||||
received_at TEXT NOT NULL,
|
||||
persisted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
execution_id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
task_item_id TEXT NOT NULL,
|
||||
task_revision INTEGER NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
callee TEXT NOT NULL,
|
||||
route_policy_id TEXT NOT NULL,
|
||||
caller_profile_id TEXT NOT NULL,
|
||||
agent_version_id TEXT NOT NULL,
|
||||
variables BLOB NOT NULL,
|
||||
ring_timeout_ms INTEGER NOT NULL,
|
||||
max_call_duration_ms INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('accepted', 'reserved', 'running', 'draining', 'paused', 'stopped', 'finished', 'unknown')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (tenant_key, task_id, task_item_id, task_revision)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS tasks_pending_idx ON tasks (status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS tasks_tenant_idx ON tasks (tenant_key, status, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quotas (
|
||||
scope TEXT PRIMARY KEY,
|
||||
limit_value INTEGER NOT NULL CHECK (limit_value >= 0),
|
||||
reserved_value INTEGER NOT NULL DEFAULT 0 CHECK (reserved_value >= 0),
|
||||
unknown_value INTEGER NOT NULL DEFAULT 0 CHECK (unknown_value >= 0),
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reservations (
|
||||
reservation_id TEXT PRIMARY KEY,
|
||||
execution_id TEXT NOT NULL,
|
||||
tenant_key TEXT NOT NULL,
|
||||
scopes BLOB NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('held', 'released', 'unknown')),
|
||||
created_at TEXT NOT NULL,
|
||||
released_at TEXT,
|
||||
UNIQUE (execution_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS reservations_tenant_idx ON reservations (tenant_key, state);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
tenant_key TEXT NOT NULL,
|
||||
exchange TEXT NOT NULL,
|
||||
routing_key TEXT NOT NULL,
|
||||
body BLOB NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'dispatching', 'published', 'retry')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
published_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS outbox_pending_idx ON outbox (status, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS controls (
|
||||
execution_id TEXT PRIMARY KEY,
|
||||
task_revision INTEGER NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('pause', 'resume', 'drain', 'stop', 'hangup')),
|
||||
active_call_policy TEXT,
|
||||
reason TEXT,
|
||||
idempotency_key TEXT,
|
||||
applied INTEGER NOT NULL DEFAULT 0,
|
||||
requested_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS replays (
|
||||
idempotency_key TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
source_command_id TEXT NOT NULL,
|
||||
tenant_key TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (idempotency_key, source_command_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leases (
|
||||
lease_id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL,
|
||||
holder_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('active', 'expired', 'released'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS leases_scope_idx ON leases (scope, state, expires_at);
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS scheduler_state (
|
||||
scope TEXT PRIMARY KEY,
|
||||
tenants_json BLOB NOT NULL,
|
||||
cursor INTEGER NOT NULL CHECK (cursor >= 0),
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS uploads (
|
||||
upload_id TEXT PRIMARY KEY,
|
||||
binding BLOB NOT NULL,
|
||||
asset BLOB NOT NULL,
|
||||
grant BLOB NOT NULL,
|
||||
object_key TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('granted', 'completed', 'failed')),
|
||||
oss_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS uploads_state_idx ON uploads (state, created_at);
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS execution_facts (
|
||||
fact_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
tenant_key TEXT NOT NULL,
|
||||
execution_id TEXT NOT NULL,
|
||||
content_sha256 TEXT NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
binding_json BLOB NOT NULL,
|
||||
payload_json BLOB NOT NULL,
|
||||
observed_at TEXT NOT NULL,
|
||||
source_boot_id TEXT NOT NULL,
|
||||
source_sequence INTEGER NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
aggregate_type TEXT NOT NULL,
|
||||
aggregate_id TEXT NOT NULL,
|
||||
aggregate_version INTEGER NOT NULL,
|
||||
received_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS execution_facts_execution_idx ON execution_facts (execution_id, observed_at);
|
||||
CREATE INDEX IF NOT EXISTS execution_facts_event_idx ON execution_facts (event_id);
|
||||
@@ -0,0 +1,817 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
iofs "io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
"git.ipao.vip/rogee/go-sip/internal/tenant"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
var (
|
||||
ErrDuplicateCommand = errors.New("duplicate command")
|
||||
ErrCommandConflict = errors.New("command id reused with different body")
|
||||
ErrNoCapacity = errors.New("quota capacity unavailable")
|
||||
ErrCASConflict = errors.New("control revision conflict")
|
||||
ErrLeaseHeld = errors.New("active lease held by another dispatcher")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
if path == "" {
|
||||
path = "file:dispatcher.db?_pragma=busy_timeout(5000)"
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
store := &Store{db: db, now: time.Now}
|
||||
if err := store.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := store.RecoverOutbox(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func New(db *sql.DB, now func() time.Time) (*Store, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("nil database")
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
store := &Store{db: db, now: now}
|
||||
if err := store.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.RecoverOutbox(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
files, err := iofs.Glob(migrations, "migrations/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, file := range files {
|
||||
data, err := migrations.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(string(data)); err != nil {
|
||||
return fmt.Errorf("migrate sqlite %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RecoverOutbox() error {
|
||||
_, err := s.db.Exec(`UPDATE outbox SET status = 'retry', last_error = 'recovered_after_restart' WHERE status = 'dispatching'`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
func (s *Store) LoadSchedulerCursor(scope string, tenants []string) (int, error) {
|
||||
if scope == "" {
|
||||
return 0, errors.New("scheduler scope is required")
|
||||
}
|
||||
encoded, err := json.Marshal(tenants)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cursor int
|
||||
err = s.db.QueryRow(`SELECT cursor FROM scheduler_state WHERE scope = ?`, scope).Scan(&cursor)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_, err = s.db.Exec(`INSERT INTO scheduler_state(scope, tenants_json, cursor, updated_at) VALUES(?, ?, 0, ?)`, scope, encoded, s.now().UTC().Format(time.RFC3339Nano))
|
||||
return 0, err
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
cursor = 0
|
||||
} else {
|
||||
cursor %= len(tenants)
|
||||
if cursor < 0 {
|
||||
cursor += len(tenants)
|
||||
}
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE scheduler_state SET tenants_json = ?, cursor = ?, updated_at = ? WHERE scope = ?`, encoded, cursor, s.now().UTC().Format(time.RFC3339Nano), scope)
|
||||
return cursor, err
|
||||
}
|
||||
|
||||
func (s *Store) SaveSchedulerCursor(scope string, tenants []string, cursor int) error {
|
||||
if scope == "" {
|
||||
return errors.New("scheduler scope is required")
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
cursor = 0
|
||||
} else {
|
||||
cursor %= len(tenants)
|
||||
if cursor < 0 {
|
||||
cursor += len(tenants)
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(tenants)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err = s.db.Exec(`INSERT INTO scheduler_state(scope, tenants_json, cursor, updated_at) VALUES(?, ?, ?, ?)
|
||||
ON CONFLICT(scope) DO UPDATE SET tenants_json = excluded.tenants_json, cursor = excluded.cursor, updated_at = excluded.updated_at`,
|
||||
scope, encoded, cursor, s.now().UTC().Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
|
||||
type IngestResult struct {
|
||||
CommandID string
|
||||
ExecutionID string
|
||||
Duplicate bool
|
||||
PersistedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) IngestCommand(raw []byte, routingKey string) (IngestResult, error) {
|
||||
envelope, payload, err := contract.DecodeExecute(raw)
|
||||
if err != nil {
|
||||
return IngestResult{}, err
|
||||
}
|
||||
if routingKey != "" {
|
||||
if err := verifyRouting(envelope.TenantKey, routingKey); err != nil {
|
||||
return IngestResult{}, err
|
||||
}
|
||||
}
|
||||
now := s.now().UTC()
|
||||
expired, err := contract.NotAfterExpired(envelope.NotAfter, now)
|
||||
if err != nil {
|
||||
return IngestResult{}, err
|
||||
}
|
||||
if expired {
|
||||
return IngestResult{}, fmt.Errorf("command admission deadline has expired")
|
||||
}
|
||||
hash := sha256.Sum256(raw)
|
||||
bodyHash := hex.EncodeToString(hash[:])
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("begin ingest: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var existingHash, status string
|
||||
err = tx.QueryRow(`SELECT body_hash, status FROM inbox WHERE command_id = ?`, envelope.CommandID).Scan(&existingHash, &status)
|
||||
switch {
|
||||
case err == nil:
|
||||
if existingHash != bodyHash {
|
||||
return IngestResult{}, fmt.Errorf("%w: %s", ErrCommandConflict, envelope.CommandID)
|
||||
}
|
||||
return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, Duplicate: true, PersistedAt: now}, nil
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return IngestResult{}, fmt.Errorf("lookup inbox: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`INSERT INTO inbox(command_id, tenant_id, tenant_key, command_type, body_hash, body, status, received_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 'received', ?)`, envelope.CommandID, envelope.TenantID, envelope.TenantKey, envelope.CommandType, bodyHash, raw, now.Format(time.RFC3339Nano)); err != nil {
|
||||
return IngestResult{}, fmt.Errorf("persist inbox: %w", err)
|
||||
}
|
||||
|
||||
variables, err := json.Marshal(payload.Variables)
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("encode variables: %w", err)
|
||||
}
|
||||
result, err := tx.Exec(`INSERT INTO tasks(
|
||||
execution_id, tenant_key, tenant_id, task_id, task_item_id, task_revision, trace_id,
|
||||
callee, route_policy_id, caller_profile_id, agent_version_id, variables,
|
||||
ring_timeout_ms, max_call_duration_ms, status, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'accepted', ?, ?)
|
||||
ON CONFLICT(tenant_key, task_id, task_item_id, task_revision) DO NOTHING`,
|
||||
payload.ExecutionID, envelope.TenantKey, envelope.TenantID, payload.TaskID, payload.TaskItemID, payload.TaskRevision,
|
||||
envelope.TraceID, payload.Callee, payload.RoutePolicyID, payload.CallerProfileID, payload.AgentVersionID,
|
||||
variables, payload.RingTimeoutMS, payload.MaxCallDurationMS, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("persist task: %w", err)
|
||||
}
|
||||
inserted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("inspect task insert: %w", err)
|
||||
}
|
||||
if inserted == 0 {
|
||||
if _, err := tx.Exec(`UPDATE inbox SET status = 'persisted', persisted_at = ? WHERE command_id = ?`, now.Format(time.RFC3339Nano), envelope.CommandID); err != nil {
|
||||
return IngestResult{}, fmt.Errorf("mark duplicate inbox: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return IngestResult{}, fmt.Errorf("commit duplicate command: %w", err)
|
||||
}
|
||||
return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, Duplicate: true, PersistedAt: now}, nil
|
||||
}
|
||||
|
||||
event, err := (contract.EventBuilder{
|
||||
TenantID: envelope.TenantID, TenantKey: envelope.TenantKey, TraceID: envelope.TraceID,
|
||||
EventType: "command.result", Aggregate: "command", AggregateID: envelope.CommandID, Version: 1,
|
||||
Payload: map[string]any{
|
||||
"command_id": envelope.CommandID, "command_type": envelope.CommandType,
|
||||
"execution_id": payload.ExecutionID,
|
||||
"status": "accepted", "reason_code": "accepted",
|
||||
"requested_task_revision": payload.TaskRevision,
|
||||
},
|
||||
}).Marshal(now, envelope.CommandID+"-result")
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("build command.result: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, 'pending', ?)`, envelope.CommandID+"-result", envelope.TenantKey, tenant.EventExchange, "agent-call.command.result", event, now.Format(time.RFC3339Nano)); err != nil {
|
||||
return IngestResult{}, fmt.Errorf("persist outbox: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE inbox SET status = 'persisted', persisted_at = ? WHERE command_id = ?`, now.Format(time.RFC3339Nano), envelope.CommandID); err != nil {
|
||||
return IngestResult{}, fmt.Errorf("mark inbox persisted: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return IngestResult{}, fmt.Errorf("commit ingest: %w", err)
|
||||
}
|
||||
return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, PersistedAt: now}, nil
|
||||
}
|
||||
|
||||
func verifyRouting(tenantKey, routingKey string) error {
|
||||
want, err := tenant.CommandRoutingKey(tenantKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if want != routingKey {
|
||||
return fmt.Errorf("tenant routing mismatch: expected %q got %q", want, routingKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type OutboxRecord struct {
|
||||
ID int64
|
||||
EventID string
|
||||
TenantKey string
|
||||
Exchange string
|
||||
RoutingKey string
|
||||
Body []byte
|
||||
Attempts int
|
||||
}
|
||||
|
||||
func (s *Store) ClaimOutbox(limit int) ([]OutboxRecord, error) {
|
||||
if limit <= 0 {
|
||||
return nil, errors.New("outbox limit must be positive")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.Query(`SELECT id, event_id, tenant_key, exchange, routing_key, body, attempts
|
||||
FROM outbox WHERE status IN ('pending', 'retry') ORDER BY id LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var records []OutboxRecord
|
||||
for rows.Next() {
|
||||
var r OutboxRecord
|
||||
if err := rows.Scan(&r.ID, &r.EventID, &r.TenantKey, &r.Exchange, &r.RoutingKey, &r.Body, &r.Attempts); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, r)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range records {
|
||||
if _, err := tx.Exec(`UPDATE outbox SET status = 'dispatching', attempts = attempts + 1 WHERE id = ?`, r.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkOutboxPublished(id int64) error {
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err := s.db.Exec(`UPDATE outbox SET status = 'published', published_at = ?, last_error = NULL WHERE id = ?`, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) MarkOutboxRetry(id int64, cause error) error {
|
||||
message := "retry"
|
||||
if cause != nil {
|
||||
message = cause.Error()
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE outbox SET status = 'retry', last_error = ? WHERE id = ?`, message, id)
|
||||
return err
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ExecutionID string
|
||||
TenantKey string
|
||||
TenantID string
|
||||
TaskID string
|
||||
TaskItemID string
|
||||
TaskRevision int64
|
||||
TraceID string
|
||||
Callee string
|
||||
RoutePolicyID string
|
||||
CallerProfileID string
|
||||
AgentVersionID string
|
||||
Variables map[string]any
|
||||
RingTimeoutMS int64
|
||||
MaxCallDurationMS int64
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) FindTask(tenantID, taskID string) (Task, error) {
|
||||
if tenantID == "" || taskID == "" {
|
||||
return Task{}, errors.New("tenant id and task id are required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
row := s.db.QueryRow(`SELECT execution_id, tenant_key, tenant_id, task_id, task_item_id, task_revision,
|
||||
trace_id, callee, route_policy_id, caller_profile_id, agent_version_id, variables,
|
||||
ring_timeout_ms, max_call_duration_ms, status, created_at, updated_at
|
||||
FROM tasks WHERE tenant_id = ? AND task_id = ? ORDER BY task_revision DESC LIMIT 1`, tenantID, taskID)
|
||||
return scanTask(row)
|
||||
}
|
||||
|
||||
func (s *Store) NextTask(tenantKey string) (Task, error) {
|
||||
if strings.TrimSpace(tenantKey) == "" {
|
||||
return Task{}, errors.New("tenant key is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
row := s.db.QueryRow(`SELECT execution_id, tenant_key, tenant_id, task_id, task_item_id, task_revision,
|
||||
trace_id, callee, route_policy_id, caller_profile_id, agent_version_id, variables,
|
||||
ring_timeout_ms, max_call_duration_ms, status, created_at, updated_at
|
||||
FROM tasks WHERE tenant_key = ? AND status = 'accepted' ORDER BY created_at, execution_id LIMIT 1`, tenantKey)
|
||||
return scanTask(row)
|
||||
}
|
||||
|
||||
func (s *Store) MarkTaskReserved(executionID string) error {
|
||||
if executionID == "" {
|
||||
return errors.New("execution id is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result, err := s.db.Exec(`UPDATE tasks SET status = 'reserved', updated_at = ? WHERE execution_id = ? AND status = 'accepted'`, s.now().UTC().Format(time.RFC3339Nano), executionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return ErrCASConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkTaskRunning(executionID string) error {
|
||||
if executionID == "" {
|
||||
return errors.New("execution id is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result, err := s.db.Exec(`UPDATE tasks SET status = 'running', updated_at = ? WHERE execution_id = ? AND status = 'reserved'`, s.now().UTC().Format(time.RFC3339Nano), executionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return ErrCASConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FinalizeReservation releases a failed remote attempt and updates its task in
|
||||
// the same SQLite transaction. Unknown attempts stay counted in unknown_value;
|
||||
// only a failure proven before remote submission is requeued as accepted.
|
||||
func (s *Store) FinalizeReservation(reservationID, executionID string, unknown bool) error {
|
||||
if reservationID == "" || executionID == "" {
|
||||
return errors.New("reservation and execution ids are required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var state, storedExecutionID string
|
||||
var scopesJSON []byte
|
||||
if err := tx.QueryRow(`SELECT state, execution_id, scopes FROM reservations WHERE reservation_id = ?`, reservationID).Scan(&state, &storedExecutionID, &scopesJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
if storedExecutionID != executionID {
|
||||
return fmt.Errorf("reservation execution mismatch: got %q, want %q", storedExecutionID, executionID)
|
||||
}
|
||||
if state != "held" {
|
||||
return nil
|
||||
}
|
||||
var scopes []string
|
||||
if err := json.Unmarshal(scopesJSON, &scopes); err != nil {
|
||||
return fmt.Errorf("decode reservation scopes: %w", err)
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
return errors.New("quota scopes are required to finalize a reservation")
|
||||
}
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, scope := range scopes {
|
||||
var result sql.Result
|
||||
if unknown {
|
||||
result, err = tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, unknown_value = unknown_value + 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope)
|
||||
} else {
|
||||
result, err = tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return ErrCASConflict
|
||||
}
|
||||
}
|
||||
newState := "released"
|
||||
newTaskStatus := "accepted"
|
||||
if unknown {
|
||||
newState = "unknown"
|
||||
newTaskStatus = "unknown"
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE reservations SET state = ?, released_at = ? WHERE reservation_id = ? AND state = 'held'`, newState, now, reservationID); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.Exec(`UPDATE tasks SET status = ?, updated_at = ? WHERE execution_id = ? AND status = 'reserved'`, newTaskStatus, now, executionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return ErrCASConflict
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type CommandRecord struct {
|
||||
CommandID string
|
||||
TenantID string
|
||||
TenantKey string
|
||||
CommandType string
|
||||
Status string
|
||||
Body []byte
|
||||
ReceivedAt time.Time
|
||||
PersistedAt *time.Time
|
||||
}
|
||||
|
||||
func (s *Store) GetCommand(tenantID, commandID string) (CommandRecord, error) {
|
||||
if tenantID == "" || commandID == "" {
|
||||
return CommandRecord{}, errors.New("tenant id and command id are required")
|
||||
}
|
||||
var record CommandRecord
|
||||
var received, persisted sql.NullString
|
||||
if err := s.db.QueryRow(`SELECT command_id, tenant_id, tenant_key, command_type, status, body, received_at, persisted_at
|
||||
FROM inbox WHERE tenant_id = ? AND command_id = ?`, tenantID, commandID).Scan(&record.CommandID, &record.TenantID, &record.TenantKey, &record.CommandType, &record.Status, &record.Body, &received, &persisted); err != nil {
|
||||
return CommandRecord{}, err
|
||||
}
|
||||
var err error
|
||||
record.ReceivedAt, err = time.Parse(time.RFC3339Nano, received.String)
|
||||
if err != nil {
|
||||
return CommandRecord{}, err
|
||||
}
|
||||
if persisted.Valid {
|
||||
value, err := time.Parse(time.RFC3339Nano, persisted.String)
|
||||
if err != nil {
|
||||
return CommandRecord{}, err
|
||||
}
|
||||
record.PersistedAt = &value
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReplayCommand(idempotencyKey, tenantID, sourceCommandID, reason string) error {
|
||||
if idempotencyKey == "" || tenantID == "" || sourceCommandID == "" || strings.TrimSpace(reason) == "" {
|
||||
return errors.New("replay identity and reason are required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var tenantKey string
|
||||
var body []byte
|
||||
if err := tx.QueryRow(`SELECT tenant_key, body FROM inbox WHERE tenant_id = ? AND command_id = ?`, tenantID, sourceCommandID).Scan(&tenantKey, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
var existing string
|
||||
if err := tx.QueryRow(`SELECT idempotency_key FROM replays WHERE idempotency_key = ?`, idempotencyKey).Scan(&existing); err == nil {
|
||||
return tx.Commit()
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := tx.Exec(`INSERT INTO replays(idempotency_key, tenant_id, source_command_id, tenant_key, reason, created_at) VALUES(?, ?, ?, ?, ?, ?)`, idempotencyKey, tenantID, sourceCommandID, tenantKey, reason, now); err != nil {
|
||||
return err
|
||||
}
|
||||
routingKey := "agent-call.tenant." + tenantKey + ".call.execute"
|
||||
eventID := "replay-" + idempotencyKey
|
||||
if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES(?, ?, ?, ?, ?, 'pending', ?)`, eventID, tenantKey, tenant.CommandExchange, routingKey, body, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func scanTask(row *sql.Row) (Task, error) {
|
||||
var t Task
|
||||
var variables []byte
|
||||
var created, updated string
|
||||
if err := row.Scan(&t.ExecutionID, &t.TenantKey, &t.TenantID, &t.TaskID, &t.TaskItemID, &t.TaskRevision,
|
||||
&t.TraceID, &t.Callee, &t.RoutePolicyID, &t.CallerProfileID, &t.AgentVersionID, &variables,
|
||||
&t.RingTimeoutMS, &t.MaxCallDurationMS, &t.Status, &created, &updated); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
if err := json.Unmarshal(variables, &t.Variables); err != nil {
|
||||
return Task{}, fmt.Errorf("decode task variables: %w", err)
|
||||
}
|
||||
var err error
|
||||
t.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
t.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetQuota(scope string, limit int64) error {
|
||||
if scope == "" || limit < 0 {
|
||||
return errors.New("invalid quota")
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO quotas(scope, limit_value, updated_at) VALUES(?, ?, ?)
|
||||
ON CONFLICT(scope) DO UPDATE SET limit_value = excluded.limit_value, updated_at = excluded.updated_at`, scope, limit, s.now().UTC().Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Reserve(reservationID, executionID, tenantKey string, scopes []string) error {
|
||||
if reservationID == "" || executionID == "" || tenantKey == "" || len(scopes) == 0 {
|
||||
return errors.New("reservation identity and scopes are required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
scopesJSON, err := json.Marshal(scopes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
var limit, reserved, unknown int64
|
||||
if err := tx.QueryRow(`SELECT limit_value, reserved_value, unknown_value FROM quotas WHERE scope = ?`, scope).Scan(&limit, &reserved, &unknown); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: quota %s is not configured", ErrNoCapacity, scope)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if reserved+unknown >= limit {
|
||||
return fmt.Errorf("%w: %s", ErrNoCapacity, scope)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO reservations(reservation_id, execution_id, tenant_key, scopes, state, created_at) VALUES(?, ?, ?, ?, 'held', ?)`, reservationID, executionID, tenantKey, scopesJSON, now); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if _, err := tx.Exec(`UPDATE quotas SET reserved_value = reserved_value + 1, updated_at = ? WHERE scope = ?`, now, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ReleaseReservation(reservationID string, unknown bool) error {
|
||||
if reservationID == "" {
|
||||
return errors.New("reservation id is required")
|
||||
}
|
||||
return s.releaseReservation(reservationID, unknown, nil)
|
||||
}
|
||||
|
||||
func (s *Store) ReleaseReservationWithScopes(reservationID string, scopes []string, unknown bool) error {
|
||||
return s.releaseReservation(reservationID, unknown, scopes)
|
||||
}
|
||||
|
||||
func (s *Store) releaseReservation(reservationID string, unknown bool, scopes []string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var state string
|
||||
var scopesJSON []byte
|
||||
if err := tx.QueryRow(`SELECT state, scopes FROM reservations WHERE reservation_id = ?`, reservationID).Scan(&state, &scopesJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
if state != "held" {
|
||||
return nil
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
if err := json.Unmarshal(scopesJSON, &scopes); err != nil {
|
||||
return fmt.Errorf("decode reservation scopes: %w", err)
|
||||
}
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
return errors.New("quota scopes are required to release a reservation")
|
||||
}
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, scope := range scopes {
|
||||
if unknown {
|
||||
if _, err := tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, unknown_value = unknown_value + 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
newState := "released"
|
||||
if unknown {
|
||||
newState = "unknown"
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE reservations SET state = ?, released_at = ? WHERE reservation_id = ?`, newState, now, reservationID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type Lease struct {
|
||||
LeaseID string
|
||||
Scope string
|
||||
HolderID string
|
||||
ExpiresAt time.Time
|
||||
State string
|
||||
}
|
||||
|
||||
func (s *Store) AcquireLease(leaseID, scope, holderID string, ttl time.Duration) (Lease, error) {
|
||||
if leaseID == "" || scope == "" || holderID == "" || ttl <= 0 {
|
||||
return Lease{}, errors.New("lease id, scope, holder, and positive ttl are required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return Lease{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := s.now().UTC()
|
||||
var existingID, existingHolder, existingExpiry string
|
||||
err = tx.QueryRow(`SELECT lease_id, holder_id, expires_at FROM leases WHERE scope = ? AND state = 'active' LIMIT 1`, scope).Scan(&existingID, &existingHolder, &existingExpiry)
|
||||
if err == nil {
|
||||
expires, parseErr := time.Parse(time.RFC3339Nano, existingExpiry)
|
||||
if parseErr != nil {
|
||||
return Lease{}, parseErr
|
||||
}
|
||||
if expires.After(now) && existingHolder != holderID {
|
||||
return Lease{}, fmt.Errorf("%w: scope %s", ErrLeaseHeld, scope)
|
||||
}
|
||||
if expires.After(now) && existingHolder == holderID && existingID != leaseID {
|
||||
return Lease{}, fmt.Errorf("%w: holder already owns scope %s", ErrLeaseHeld, scope)
|
||||
}
|
||||
if !expires.After(now) {
|
||||
if _, err := tx.Exec(`UPDATE leases SET state = 'expired' WHERE lease_id = ?`, existingID); err != nil {
|
||||
return Lease{}, err
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return Lease{}, err
|
||||
}
|
||||
expires := now.Add(ttl)
|
||||
if _, err := tx.Exec(`INSERT INTO leases(lease_id, scope, holder_id, expires_at, state) VALUES(?, ?, ?, ?, 'active')
|
||||
ON CONFLICT(lease_id) DO UPDATE SET scope = excluded.scope, holder_id = excluded.holder_id, expires_at = excluded.expires_at, state = 'active'`, leaseID, scope, holderID, expires.Format(time.RFC3339Nano)); err != nil {
|
||||
return Lease{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Lease{}, err
|
||||
}
|
||||
return Lease{LeaseID: leaseID, Scope: scope, HolderID: holderID, ExpiresAt: expires, State: "active"}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReleaseLease(leaseID string) error {
|
||||
if leaseID == "" {
|
||||
return errors.New("lease id is required")
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE leases SET state = 'released' WHERE lease_id = ? AND state = 'active'`, leaseID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ApplyControl(executionID string, expectedRevision int64, action string) error {
|
||||
return s.ApplyControlDetailed(executionID, expectedRevision, action, "", "", "")
|
||||
}
|
||||
|
||||
func (s *Store) ApplyControlDetailed(executionID string, expectedRevision int64, action, activeCallPolicy, reason, idempotencyKey string) error {
|
||||
if executionID == "" || expectedRevision < 1 {
|
||||
return errors.New("execution id and expected revision are required")
|
||||
}
|
||||
if action != "pause" && action != "resume" && action != "drain" && action != "stop" && action != "hangup" {
|
||||
return fmt.Errorf("unsupported control action %q", action)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var status string
|
||||
if err := tx.QueryRow(`SELECT status FROM tasks WHERE execution_id = ? AND task_revision = ?`, executionID, expectedRevision).Scan(&status); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrCASConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
if status == "stopped" && action != "stop" {
|
||||
return fmt.Errorf("%w: stopped execution cannot resume", ErrCASConflict)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO controls(execution_id, task_revision, action, active_call_policy, reason, idempotency_key, applied, requested_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, ?)
|
||||
ON CONFLICT(execution_id) DO UPDATE SET task_revision = excluded.task_revision, action = excluded.action, active_call_policy = excluded.active_call_policy, reason = excluded.reason, idempotency_key = excluded.idempotency_key, applied = 0, requested_at = excluded.requested_at`, executionID, expectedRevision, action, activeCallPolicy, reason, idempotencyKey, s.now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
newStatus := status
|
||||
switch action {
|
||||
case "pause":
|
||||
newStatus = "paused"
|
||||
case "resume":
|
||||
if status != "paused" {
|
||||
return fmt.Errorf("%w: execution is %s", ErrCASConflict, status)
|
||||
}
|
||||
newStatus = "accepted"
|
||||
case "drain":
|
||||
newStatus = "draining"
|
||||
case "stop", "hangup":
|
||||
newStatus = "stopped"
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE tasks SET status = ?, updated_at = ? WHERE execution_id = ? AND task_revision = ?`, newStatus, s.now().UTC().Format(time.RFC3339Nano), executionID, expectedRevision); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
"git.ipao.vip/rogee/go-sip/internal/tenant"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)
|
||||
s, err := New(db, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestIngestIsDurableAndIdempotent(t *testing.T) {
|
||||
s := testStore(t)
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Duplicate {
|
||||
t.Fatal("first command marked duplicate")
|
||||
}
|
||||
second, err := s.IngestCommand(raw, tenant.CommandRoutingPrefix+"tenant-demo-key"+tenant.CommandRoutingSuffix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !second.Duplicate {
|
||||
t.Fatal("second command was not idempotent")
|
||||
}
|
||||
outbox, err := s.ClaimOutbox(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(outbox) != 1 {
|
||||
t.Fatalf("outbox rows = %d, want 1", len(outbox))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestRejectsRoutingMismatch(t *testing.T) {
|
||||
s := testStore(t)
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.IngestCommand(raw, "agent-call.tenant.other.call.execute"); err == nil {
|
||||
t.Fatal("expected routing mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaIsAtomicAndUnknownIsNotReleased(t *testing.T) {
|
||||
s := testStore(t)
|
||||
for _, scope := range []string{"tenant:tenant-demo-key", "global", "cell:cell-1"} {
|
||||
if err := s.SetQuota(scope, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.Reserve("r1", "e1", "tenant-demo-key", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Reserve("r2", "e2", "tenant-demo-key", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}); !errors.Is(err, ErrNoCapacity) {
|
||||
t.Fatalf("reserve error = %v, want ErrNoCapacity", err)
|
||||
}
|
||||
if err := s.ReleaseReservationWithScopes("r1", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Reserve("r3", "e3", "tenant-demo-key", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}); !errors.Is(err, ErrNoCapacity) {
|
||||
t.Fatalf("unknown reservation released capacity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeReservationRequeuesBeforeRemoteSubmission(t *testing.T) {
|
||||
s := testStore(t)
|
||||
if err := s.SetQuota("global", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Reserve("r-requeue", "exec_demo_001", "tenant-demo-key", []string{"global"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.MarkTaskReserved("exec_demo_001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.FinalizeReservation("r-requeue", "exec_demo_001", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var taskStatus string
|
||||
if err := s.DB().QueryRow(`SELECT status FROM tasks WHERE execution_id = 'exec_demo_001'`).Scan(&taskStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taskStatus != "accepted" {
|
||||
t.Fatalf("task status=%q, want accepted", taskStatus)
|
||||
}
|
||||
var reserved, unknown int64
|
||||
if err := s.DB().QueryRow(`SELECT reserved_value, unknown_value FROM quotas WHERE scope = 'global'`).Scan(&reserved, &unknown); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reserved != 0 || unknown != 0 {
|
||||
t.Fatalf("quota reserved=%d unknown=%d, want 0/0", reserved, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeReservationKeepsUnknownCounted(t *testing.T) {
|
||||
s := testStore(t)
|
||||
if err := s.SetQuota("global", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Reserve("r-unknown", "exec_demo_001", "tenant-demo-key", []string{"global"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.MarkTaskReserved("exec_demo_001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.FinalizeReservation("r-unknown", "exec_demo_001", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var taskStatus string
|
||||
if err := s.DB().QueryRow(`SELECT status FROM tasks WHERE execution_id = 'exec_demo_001'`).Scan(&taskStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taskStatus != "unknown" {
|
||||
t.Fatalf("task status=%q, want unknown", taskStatus)
|
||||
}
|
||||
var reserved, unknown int64
|
||||
if err := s.DB().QueryRow(`SELECT reserved_value, unknown_value FROM quotas WHERE scope = 'global'`).Scan(&reserved, &unknown); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reserved != 0 || unknown != 1 {
|
||||
t.Fatalf("quota reserved=%d unknown=%d, want 0/1", reserved, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlCASAndStopBarrier(t *testing.T) {
|
||||
s := testStore(t)
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ApplyControl("exec_demo_001", 1, "pause"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ApplyControl("exec_demo_001", 99, "resume"); !errors.Is(err, ErrCASConflict) {
|
||||
t.Fatalf("revision mismatch error = %v", err)
|
||||
}
|
||||
if err := s.ApplyControl("exec_demo_001", 1, "stop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ApplyControl("exec_demo_001", 1, "resume"); !errors.Is(err, ErrCASConflict) {
|
||||
t.Fatalf("stopped resume error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UploadRecord struct {
|
||||
UploadID string
|
||||
Binding []byte
|
||||
Asset []byte
|
||||
Grant []byte
|
||||
ObjectKey string
|
||||
State string
|
||||
OSSID string
|
||||
CreatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
func (s *Store) LoadUpload(uploadID string) (UploadRecord, error) {
|
||||
if uploadID == "" {
|
||||
return UploadRecord{}, errors.New("upload ID is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var record UploadRecord
|
||||
var createdAt string
|
||||
var completedAt sql.NullString
|
||||
var ossID sql.NullString
|
||||
err := s.db.QueryRow(`SELECT upload_id, binding, asset, grant, object_key, state, oss_id, created_at, completed_at
|
||||
FROM uploads WHERE upload_id = ?`, uploadID).Scan(
|
||||
&record.UploadID, &record.Binding, &record.Asset, &record.Grant, &record.ObjectKey,
|
||||
&record.State, &ossID, &createdAt, &completedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return UploadRecord{}, err
|
||||
}
|
||||
return UploadRecord{}, fmt.Errorf("load upload: %w", err)
|
||||
}
|
||||
record.OSSID = ossID.String
|
||||
parsed, err := time.Parse(time.RFC3339Nano, createdAt)
|
||||
if err != nil {
|
||||
return UploadRecord{}, fmt.Errorf("parse upload created_at: %w", err)
|
||||
}
|
||||
record.CreatedAt = parsed
|
||||
if completedAt.Valid && completedAt.String != "" {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, completedAt.String)
|
||||
if err != nil {
|
||||
return UploadRecord{}, fmt.Errorf("parse upload completed_at: %w", err)
|
||||
}
|
||||
record.CompletedAt = &parsed
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReplaceUploadGrant(uploadID, objectKey string, grant []byte) error {
|
||||
if uploadID == "" || objectKey == "" || len(grant) == 0 {
|
||||
return errors.New("upload ID, object key and grant are required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result, err := s.db.Exec(`UPDATE uploads SET grant = ?, object_key = ? WHERE upload_id = ? AND state = 'granted'`, grant, objectKey, uploadID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("replace upload grant: %w", err)
|
||||
}
|
||||
updated, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect replaced upload grant: %w", err)
|
||||
}
|
||||
if updated == 0 {
|
||||
var state string
|
||||
if err := s.db.QueryRow(`SELECT state FROM uploads WHERE upload_id = ?`, uploadID).Scan(&state); err != nil {
|
||||
return err
|
||||
}
|
||||
if state == "completed" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("upload is not in granted state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) InsertUpload(record UploadRecord) error {
|
||||
if record.UploadID == "" || len(record.Binding) == 0 || len(record.Asset) == 0 || len(record.Grant) == 0 || record.ObjectKey == "" {
|
||||
return errors.New("complete upload record is required")
|
||||
}
|
||||
if record.CreatedAt.IsZero() {
|
||||
record.CreatedAt = s.now().UTC()
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(`INSERT INTO uploads(upload_id, binding, asset, grant, object_key, state, oss_id, created_at, completed_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, NULL, ?, NULL)`, record.UploadID, record.Binding, record.Asset, record.Grant,
|
||||
record.ObjectKey, record.State, record.CreatedAt.UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert upload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CompleteUpload(uploadID, ossID string, completedAt time.Time) error {
|
||||
return s.completeUpload(uploadID, ossID, completedAt, nil, "", "", "", "")
|
||||
}
|
||||
|
||||
// CompleteUploadAndOutbox atomically records verified OSS completion and the
|
||||
// recording.ready event. A successful RPC therefore cannot lose the MQ handoff
|
||||
// between the upload state update and outbox persistence.
|
||||
func (s *Store) CompleteUploadAndOutbox(uploadID, ossID string, completedAt time.Time, eventID, tenantKey, exchange, routingKey string, body []byte) error {
|
||||
if eventID == "" || tenantKey == "" || exchange == "" || routingKey == "" || len(body) == 0 {
|
||||
return errors.New("verified upload outbox event is required")
|
||||
}
|
||||
return s.completeUpload(uploadID, ossID, completedAt, body, eventID, tenantKey, exchange, routingKey)
|
||||
}
|
||||
|
||||
func (s *Store) completeUpload(uploadID, ossID string, completedAt time.Time, body []byte, eventID, tenantKey, exchange, routingKey string) error {
|
||||
if uploadID == "" || ossID == "" {
|
||||
return errors.New("upload ID and OSS ID are required")
|
||||
}
|
||||
if completedAt.IsZero() {
|
||||
completedAt = s.now().UTC()
|
||||
}
|
||||
if eventID != "" && (exchange == "" || routingKey == "") {
|
||||
return errors.New("exchange and routing key are required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin complete upload: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, err := tx.Exec(`UPDATE uploads SET state = 'completed', oss_id = ?, completed_at = ? WHERE upload_id = ? AND state = 'granted'`,
|
||||
ossID, completedAt.UTC().Format(time.RFC3339Nano), uploadID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete upload: %w", err)
|
||||
}
|
||||
updated, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect completed upload: %w", err)
|
||||
}
|
||||
if updated == 0 {
|
||||
var state string
|
||||
var existingOSSID sql.NullString
|
||||
lookupErr := tx.QueryRow(`SELECT state, oss_id FROM uploads WHERE upload_id = ?`, uploadID).Scan(&state, &existingOSSID)
|
||||
if lookupErr != nil {
|
||||
if errors.Is(lookupErr, sql.ErrNoRows) {
|
||||
return lookupErr
|
||||
}
|
||||
return fmt.Errorf("inspect upload state: %w", lookupErr)
|
||||
}
|
||||
if state == "completed" && existingOSSID.Valid && existingOSSID.String == ossID {
|
||||
return tx.Commit()
|
||||
}
|
||||
return errors.New("upload is not in granted state")
|
||||
}
|
||||
if eventID != "" {
|
||||
if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, 'pending', ?)`, eventID, tenantKey, exchange, routingKey, body, completedAt.UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("persist verified upload outbox: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit completed upload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user