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

479 lines
20 KiB
Go

package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/contract"
"git.ipao.vip/rogee/go-sip/internal/tenant"
)
var (
ErrCurrentCapacity = errors.New("current task, tenant, or trunk capacity exhausted")
ErrCurrentNotReady = errors.New("current task admission is not ready")
ErrCurrentAlreadyStarted = errors.New("call instruction already began dispatch")
ErrCurrentStopped = errors.New("stopped task does not accept old call instructions")
)
type CurrentExecuteCommand struct {
DispatcherID string
EventID string
TenantID int64
TaskID string
Callee string
IssuedAt string
}
type CurrentExecution struct {
CurrentExecuteCommand
Status string
SelectedTrunkID string
}
type CurrentCallReservation struct {
TrunkID string
SIPRevision int64
CallerID string
DialedCallee string
Deadline time.Time
}
type CurrentOutboxEvent struct {
EventID string
EventType string
RoutingKey string
Body []byte
}
// RecordExecute durably accepts the transport message before MQ ACK. A
// redelivery of the same message identity never creates another execution.
func (s *CurrentStore) RecordExecute(cmd CurrentExecuteCommand) (CurrentExecution, bool, error) {
if cmd.DispatcherID == "" || cmd.EventID == "" || len(cmd.EventID) > 255 || cmd.TenantID <= 0 || cmd.TaskID == "" || cmd.Callee == "" {
return CurrentExecution{}, false, errors.New("invalid call command identity or callee")
}
if _, err := time.Parse(time.RFC3339Nano, cmd.IssuedAt); err != nil {
return CurrentExecution{}, false, fmt.Errorf("invalid call issued_at: %w", err)
}
tx, err := s.db.Begin()
if err != nil {
return CurrentExecution{}, false, err
}
defer tx.Rollback()
var controlState string
var present int
err = tx.QueryRow(`SELECT control_state,present FROM dispatcher_tasks WHERE dispatcher_id=? AND tenant_id=? AND task_id=?`, cmd.DispatcherID, cmd.TenantID, cmd.TaskID).Scan(&controlState, &present)
if errors.Is(err, sql.ErrNoRows) || present != 1 {
return CurrentExecution{}, false, ErrCurrentNotReady
}
if err != nil {
return CurrentExecution{}, false, fmt.Errorf("read call task ownership: %w", err)
}
if controlState == "stopped" || controlState == "stopping" {
return CurrentExecution{}, false, ErrCurrentStopped
}
result, err := tx.Exec(`INSERT INTO dispatcher_inbox(dispatcher_id,event_id,tenant_id,task_id,callee,issued_at,status)
VALUES(?,?,?,?,?,?,'pending') ON CONFLICT(dispatcher_id,event_id) DO NOTHING`, cmd.DispatcherID, cmd.EventID, cmd.TenantID, cmd.TaskID, cmd.Callee, cmd.IssuedAt)
if err != nil {
return CurrentExecution{}, false, fmt.Errorf("persist call inbox: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return CurrentExecution{}, false, err
}
var found CurrentExecution
err = tx.QueryRow(`SELECT dispatcher_id,event_id,tenant_id,task_id,callee,issued_at,status,COALESCE(selected_trunk_id,'')
FROM dispatcher_inbox WHERE dispatcher_id=? AND event_id=?`, cmd.DispatcherID, cmd.EventID).Scan(&found.DispatcherID, &found.EventID, &found.TenantID, &found.TaskID, &found.Callee, &found.IssuedAt, &found.Status, &found.SelectedTrunkID)
if err != nil {
return CurrentExecution{}, false, fmt.Errorf("read call inbox: %w", err)
}
if found.CurrentExecuteCommand != cmd {
return CurrentExecution{}, false, fmt.Errorf("conflicting call message identity %q", cmd.EventID)
}
if err := tx.Commit(); err != nil {
return CurrentExecution{}, false, fmt.Errorf("commit call inbox: %w", err)
}
return found, affected == 1, nil
}
// ReserveExecute is the final transactional quota/fencing boundary before
// sending a single originate. A crash or timeout after this point leaves an
// occupied dispatching/unknown execution, never an automatic redial.
func (s *CurrentStore) ReserveExecute(dispatcherID, eventID string, selected CurrentCallReservation, at time.Time) error {
if dispatcherID == "" || eventID == "" || selected.TrunkID == "" || selected.SIPRevision <= 0 || selected.CallerID == "" || selected.DialedCallee == "" || !selected.Deadline.After(at) {
return errors.New("invalid originate reservation or expired deadline")
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var cmd CurrentExecuteCommand
var status string
err = tx.QueryRow(`SELECT tenant_id,task_id,callee,issued_at,status FROM dispatcher_inbox WHERE dispatcher_id=? AND event_id=?`, dispatcherID, eventID).Scan(&cmd.TenantID, &cmd.TaskID, &cmd.Callee, &cmd.IssuedAt, &status)
if err != nil {
return fmt.Errorf("load durable call instruction: %w", err)
}
if status != "pending" {
return ErrCurrentAlreadyStarted
}
cmd.DispatcherID, cmd.EventID = dispatcherID, eventID
issued, err := time.Parse(time.RFC3339Nano, cmd.IssuedAt)
if err != nil {
return fmt.Errorf("decode persisted call issued_at: %w", err)
}
if issued.After(at) {
return fmt.Errorf("%w: call issued_at is in the future", ErrCurrentNotReady)
}
var body []byte
var revision int64
err = tx.QueryRow(`SELECT c.snapshot_json,c.sip_revision FROM dispatcher_tasks t
JOIN dispatcher_state ds ON ds.dispatcher_id=t.dispatcher_id
JOIN dispatcher_configs c ON c.dispatcher_id=t.dispatcher_id AND c.tenant_id=t.tenant_id AND c.task_id=t.task_id AND c.task_revision=t.task_revision
WHERE t.dispatcher_id=? AND t.tenant_id=? AND t.task_id=? AND t.present=1 AND t.status='running' AND t.control_state='' AND ds.discovery_ready=1`, dispatcherID, cmd.TenantID, cmd.TaskID).Scan(&body, &revision)
if errors.Is(err, sql.ErrNoRows) {
return ErrCurrentNotReady
}
if err != nil {
return fmt.Errorf("load current task admission: %w", err)
}
if revision != selected.SIPRevision {
return fmt.Errorf("%w: SIP revision changed", ErrCurrentNotReady)
}
var snapshot struct {
Task configread.CurrentTask `json:"task"`
SIP configread.CurrentSIP `json:"sip"`
Quota configread.CurrentQuota `json:"quota"`
}
if err := json.Unmarshal(body, &snapshot); err != nil {
return fmt.Errorf("decode admission snapshot: %w", err)
}
if snapshot.Task.DispatcherID != dispatcherID || snapshot.Task.TenantID != cmd.TenantID || snapshot.Task.TaskID != cmd.TaskID || snapshot.SIP.Revision != revision || snapshot.Quota.TenantID != cmd.TenantID {
return errors.New("admission snapshot identity mismatch")
}
if snapshot.Task.MaxConcurrentCalls <= 0 || snapshot.Quota.MaxConcurrentCalls <= 0 {
return fmt.Errorf("%w: missing quota", ErrCurrentNotReady)
}
var trunks []struct {
TrunkID string `json:"trunk_id"`
Enabled bool `json:"enabled"`
MaxConcurrentCalls *int64 `json:"max_concurrent_calls"`
}
if err := json.Unmarshal(snapshot.SIP.Trunks, &trunks); err != nil {
return fmt.Errorf("decode selected SIP trunk: %w", err)
}
var trunkLimit int64
for _, trunk := range trunks {
if trunk.TrunkID == selected.TrunkID && trunk.Enabled && trunk.MaxConcurrentCalls != nil {
trunkLimit = *trunk.MaxConcurrentCalls
break
}
}
if trunkLimit <= 0 {
return fmt.Errorf("%w: selected trunk is disabled or has unknown quota", ErrCurrentNotReady)
}
count := func(query string, args ...any) (int64, error) {
var n int64
err := tx.QueryRow(query, args...).Scan(&n)
return n, err
}
const occupied = `status IN ('dispatching','dispatched','unknown')`
tenantUsed, err := count(`SELECT COUNT(*) FROM dispatcher_inbox WHERE dispatcher_id=? AND tenant_id=? AND `+occupied, dispatcherID, cmd.TenantID)
if err != nil {
return fmt.Errorf("count tenant occupancy: %w", err)
}
taskUsed, err := count(`SELECT COUNT(*) FROM dispatcher_inbox WHERE dispatcher_id=? AND tenant_id=? AND task_id=? AND `+occupied, dispatcherID, cmd.TenantID, cmd.TaskID)
if err != nil {
return fmt.Errorf("count task occupancy: %w", err)
}
trunkUsed, err := count(`SELECT COUNT(*) FROM dispatcher_inbox WHERE dispatcher_id=? AND selected_trunk_id=? AND `+occupied, dispatcherID, selected.TrunkID)
if err != nil {
return fmt.Errorf("count trunk occupancy: %w", err)
}
if tenantUsed >= snapshot.Quota.MaxConcurrentCalls || taskUsed >= snapshot.Task.MaxConcurrentCalls || trunkUsed >= trunkLimit {
return ErrCurrentCapacity
}
result, err := tx.Exec(`UPDATE dispatcher_inbox SET status='dispatching',selected_trunk_id=?,caller_id=?,dialed_callee=?,deadline=?,snapshot_json=?
WHERE dispatcher_id=? AND event_id=? AND status='pending'`, selected.TrunkID, selected.CallerID, selected.DialedCallee, selected.Deadline.UTC().Format(time.RFC3339Nano), body, dispatcherID, eventID)
if err != nil {
return fmt.Errorf("persist pre-originate fence: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return ErrCurrentAlreadyStarted
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit pre-originate fence: %w", err)
}
return nil
}
// CancelReservationBeforeOrigin is valid only before invoking the Agent RPC.
// It releases a reservation if the clock, SIP load, or schedule changed during
// the final pre-dial check; it must never be used after an unknown RPC outcome.
func (s *CurrentStore) CancelReservationBeforeOrigin(dispatcherID, eventID string) error {
result, err := s.db.Exec(`UPDATE dispatcher_inbox SET status=CASE WHEN EXISTS(
SELECT 1 FROM dispatcher_tasks t WHERE t.dispatcher_id=dispatcher_inbox.dispatcher_id
AND t.tenant_id=dispatcher_inbox.tenant_id AND t.task_id=dispatcher_inbox.task_id
AND t.control_state IN ('stopping','stopped')) THEN 'suppressed' ELSE 'pending' END,
selected_trunk_id=NULL,caller_id=NULL,dialed_callee=NULL,deadline=NULL,snapshot_json=NULL
WHERE dispatcher_id=? AND event_id=? AND status='dispatching'`, dispatcherID, eventID)
if err != nil {
return fmt.Errorf("cancel unused originate reservation: %w", err)
}
return requireOneRow(result, "cancel unused originate reservation")
}
func (s *CurrentStore) MarkExecuteUnknown(dispatcherID, eventID string) error {
result, err := s.db.Exec(`UPDATE dispatcher_inbox SET status='unknown' WHERE dispatcher_id=? AND event_id=? AND status='dispatching'`, dispatcherID, eventID)
if err != nil {
return fmt.Errorf("persist unknown execution: %w", err)
}
if err := requireOneRow(result, "unknown execution"); err != nil {
// The Agent may have confirmed the real end while the originate RPC was
// still returning a timeout. Never turn a durable finished call back into
// an unknown occupancy or treat that late timeout as another originate.
var confirmed int
scanErr := s.db.QueryRow(`SELECT COUNT(*) FROM dispatcher_inbox i JOIN dispatcher_outbox o
ON o.dispatcher_id=i.dispatcher_id AND o.event_id=i.event_id AND o.event_type='call.execute'
WHERE i.dispatcher_id=? AND i.event_id=? AND i.status='finished'`, dispatcherID, eventID).Scan(&confirmed)
if scanErr != nil {
return errors.Join(err, fmt.Errorf("inspect call after unknown RPC: %w", scanErr))
}
if confirmed == 1 {
return nil
}
return err
}
return nil
}
// FinishExecute accepts only an authenticated, confirmed Agent end fact. An
// end can race the originate RPC acknowledgment, or resolve a previously
// unknown RPC outcome. In both cases the acknowledgment and occupancy release
// commit together; a missing outbox never frees capacity.
func (s *CurrentStore) FinishExecute(dispatcherID, eventID string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var status, issuedAt string
var tenantID int64
if err := tx.QueryRow(`SELECT status,tenant_id,issued_at FROM dispatcher_inbox WHERE dispatcher_id=? AND event_id=?`, dispatcherID, eventID).Scan(&status, &tenantID, &issuedAt); err != nil {
return fmt.Errorf("inspect confirmed call end: %w", err)
}
switch status {
case "dispatching", "unknown":
if err := insertExecuteAck(tx, dispatcherID, eventID, tenantID, issuedAt, map[string]any{"status": "dispatched"}); err != nil {
return err
}
case "dispatched", "finished":
if err := requireExecuteAck(tx, dispatcherID, eventID); err != nil {
return err
}
if status == "finished" {
return nil
}
default:
return fmt.Errorf("call %q cannot confirm an end from state %q", eventID, status)
}
result, err := tx.Exec(`UPDATE dispatcher_inbox SET status='finished' WHERE dispatcher_id=? AND event_id=? AND status=?`, dispatcherID, eventID, status)
if err != nil {
return fmt.Errorf("persist confirmed call end: %w", err)
}
if err := requireOneRow(result, "confirmed call end"); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit confirmed call end and acknowledgment: %w", err)
}
return nil
}
func (s *CurrentStore) RejectExecute(dispatcherID, eventID, reason string) error {
if reason == "" {
return errors.New("rejection reason is required")
}
return s.closeExecuteWithAck(dispatcherID, eventID, "pending", "rejected", map[string]any{"status": "rejected", "reason_code": nil, "reason_message": reason})
}
func (s *CurrentStore) MarkExecuteDispatched(dispatcherID, eventID string) error {
return s.closeExecuteWithAck(dispatcherID, eventID, "dispatching", "dispatched", map[string]any{"status": "dispatched"})
}
func (s *CurrentStore) closeExecuteWithAck(dispatcherID, eventID, expectedStatus, nextStatus string, payload any) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var tenantID int64
var issuedAt, status string
err = tx.QueryRow(`SELECT tenant_id,issued_at,status FROM dispatcher_inbox WHERE dispatcher_id=? AND event_id=?`, dispatcherID, eventID).Scan(&tenantID, &issuedAt, &status)
if err != nil {
return fmt.Errorf("read call acknowledgment identity: %w", err)
}
if status != expectedStatus {
if expectedStatus == "dispatching" && nextStatus == "dispatched" && status == "finished" {
return requireExecuteAck(tx, dispatcherID, eventID)
}
return fmt.Errorf("call %q cannot transition from %s to %s", eventID, status, nextStatus)
}
result, err := tx.Exec(`UPDATE dispatcher_inbox SET status=? WHERE dispatcher_id=? AND event_id=? AND status=?`, nextStatus, dispatcherID, eventID, expectedStatus)
if err != nil {
return fmt.Errorf("persist call acknowledgment status: %w", err)
}
if err := requireOneRow(result, "call acknowledgment status"); err != nil {
return err
}
if err := insertExecuteAck(tx, dispatcherID, eventID, tenantID, issuedAt, payload); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit call status and acknowledgment: %w", err)
}
return nil
}
func insertExecuteAck(tx *sql.Tx, dispatcherID, eventID string, tenantID int64, issuedAt string, payload any) error {
body, err := json.Marshal(struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
DispatcherID string `json:"dispatcher_id"`
TenantID int64 `json:"tenant_id"`
IssuedAt string `json:"issued_at"`
Payload any `json:"payload"`
}{eventID, "call.execute", dispatcherID, tenantID, issuedAt, payload})
if err != nil {
return fmt.Errorf("encode call acknowledgment: %w", err)
}
if err := contract.ValidateCurrent("mq", body); err != nil {
return fmt.Errorf("call acknowledgment violates current MQ contract: %w", err)
}
route, err := tenant.CurrentResultRoute(dispatcherID)
if err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO dispatcher_outbox(dispatcher_id,event_id,event_type,routing_key,body) VALUES(?,?,?,?,?)`, dispatcherID, eventID, "call.execute", route.BindingKey, body); err != nil {
return fmt.Errorf("persist call acknowledgment outbox: %w", err)
}
return nil
}
func requireExecuteAck(tx *sql.Tx, dispatcherID, eventID string) error {
var eventType string
if err := tx.QueryRow(`SELECT event_type FROM dispatcher_outbox WHERE dispatcher_id=? AND event_id=?`, dispatcherID, eventID).Scan(&eventType); err != nil {
return fmt.Errorf("confirmed call %q has no durable acknowledgment: %w", eventID, err)
}
if eventType != "call.execute" {
return fmt.Errorf("confirmed call %q has conflicting acknowledgment type %q", eventID, eventType)
}
return nil
}
// PendingExecuteCount is scoped to one task so a rule wait pauses only its
// SaaS-owned task queue; other tasks and the control queue keep consuming.
func (s *CurrentStore) PendingExecuteCount(dispatcherID string, tenantID int64, taskID string) (int64, error) {
if dispatcherID == "" || tenantID <= 0 || taskID == "" {
return 0, errors.New("pending task count requires durable task identity")
}
var count int64
if err := s.db.QueryRow(`SELECT COUNT(*) FROM dispatcher_inbox WHERE dispatcher_id=? AND tenant_id=? AND task_id=? AND status='pending'`, dispatcherID, tenantID, taskID).Scan(&count); err != nil {
return 0, fmt.Errorf("count task-local rule waits: %w", err)
}
return count, nil
}
// TrunkOccupancy includes dispatching and unknown executions. No timeout or
// lease expiry releases a call whose real end has not been confirmed.
func (s *CurrentStore) TrunkOccupancy(dispatcherID string) (map[string]int64, error) {
rows, err := s.db.Query(`SELECT selected_trunk_id, COUNT(*) FROM dispatcher_inbox
WHERE dispatcher_id=? AND status IN ('dispatching','dispatched','unknown')
AND selected_trunk_id IS NOT NULL GROUP BY selected_trunk_id`, dispatcherID)
if err != nil {
return nil, fmt.Errorf("count occupied SIP trunks: %w", err)
}
defer rows.Close()
occupied := make(map[string]int64)
for rows.Next() {
var trunkID string
var count int64
if err := rows.Scan(&trunkID, &count); err != nil {
return nil, err
}
occupied[trunkID] = count
}
if err := rows.Err(); err != nil {
return nil, err
}
return occupied, nil
}
func (s *CurrentStore) ListPendingExecute(dispatcherID string) ([]CurrentExecuteCommand, error) {
rows, err := s.db.Query(`SELECT dispatcher_id,event_id,tenant_id,task_id,callee,issued_at FROM dispatcher_inbox WHERE dispatcher_id=? AND status='pending' ORDER BY rowid`, dispatcherID)
if err != nil {
return nil, fmt.Errorf("list pending call commands: %w", err)
}
defer rows.Close()
var commands []CurrentExecuteCommand
for rows.Next() {
var cmd CurrentExecuteCommand
if err := rows.Scan(&cmd.DispatcherID, &cmd.EventID, &cmd.TenantID, &cmd.TaskID, &cmd.Callee, &cmd.IssuedAt); err != nil {
return nil, err
}
commands = append(commands, cmd)
}
if err := rows.Err(); err != nil {
return nil, err
}
return commands, nil
}
func (s *CurrentStore) ListPendingOutbox(dispatcherID string) ([]CurrentOutboxEvent, error) {
rows, err := s.db.Query(`SELECT event_id,event_type,routing_key,body FROM dispatcher_outbox WHERE dispatcher_id=? AND confirmed=0 ORDER BY rowid`, dispatcherID)
if err != nil {
return nil, fmt.Errorf("list pending SaaS events: %w", err)
}
defer rows.Close()
var events []CurrentOutboxEvent
for rows.Next() {
var event CurrentOutboxEvent
if err := rows.Scan(&event.EventID, &event.EventType, &event.RoutingKey, &event.Body); err != nil {
return nil, err
}
events = append(events, event)
}
if err := rows.Err(); err != nil {
return nil, err
}
return events, nil
}
// Confirm means delivery to a bound queue, not application receipt. The row
// remains durable and is never deleted without separate explicit evidence.
func (s *CurrentStore) MarkOutboxConfirmed(dispatcherID, eventID string) error {
result, err := s.db.Exec(`UPDATE dispatcher_outbox SET confirmed=1,confirmed_at=? WHERE dispatcher_id=? AND event_id=? AND confirmed=0`, time.Now().UTC().Format(time.RFC3339Nano), dispatcherID, eventID)
if err != nil {
return fmt.Errorf("persist MQ queue confirm: %w", err)
}
return requireOneRow(result, "MQ queue confirm")
}
func requireOneRow(result sql.Result, operation string) error {
count, err := result.RowsAffected()
if err != nil {
return err
}
if count != 1 {
return fmt.Errorf("%s expected exactly one state transition, got %d", operation, count)
}
return nil
}