327 lines
13 KiB
Go
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
|
|
}
|