package store import ( "bytes" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "git.ipao.vip/rogee/go-sip/internal/configread" ) // CurrentStore is the authority for the new numeric-tenant task model. It // deliberately refuses an old database instead of clearing or replaying data // whose owner/message identity cannot be proved safe to convert. type CurrentStore struct{ db *sql.DB } const currentSchema = ` CREATE TABLE IF NOT EXISTS dispatcher_state ( dispatcher_id TEXT PRIMARY KEY, discovery_ready INTEGER NOT NULL DEFAULT 0 CHECK(discovery_ready IN (0,1)), applied_sip_revision INTEGER NOT NULL DEFAULT 0 CHECK(applied_sip_revision >= 0), pending_sip_revision INTEGER NOT NULL DEFAULT 0 CHECK(pending_sip_revision >= 0) ); CREATE TABLE IF NOT EXISTS dispatcher_tasks ( dispatcher_id TEXT NOT NULL, tenant_id INTEGER NOT NULL CHECK(tenant_id > 0), task_id TEXT NOT NULL, task_revision INTEGER NOT NULL CHECK(task_revision > 0), status TEXT NOT NULL CHECK(status IN ('running','paused','stopped')), control_state TEXT NOT NULL DEFAULT '' CHECK(control_state IN ('','pausing','paused','stopping','stopped','resuming')), present INTEGER NOT NULL DEFAULT 1 CHECK(present IN (0,1)), PRIMARY KEY(dispatcher_id,tenant_id,task_id) ); CREATE TABLE IF NOT EXISTS dispatcher_configs ( dispatcher_id TEXT NOT NULL, tenant_id INTEGER NOT NULL CHECK(tenant_id > 0), task_id TEXT NOT NULL, task_revision INTEGER NOT NULL CHECK(task_revision > 0), sip_revision INTEGER NOT NULL CHECK(sip_revision > 0), quota_revision INTEGER NOT NULL CHECK(quota_revision > 0), content_sha256 TEXT NOT NULL, snapshot_json BLOB NOT NULL, PRIMARY KEY(dispatcher_id,tenant_id,task_id) ); CREATE TABLE IF NOT EXISTS dispatcher_inbox ( dispatcher_id TEXT NOT NULL, event_id TEXT NOT NULL, tenant_id INTEGER NOT NULL CHECK(tenant_id > 0), task_id TEXT NOT NULL, callee TEXT NOT NULL, issued_at TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','dispatching','dispatched','unknown','rejected','finished','suppressed')), selected_trunk_id TEXT, caller_id TEXT, dialed_callee TEXT, deadline TEXT, snapshot_json BLOB, PRIMARY KEY(dispatcher_id,event_id) ); CREATE INDEX IF NOT EXISTS dispatcher_inbox_occupancy ON dispatcher_inbox(dispatcher_id,tenant_id,task_id,selected_trunk_id,status); CREATE TABLE IF NOT EXISTS dispatcher_outbox ( dispatcher_id TEXT NOT NULL, event_id TEXT NOT NULL, event_type TEXT NOT NULL, routing_key TEXT NOT NULL, body BLOB NOT NULL, confirmed INTEGER NOT NULL DEFAULT 0 CHECK(confirmed IN (0,1)), confirmed_at TEXT, PRIMARY KEY(dispatcher_id,event_id) ); CREATE TABLE IF NOT EXISTS dispatcher_recordings ( dispatcher_id TEXT NOT NULL, source_event_id TEXT NOT NULL, upload_id TEXT NOT NULL, recording_id TEXT NOT NULL, bucket TEXT NOT NULL, object_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes INTEGER NOT NULL CHECK(size_bytes > 0), format TEXT NOT NULL, channels INTEGER NOT NULL CHECK(channels > 0), sample_rate_hz INTEGER NOT NULL CHECK(sample_rate_hz > 0), duration_ms INTEGER NOT NULL CHECK(duration_ms >= 0), confirmed_at TEXT, PRIMARY KEY(dispatcher_id,source_event_id), UNIQUE(dispatcher_id,upload_id), UNIQUE(bucket,object_key), FOREIGN KEY(dispatcher_id,source_event_id) REFERENCES dispatcher_inbox(dispatcher_id,event_id) );` func OpenCurrent(path string) (_ *CurrentStore, err error) { if path == "" { return nil, errors.New("SQLite path is required") } db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open current SQLite: %w", err) } defer func() { if err != nil { db.Close() } }() db.SetMaxOpenConns(1) rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) if err != nil { return nil, fmt.Errorf("inspect SQLite schema before writing: %w", err) } allowed := map[string]bool{"dispatcher_state": true, "dispatcher_tasks": true, "dispatcher_configs": true, "dispatcher_inbox": true, "dispatcher_outbox": true, "dispatcher_recordings": true} seen := make(map[string]bool, len(allowed)) var unexpected []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { rows.Close() return nil, err } if !allowed[name] { unexpected = append(unexpected, name) } else { seen[name] = true } } err = rows.Err() rows.Close() if err != nil { return nil, fmt.Errorf("inspect SQLite tables: %w", err) } if len(unexpected) > 0 { return nil, fmt.Errorf("old or unknown SQLite tables %s: preserve database and reconcile manually before admission", strings.Join(unexpected, ",")) } var version int if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { return nil, fmt.Errorf("inspect SQLite layout version before writing: %w", err) } if len(seen) == 0 { if version != 0 { return nil, fmt.Errorf("unknown SQLite layout version %d without tables: preserve database before admission", version) } } else { if len(seen) != len(allowed) || version != 2 { return nil, fmt.Errorf("existing SQLite layout is incomplete or obsolete (tables=%d, version=%d): preserve data before admission", len(seen), version) } var requiredColumns int if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('dispatcher_state') WHERE name IN ('discovery_ready','applied_sip_revision','pending_sip_revision')`).Scan(&requiredColumns); err != nil { return nil, fmt.Errorf("inspect durable SIP state layout: %w", err) } if requiredColumns != 3 { return nil, errors.New("existing SQLite lacks durable SIP admission columns: preserve data before admission") } var recordingColumns int if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('dispatcher_recordings') WHERE name IN ('dispatcher_id','source_event_id','upload_id','recording_id','bucket','object_key','checksum_sha256','size_bytes','format','channels','sample_rate_hz','duration_ms','confirmed_at')`).Scan(&recordingColumns); err != nil { return nil, fmt.Errorf("inspect original recording target layout: %w", err) } if recordingColumns != 13 { return nil, errors.New("existing SQLite lacks immutable recording target columns: preserve data before admission") } } for _, pragma := range []string{"PRAGMA busy_timeout=5000", "PRAGMA journal_mode=WAL", "PRAGMA synchronous=FULL", "PRAGMA foreign_keys=ON"} { if _, err := db.Exec(pragma); err != nil { return nil, fmt.Errorf("initialize SQLite %s: %w", pragma, err) } } if len(seen) == 0 { tx, err := db.Begin() if err != nil { return nil, err } defer tx.Rollback() if _, err := tx.Exec(currentSchema); err != nil { return nil, fmt.Errorf("create current SQLite schema: %w", err) } if _, err := tx.Exec(`PRAGMA user_version=2`); err != nil { return nil, fmt.Errorf("set current SQLite layout version: %w", err) } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit current SQLite schema: %w", err) } } else if _, err := db.Exec(currentSchema); err != nil { return nil, fmt.Errorf("verify current SQLite schema: %w", err) } return &CurrentStore{db: db}, nil } func (s *CurrentStore) Close() error { return s.db.Close() } // CloseAdmission is durable, including when startup fails before SIP loading. func (s *CurrentStore) CloseAdmission(dispatcherID string) error { if dispatcherID == "" { return errors.New("dispatcher ID is required") } if _, err := s.db.Exec(`INSERT INTO dispatcher_state(dispatcher_id,discovery_ready) VALUES(?,0) ON CONFLICT(dispatcher_id) DO UPDATE SET discovery_ready=0`, dispatcherID); err != nil { return fmt.Errorf("close task admission: %w", err) } return nil } // ApplyDiscoverySnapshot commits the entire cold-start list in one transaction. // It closes admission until the caller has drained the control queue. func (s *CurrentStore) ApplyDiscoverySnapshot(dispatcherID string, tasks []configread.CurrentDiscoveredTask) (err error) { if dispatcherID == "" { return errors.New("dispatcher ID is required") } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err = tx.Exec(`INSERT INTO dispatcher_state(dispatcher_id,discovery_ready) VALUES(?,0) ON CONFLICT(dispatcher_id) DO UPDATE SET discovery_ready=0`, dispatcherID); err != nil { return fmt.Errorf("close discovery admission: %w", err) } if _, err = tx.Exec(`UPDATE dispatcher_tasks SET present=0 WHERE dispatcher_id=?`, dispatcherID); err != nil { return fmt.Errorf("invalidate previous discovery list: %w", err) } if err := applyDiscoveredTasks(tx, dispatcherID, tasks); err != nil { return err } return tx.Commit() } // ApplyDiscoveryPage commits one delta page before its in-memory cursor may // advance. It never treats tasks absent from a delta page as retired. func (s *CurrentStore) ApplyDiscoveryPage(dispatcherID string, tasks []configread.CurrentDiscoveredTask) error { if dispatcherID == "" || len(tasks) == 0 { return errors.New("discovery delta requires a Dispatcher and nonempty page") } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if err := applyDiscoveredTasks(tx, dispatcherID, tasks); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("commit discovery page: %w", err) } return nil } func applyDiscoveredTasks(tx *sql.Tx, dispatcherID string, tasks []configread.CurrentDiscoveredTask) error { seen := make(map[string]struct{}, len(tasks)) for _, task := range tasks { if task.TaskID == "" || task.TenantID <= 0 || task.TaskRevision <= 0 || !validTaskStatus(task.Status) { return fmt.Errorf("invalid discovered task %q", task.TaskID) } if _, exists := seen[task.TaskID]; exists { return fmt.Errorf("duplicate discovered task %q", task.TaskID) } seen[task.TaskID] = struct{}{} var otherTenant int64 err := tx.QueryRow(`SELECT tenant_id FROM dispatcher_tasks WHERE dispatcher_id=? AND task_id=? AND tenant_id<>? LIMIT 1`, dispatcherID, task.TaskID, task.TenantID).Scan(&otherTenant) if err == nil { return fmt.Errorf("task %q was previously assigned to a different tenant", task.TaskID) } if !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("check task %q tenant ownership: %w", task.TaskID, err) } var oldRevision int64 err = tx.QueryRow(`SELECT task_revision FROM dispatcher_tasks WHERE dispatcher_id=? AND tenant_id=? AND task_id=?`, dispatcherID, task.TenantID, task.TaskID).Scan(&oldRevision) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("check task %q discovery revision: %w", task.TaskID, err) } if err == nil && task.TaskRevision < oldRevision { return fmt.Errorf("task %q discovery revision regressed", task.TaskID) } initialControl := "" if task.Status == "paused" || task.Status == "stopped" { initialControl = task.Status } if _, err = tx.Exec(`INSERT INTO dispatcher_tasks(dispatcher_id,tenant_id,task_id,task_revision,status,present,control_state) VALUES(?,?,?,?,?,1,?) ON CONFLICT(dispatcher_id,tenant_id,task_id) DO UPDATE SET task_revision=excluded.task_revision, status=CASE WHEN dispatcher_tasks.status='stopped' OR dispatcher_tasks.control_state IN ('stopping','stopped') THEN 'stopped' ELSE excluded.status END, present=1, control_state=CASE WHEN dispatcher_tasks.status='stopped' OR dispatcher_tasks.control_state IN ('stopping','stopped') OR excluded.status='stopped' THEN 'stopped' WHEN dispatcher_tasks.control_state<>'' THEN dispatcher_tasks.control_state WHEN excluded.status='paused' THEN 'paused' ELSE '' END`, dispatcherID, task.TenantID, task.TaskID, task.TaskRevision, task.Status, initialControl); err != nil { return fmt.Errorf("persist discovered task %q: %w", task.TaskID, err) } if task.Status == "stopped" || initialControl == "stopped" { if _, err := tx.Exec(`UPDATE dispatcher_inbox SET status='suppressed' WHERE dispatcher_id=? AND tenant_id=? AND task_id=? AND status='pending'`, dispatcherID, task.TenantID, task.TaskID); err != nil { return fmt.Errorf("suppress stopped task %q pending calls: %w", task.TaskID, err) } } } return nil } func validTaskStatus(status string) bool { return status == "running" || status == "paused" || status == "stopped" } type CurrentAssignedTask struct { TenantID int64 TaskID string TaskRevision int64 Status string ControlState string } // ListAssignedTasks is used to attach only the task queues assigned to this // Dispatcher. Stopped task queues can still drain old, unaccepted commands. func (s *CurrentStore) ListAssignedTasks(dispatcherID string) ([]CurrentAssignedTask, error) { rows, err := s.db.Query(`SELECT tenant_id,task_id,task_revision,status,control_state FROM dispatcher_tasks WHERE dispatcher_id=? AND present=1 ORDER BY task_id`, dispatcherID) if err != nil { return nil, fmt.Errorf("list assigned task queues: %w", err) } defer rows.Close() var assigned []CurrentAssignedTask for rows.Next() { var task CurrentAssignedTask if err := rows.Scan(&task.TenantID, &task.TaskID, &task.TaskRevision, &task.Status, &task.ControlState); err != nil { return nil, fmt.Errorf("read assigned task queue: %w", err) } assigned = append(assigned, task) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list assigned task queues: %w", err) } return assigned, nil } // CanAdmit checks persisted discovery and human controls; it does not replace // call-time whitelist, schedule, SIP load, quota or authorization checks. func (s *CurrentStore) CanAdmit(dispatcherID string, tenantID int64, taskID string) (bool, error) { var count int err := s.db.QueryRow(`SELECT COUNT(*) 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, tenantID, taskID).Scan(&count) if err != nil { return false, fmt.Errorf("check durable task admission: %w", err) } return count == 1, nil } // SaveSnapshot refuses an immutable task revision with different content. Only // the providers actually referenced by this task are included in its digest. func (s *CurrentStore) SaveSnapshot(snapshot configread.CurrentSnapshot) error { task := snapshot.Task if task.DispatcherID == "" || task.TenantID <= 0 || task.TaskID == "" || task.TaskRevision <= 0 || snapshot.SIP.DispatcherID != task.DispatcherID || snapshot.Quota.DispatcherID != task.DispatcherID || snapshot.Quota.TenantID != task.TenantID || snapshot.SIP.Revision <= 0 || snapshot.Quota.QuotaRevision <= 0 || !json.Valid(task.Raw) { return errors.New("invalid current task snapshot identity, revision, or body") } providers := make(map[string]configread.CurrentProvider) for _, ref := range []string{task.Agent.ASR.ProviderRef, task.Agent.LLM.ProviderRef, task.Agent.TTS.ProviderRef} { if ref == "" { continue } provider, ok := snapshot.Providers[ref] if !ok || !provider.Enabled || provider.Credential == "" { return fmt.Errorf("task provider %q is unavailable", ref) } providers[ref] = provider } var canonicalTask any decoder := json.NewDecoder(bytes.NewReader(task.Raw)) decoder.UseNumber() if err := decoder.Decode(&canonicalTask); err != nil { return fmt.Errorf("decode immutable task: %w", err) } binding, err := json.Marshal(struct { Task any `json:"task"` Providers map[string]configread.CurrentProvider `json:"providers"` }{canonicalTask, providers}) if err != nil { return fmt.Errorf("encode immutable task binding: %w", err) } full, err := json.Marshal(struct { Task any `json:"task"` Providers map[string]configread.CurrentProvider `json:"providers"` SIP configread.CurrentSIP `json:"sip"` Quota configread.CurrentQuota `json:"quota"` }{canonicalTask, providers, snapshot.SIP, snapshot.Quota}) if err != nil { return fmt.Errorf("encode task execution snapshot: %w", err) } sum := sha256.Sum256(binding) digest := hex.EncodeToString(sum[:]) tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if err := validateCurrentGlobalRevisions(tx, snapshot); err != nil { return err } var oldRevision int64 var oldDigest string err = tx.QueryRow(`SELECT task_revision,content_sha256 FROM dispatcher_configs WHERE dispatcher_id=? AND tenant_id=? AND task_id=?`, task.DispatcherID, task.TenantID, task.TaskID).Scan(&oldRevision, &oldDigest) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("read immutable snapshot version: %w", err) } if err == nil { if oldRevision > task.TaskRevision { return fmt.Errorf("task %q revision regressed", task.TaskID) } if oldRevision == task.TaskRevision && oldDigest != digest { return fmt.Errorf("task %q revision %d changed content", task.TaskID, task.TaskRevision) } if oldRevision == task.TaskRevision { if _, err := tx.Exec(`UPDATE dispatcher_configs SET sip_revision=?,quota_revision=?,snapshot_json=? WHERE dispatcher_id=? AND tenant_id=? AND task_id=?`, snapshot.SIP.Revision, snapshot.Quota.QuotaRevision, full, task.DispatcherID, task.TenantID, task.TaskID); err != nil { return fmt.Errorf("refresh global SIP/quota for task %q: %w", task.TaskID, err) } return tx.Commit() } } if _, err = tx.Exec(`INSERT INTO dispatcher_configs(dispatcher_id,tenant_id,task_id,task_revision,sip_revision,quota_revision,content_sha256,snapshot_json) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(dispatcher_id,tenant_id,task_id) DO UPDATE SET task_revision=excluded.task_revision,sip_revision=excluded.sip_revision,quota_revision=excluded.quota_revision, content_sha256=excluded.content_sha256,snapshot_json=excluded.snapshot_json`, task.DispatcherID, task.TenantID, task.TaskID, task.TaskRevision, snapshot.SIP.Revision, snapshot.Quota.QuotaRevision, digest, full); err != nil { return fmt.Errorf("persist task snapshot %q: %w", task.TaskID, err) } return tx.Commit() } // validateCurrentGlobalRevisions prevents two task bindings from silently // disagreeing about the same approved Dispatcher SIP or tenant quota revision. // Older revisions also cannot replace a newer binding across tasks. func validateCurrentGlobalRevisions(tx *sql.Tx, snapshot configread.CurrentSnapshot) error { newSIP, err := json.Marshal(snapshot.SIP) if err != nil { return fmt.Errorf("encode approved SIP revision: %w", err) } newQuota, err := json.Marshal(snapshot.Quota) if err != nil { return fmt.Errorf("encode approved quota revision: %w", err) } rows, err := tx.Query(`SELECT tenant_id,task_id,sip_revision,quota_revision,snapshot_json FROM dispatcher_configs WHERE dispatcher_id=?`, snapshot.Task.DispatcherID) if err != nil { return fmt.Errorf("inspect current global revision bindings: %w", err) } defer rows.Close() for rows.Next() { var tenantID, sipRevision, quotaRevision int64 var taskID string var raw []byte if err := rows.Scan(&tenantID, &taskID, &sipRevision, "aRevision, &raw); err != nil { return fmt.Errorf("read global revision binding: %w", err) } var old struct { SIP configread.CurrentSIP `json:"sip"` Quota configread.CurrentQuota `json:"quota"` } if err := json.Unmarshal(raw, &old); err != nil { return fmt.Errorf("decode global revision binding for task %q: %w", taskID, err) } if old.SIP.Revision != sipRevision || old.Quota.QuotaRevision != quotaRevision || old.Quota.TenantID != tenantID { return fmt.Errorf("stored global revision binding for task %q is inconsistent", taskID) } if sipRevision > snapshot.SIP.Revision { return fmt.Errorf("SIP revision regressed below task %q", taskID) } if sipRevision == snapshot.SIP.Revision { previous, err := json.Marshal(old.SIP) if err != nil { return fmt.Errorf("encode existing SIP revision: %w", err) } if !bytes.Equal(previous, newSIP) { return fmt.Errorf("SIP revision %d changed approved content across task %q", sipRevision, taskID) } } if tenantID != snapshot.Task.TenantID { continue } if quotaRevision > snapshot.Quota.QuotaRevision { return fmt.Errorf("tenant quota revision regressed below task %q", taskID) } if quotaRevision == snapshot.Quota.QuotaRevision { previous, err := json.Marshal(old.Quota) if err != nil { return fmt.Errorf("encode existing tenant quota revision: %w", err) } if !bytes.Equal(previous, newQuota) { return fmt.Errorf("tenant quota revision %d changed approved content across task %q", quotaRevision, taskID) } } } if err := rows.Err(); err != nil { return fmt.Errorf("read global revision bindings: %w", err) } return nil } // ReadSnapshot returns only the current durable binding. Corrupt or mismatched // data is an error, never a signal to fetch an old contract instead. func (s *CurrentStore) ReadSnapshot(dispatcherID string, tenantID int64, taskID string) (configread.CurrentSnapshot, error) { var body []byte var taskRevision, sipRevision, quotaRevision int64 err := s.db.QueryRow(`SELECT task_revision,sip_revision,quota_revision,snapshot_json FROM dispatcher_configs WHERE dispatcher_id=? AND tenant_id=? AND task_id=?`, dispatcherID, tenantID, taskID).Scan(&taskRevision, &sipRevision, "aRevision, &body) if err != nil { return configread.CurrentSnapshot{}, fmt.Errorf("load task execution snapshot: %w", err) } var decoded struct { Task json.RawMessage `json:"task"` Providers map[string]configread.CurrentProvider `json:"providers"` SIP configread.CurrentSIP `json:"sip"` Quota configread.CurrentQuota `json:"quota"` } if err := json.Unmarshal(body, &decoded); err != nil { return configread.CurrentSnapshot{}, fmt.Errorf("decode task execution snapshot: %w", err) } var task configread.CurrentTask if err := json.Unmarshal(decoded.Task, &task); err != nil { return configread.CurrentSnapshot{}, fmt.Errorf("decode stored task configuration: %w", err) } if task.DispatcherID != dispatcherID || task.TenantID != tenantID || task.TaskID != taskID || task.TaskRevision != taskRevision || decoded.SIP.DispatcherID != dispatcherID || decoded.SIP.Revision != sipRevision || decoded.Quota.DispatcherID != dispatcherID || decoded.Quota.TenantID != tenantID || decoded.Quota.QuotaRevision != quotaRevision { return configread.CurrentSnapshot{}, errors.New("persisted task snapshot identity or revision mismatch") } return configread.CurrentSnapshot{Task: task, Providers: decoded.Providers, SIP: decoded.SIP, Quota: decoded.Quota}, nil } func (s *CurrentStore) ApplyControl(dispatcherID string, tenantID int64, taskID, action string) error { if dispatcherID == "" || tenantID <= 0 || taskID == "" { return errors.New("invalid task control identity") } var newState string var predicate string switch action { case "pause": newState, predicate = "paused", "control_state <> 'stopped'" case "stop": newState, predicate = "stopped", "1=1" case "resume": newState, predicate = "", `control_state='paused' AND status='running' AND EXISTS (SELECT 1 FROM dispatcher_configs c WHERE c.dispatcher_id=dispatcher_tasks.dispatcher_id AND c.tenant_id=dispatcher_tasks.tenant_id AND c.task_id=dispatcher_tasks.task_id AND c.task_revision=dispatcher_tasks.task_revision)` default: return fmt.Errorf("unknown task control action %q", action) } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() query := `UPDATE dispatcher_tasks SET control_state=? WHERE dispatcher_id=? AND tenant_id=? AND task_id=? AND present=1 AND ` + predicate result, err := tx.Exec(query, newState, dispatcherID, tenantID, taskID) if err != nil { return fmt.Errorf("persist task control %q: %w", action, err) } n, err := result.RowsAffected() if err != nil { return err } if n != 1 { return fmt.Errorf("task control %q rejected for task %q", action, taskID) } if action == "stop" { if _, err := tx.Exec(`UPDATE dispatcher_inbox SET status='suppressed' WHERE dispatcher_id=? AND tenant_id=? AND task_id=? AND status='pending'`, dispatcherID, tenantID, taskID); err != nil { return fmt.Errorf("suppress unstarted stopped-task commands: %w", err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("commit task control %q: %w", action, err) } return nil }