Files
go-sip/internal/store/local_v01.go
T

327 lines
13 KiB
Go

package store
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
)
type LocalCommandRecord struct {
DispatcherID string
TaskID string
TenantID string
TenantKey string
CommandID string
CommandType string
Body []byte
ReceiptID string
Exchange string
RoutingKey string
ReceiptBody []byte
CapacityRejectedReceiptBody []byte
Status string
Admission *LocalCallAdmission
}
type LocalCallAdmission struct {
DispatcherID string
Task Task
ReservationID string
QuotaScopes []string
Snapshot LocalExecutionConfigSnapshot
}
// LocalExecutionConfigSnapshot binds an accepted call to the exact raw
// configuration responses used for its admission decision.
type LocalExecutionConfigSnapshot struct {
SchemaVersion string `json:"schema_version"`
DispatcherID string `json:"dispatcher_id"`
TenantID string `json:"tenant_id"`
TenantKey string `json:"tenant_key"`
TaskID string `json:"task_id"`
SelectedTrunkID string `json:"selected_trunk_id"`
SIPRevision int64 `json:"sip_revision"`
TaskRevision int64 `json:"task_revision"`
QuotaRevision int64 `json:"quota_revision"`
SIP json.RawMessage `json:"sip"`
Task json.RawMessage `json:"task"`
TenantQuota json.RawMessage `json:"tenant_quota"`
}
type LocalEventRecord struct {
EventID string
TenantKey string
Exchange string
RoutingKey string
Body []byte
}
// PersistLocalCommand stores the inbound command, quota reservation, bound
// config snapshot, and durable receipt in one transaction.
func (s *Store) PersistLocalCommand(record LocalCommandRecord) (bool, error) {
if record.TenantID == "" || record.TenantKey == "" || record.CommandID == "" || record.CommandType == "" || len(record.Body) == 0 || record.ReceiptID == "" || record.Exchange == "" || record.RoutingKey == "" || len(record.ReceiptBody) == 0 {
return false, errors.New("local command persistence fields are required")
}
if record.Status != "persisted" && record.Status != "rejected" {
return false, fmt.Errorf("unsupported local inbox status %q", record.Status)
}
if record.CommandType == "call.execute" && (record.DispatcherID == "" || record.TaskID == "") {
return false, errors.New("call.execute requires a Dispatcher and task identity")
}
if record.CommandType == "call.execute" && record.Status == "persisted" && record.Admission == nil {
return false, errors.New("accepted call.execute requires a durable quota admission")
}
if record.Admission == nil && len(record.CapacityRejectedReceiptBody) != 0 {
return false, errors.New("quota rejection receipt requires an admission")
}
var configBody []byte
if record.Admission != nil {
var err error
configBody, err = validateLocalAdmission(record)
if err != nil {
return false, err
}
}
bodyHash := sha256.Sum256(record.Body)
now := s.now().UTC().Format(time.RFC3339Nano)
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
var oldTenantID, oldTenantKey, oldBodyHash string
err = tx.QueryRow(`SELECT tenant_id,tenant_key,body_hash FROM inbox WHERE command_id=?`, record.CommandID).Scan(&oldTenantID, &oldTenantKey, &oldBodyHash)
if err == nil {
if oldTenantID != record.TenantID || oldTenantKey != record.TenantKey || oldBodyHash != hex.EncodeToString(bodyHash[:]) {
return false, ErrCommandConflict
}
var receiptID string
if err := tx.QueryRow(`SELECT response_id FROM mq_command_receipts WHERE tenant_id=? AND command_id=?`, record.TenantID, record.CommandID).Scan(&receiptID); err != nil {
return false, fmt.Errorf("load persisted local command receipt: %w", err)
}
if _, err := tx.Exec(`UPDATE outbox SET status='pending', published_at=NULL, last_error=NULL WHERE event_id=? AND status='published'`, receiptID); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return false, err
}
if record.CommandType == "call.execute" {
if err := requireLocalTaskRunningTx(tx, record.DispatcherID, record.TenantID, record.TenantKey, record.TaskID); err != nil {
return false, err
}
}
if _, err := tx.Exec(`INSERT INTO inbox(command_id,tenant_id,tenant_key,command_type,body_hash,body,status,received_at)
VALUES(?,?,?,?,?,?,'received',?)`, record.CommandID, record.TenantID, record.TenantKey, record.CommandType,
hex.EncodeToString(bodyHash[:]), record.Body, now); err != nil {
return false, err
}
finalStatus := record.Status
finalReceiptBody := record.ReceiptBody
if record.Admission != nil {
available, err := localQuotaScopesAvailable(tx, record.Admission.QuotaScopes)
if err != nil {
return false, err
}
if !available {
finalStatus = "rejected"
finalReceiptBody = record.CapacityRejectedReceiptBody
} else {
if err := insertLocalTask(tx, record.Admission.Task, now); err != nil {
return false, err
}
digest := sha256.Sum256(configBody)
if _, err := tx.Exec(`INSERT INTO local_v01_execution_configs(execution_id,content_sha256,body,stored_at) VALUES(?,?,?,?)`,
record.Admission.Task.ExecutionID, hex.EncodeToString(digest[:]), configBody, now); err != nil {
return false, err
}
if err := reserveLocalCallTx(tx, *record.Admission, now); err != nil {
return false, err
}
}
}
if _, err := tx.Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at)
VALUES(?,?,?,?,?,'pending',?)`, record.ReceiptID, record.TenantKey, record.Exchange, record.RoutingKey, finalReceiptBody, now); err != nil {
return false, err
}
if _, err := tx.Exec(`INSERT INTO mq_command_receipts(tenant_id,command_id,response_id) VALUES(?,?,?)`, record.TenantID, record.CommandID, record.ReceiptID); err != nil {
return false, err
}
if _, err := tx.Exec(`UPDATE inbox SET status=?,persisted_at=? WHERE command_id=?`, finalStatus, now, record.CommandID); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
return false, nil
}
func validateLocalAdmission(record LocalCommandRecord) ([]byte, error) {
admission := record.Admission
if admission == nil {
return nil, errors.New("local call admission is required")
}
task := admission.Task
snapshot := admission.Snapshot
if record.CommandType != "call.execute" || record.Status != "persisted" || len(record.CapacityRejectedReceiptBody) == 0 || !json.Valid(record.CapacityRejectedReceiptBody) {
return nil, errors.New("invalid accepted call admission receipt")
}
if admission.DispatcherID == "" || record.DispatcherID != admission.DispatcherID || record.TaskID != task.TaskID || admission.ReservationID == "" || task.ExecutionID == "" || task.TaskID == "" || task.TenantID == "" || task.TenantKey == "" || task.TaskItemID == "" || task.TraceID == "" ||
task.TenantID != record.TenantID || task.TenantKey != record.TenantKey || task.TaskItemID != record.CommandID || task.Status != "accepted" ||
task.TaskRevision <= 0 || task.Callee == "" || task.RoutePolicyID == "" || task.CallerProfileID == "" || task.AgentVersionID == "" || task.RingTimeoutMS <= 0 || task.MaxCallDurationMS <= 0 ||
admission.ReservationID != task.ExecutionID {
return nil, errors.New("local call admission task identity or limits are invalid")
}
if len(admission.QuotaScopes) != 2 || admission.QuotaScopes[0] != LocalTenantQuotaScope(admission.DispatcherID, task.TenantID) || admission.QuotaScopes[1] != LocalTaskQuotaScope(admission.DispatcherID, task.TenantID, task.TaskID) {
return nil, errors.New("local call admission quota scopes are invalid")
}
if snapshot.SchemaVersion != "execution-config-snapshot.v0.3" || snapshot.SelectedTrunkID == "" || snapshot.DispatcherID != admission.DispatcherID || snapshot.TenantID != task.TenantID || snapshot.TenantKey != task.TenantKey || snapshot.TaskID != task.TaskID ||
snapshot.TaskRevision != task.TaskRevision || snapshot.SIPRevision <= 0 || snapshot.QuotaRevision <= 0 ||
!validLocalJSONResponse(snapshot.SIP) || !validLocalJSONResponse(snapshot.Task) || !validLocalJSONResponse(snapshot.TenantQuota) {
return nil, errors.New("local call admission config snapshot identity or contents are invalid")
}
body, err := json.Marshal(snapshot)
if err != nil {
return nil, fmt.Errorf("marshal local execution config snapshot: %w", err)
}
return body, nil
}
func validLocalJSONResponse(body json.RawMessage) bool {
trimmed := bytes.TrimSpace(body)
return len(trimmed) > 0 && trimmed[0] == '{' && json.Valid(trimmed)
}
func localQuotaScopesAvailable(tx *sql.Tx, scopes []string) (bool, error) {
if len(scopes) == 0 {
return false, ErrNoCapacity
}
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 false, fmt.Errorf("%w: quota %s is not configured", ErrNoCapacity, scope)
}
return false, err
}
if limit <= 0 || reserved >= limit || unknown >= limit-reserved {
return false, nil
}
}
return true, nil
}
func insertLocalTask(tx *sql.Tx, task Task, now string) error {
variables := task.Variables
if variables == nil {
variables = map[string]any{}
}
variablesJSON, err := json.Marshal(variables)
if err != nil {
return fmt.Errorf("marshal local task variables: %w", err)
}
_, 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',?,?)`,
task.ExecutionID, task.TenantKey, task.TenantID, task.TaskID, task.TaskItemID, task.TaskRevision, task.TraceID, task.Callee,
task.RoutePolicyID, task.CallerProfileID, task.AgentVersionID, variablesJSON, task.RingTimeoutMS, task.MaxCallDurationMS, now, now)
return err
}
func reserveLocalCallTx(tx *sql.Tx, admission LocalCallAdmission, now string) error {
result, err := tx.Exec(`UPDATE tasks SET status='reserved',updated_at=? WHERE execution_id=? AND tenant_key=? AND status='accepted'`,
now, admission.Task.ExecutionID, admission.Task.TenantKey)
if err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count != 1 {
return ErrCASConflict
}
scopesJSON, err := json.Marshal(admission.QuotaScopes)
if err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO reservations(reservation_id,execution_id,tenant_key,scopes,state,created_at)
VALUES(?,?,?,?, 'held', ?)`, admission.ReservationID, admission.Task.ExecutionID, admission.Task.TenantKey, scopesJSON, now); err != nil {
return err
}
for _, scope := range admission.QuotaScopes {
result, err := tx.Exec(`UPDATE quotas SET reserved_value=reserved_value+1,updated_at=?
WHERE scope=? AND reserved_value+unknown_value<limit_value`, now, scope)
if err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count != 1 {
return fmt.Errorf("%w: %s", ErrNoCapacity, scope)
}
}
return nil
}
// EnqueueLocalEvent persists one final v0.1 event. Identical event IDs and
// bodies are idempotent; conflicting reuse is rejected without rewriting it.
func (s *Store) EnqueueLocalEvent(event LocalEventRecord) (bool, error) {
if err := validateLocalEventRecord(event); err != nil {
return false, err
}
now := s.now().UTC().Format(time.RFC3339Nano)
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
duplicate, err := enqueueLocalEventTx(tx, event, now)
if err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
return duplicate, nil
}
func enqueueLocalEventTx(tx *sql.Tx, event LocalEventRecord, now string) (bool, error) {
var oldTenantKey, oldExchange, oldRoutingKey string
var oldBody []byte
err := tx.QueryRow(`SELECT tenant_key, exchange, routing_key, body FROM outbox WHERE event_id=?`, event.EventID).Scan(&oldTenantKey, &oldExchange, &oldRoutingKey, &oldBody)
if err == nil {
if oldTenantKey != event.TenantKey || oldExchange != event.Exchange || oldRoutingKey != event.RoutingKey || string(oldBody) != string(event.Body) {
return false, ErrCommandConflict
}
return true, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return false, err
}
if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at)
VALUES(?, ?, ?, ?, ?, 'pending', ?)`, event.EventID, event.TenantKey, event.Exchange, event.RoutingKey, event.Body, now); err != nil {
return false, err
}
return false, nil
}