chore: initialize go-sip repository

This commit is contained in:
2026-09-21 08:56:04 +08:00
commit 643b11b21f
309 changed files with 40521 additions and 0 deletions
+4
View File
@@ -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
+126
View File
@@ -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
}
+93
View File
@@ -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)
}
}
+101
View File
@@ -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
);
+12
View File
@@ -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);
+817
View File
@@ -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()
}
+185
View File
@@ -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)
}
}
+169
View File
@@ -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
}