package store import ( "database/sql" "encoding/json" "errors" "fmt" "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 ErrControlRejected = errors.New("task control rejected by durable task state") // CompleteStart binds a freshly verified task to its predeclared queue and // records the control acknowledgment together. It never reopens a task // that is currently paused or stopped. func (s *Store) CompleteStart(snapshot configread.Snapshot, eventID string) error { task := snapshot.Task if task.Status != "running" { return fmt.Errorf("%w: start requires a running task", ErrControlRejected) } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() var state, status string err = tx.QueryRow(`SELECT control_state,status FROM dispatcher_tasks WHERE dispatcher_id=? AND tenant_id=? AND task_id=?`, task.DispatcherID, task.TenantID, task.TaskID).Scan(&state, &status) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("check start task state: %w", err) } if err == nil && (state != "" || status != "running") { return fmt.Errorf("%w: start cannot reopen a paused or stopped task", ErrControlRejected) } if err := applyDiscoveredTasks(tx, task.DispatcherID, []configread.DiscoveredTask{{TenantID: task.TenantID, TaskID: task.TaskID, TaskRevision: task.TaskRevision, Status: task.Status}}); err != nil { return err } if err := enqueueControlAck(tx, task.DispatcherID, task.TenantID, eventID, "applied"); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("commit started task and control acknowledgment: %w", err) } return nil } // PrepareControl closes only this task's admission before requesting the // Agent action. No successful control acknowledgment exists at this point. // Repeated commands are prepared and dispatched again; this is not control // deduplication or an expected-revision/CAS API. func (s *Store) PrepareControl(dispatcherID string, tenantID int64, taskID, action string) error { if dispatcherID == "" || tenantID <= 0 || taskID == "" { return errors.New("invalid task control identity") } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() var state, status string var revision int64 var present int err = tx.QueryRow(`SELECT control_state,status,task_revision,present FROM dispatcher_tasks WHERE dispatcher_id=? AND tenant_id=? AND task_id=?`, dispatcherID, tenantID, taskID).Scan(&state, &status, &revision, &present) if err != nil { return fmt.Errorf("load task control state: %w", err) } if present != 1 { return fmt.Errorf("%w: task is not in the assigned discovery list", ErrControlRejected) } var prepared string switch action { case "pause": if state == "stopped" || state == "stopping" { return fmt.Errorf("%w: stopped task cannot be paused", ErrControlRejected) } prepared = "pausing" case "stop": prepared = "stopping" case "resume": if state != "paused" && state != "resuming" { return fmt.Errorf("%w: resume requires a persistently paused, non-stopped task", ErrControlRejected) } if status != "running" { return fmt.Errorf("%w: fresh task is not running", ErrControlRejected) } var configuredRevision int64 err = tx.QueryRow(`SELECT task_revision FROM dispatcher_configs WHERE dispatcher_id=? AND tenant_id=? AND task_id=?`, dispatcherID, tenantID, taskID).Scan(&configuredRevision) if err != nil || configuredRevision != revision { return errors.New("resume requires the latest verified task configuration") } prepared = "resuming" default: return fmt.Errorf("invalid task control action %q", action) } if _, err := tx.Exec(`UPDATE dispatcher_tasks SET control_state=? WHERE dispatcher_id=? AND tenant_id=? AND task_id=?`, prepared, dispatcherID, tenantID, taskID); err != nil { return fmt.Errorf("persist task control barrier: %w", err) } 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 barrier: %w", err) } return nil } // CompleteControl atomically records the applied state and durable outbox // only after an Agent control RPC accepted the action. It does not claim that // active-call drain or hangup has already finished. func (s *Store) CompleteControl(dispatcherID string, tenantID int64, taskID, action, eventID string) error { var expected, final string switch action { case "pause": expected, final = "pausing", "paused" case "stop": expected, final = "stopping", "stopped" case "resume": expected, final = "resuming", "" default: return fmt.Errorf("invalid task control action %q", action) } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() var state, status string err = tx.QueryRow(`SELECT control_state,status FROM dispatcher_tasks WHERE dispatcher_id=? AND tenant_id=? AND task_id=? AND present=1`, dispatcherID, tenantID, taskID).Scan(&state, &status) if err != nil { return fmt.Errorf("read prepared task control: %w", err) } if state != expected || (action == "resume" && status != "running") { return fmt.Errorf("task %q control %q is no longer prepared or authorized", taskID, action) } if _, err := tx.Exec(`UPDATE dispatcher_tasks SET control_state=? WHERE dispatcher_id=? AND tenant_id=? AND task_id=?`, final, dispatcherID, tenantID, taskID); err != nil { return fmt.Errorf("persist applied task control: %w", err) } if err := enqueueControlAck(tx, dispatcherID, tenantID, eventID, "applied"); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("commit task control and outbox: %w", err) } return nil } func (s *Store) RejectControl(dispatcherID string, tenantID int64, eventID string) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if err := enqueueControlAck(tx, dispatcherID, tenantID, eventID, "rejected"); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("commit rejected task control: %w", err) } return nil } func enqueueControlAck(tx *sql.Tx, dispatcherID string, tenantID int64, eventID, status string) error { if eventID == "" || len(eventID) > 255 || tenantID <= 0 || (status != "applied" && status != "rejected") { return errors.New("invalid task control acknowledgment identity or status") } route, err := tenant.ResultRoute(dispatcherID) if err != nil { return err } 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"` Payload struct { Status string `json:"status"` } `json:"payload"` }{EventID: eventID, EventType: "task.control", DispatcherID: dispatcherID, TenantID: tenantID, Payload: struct { Status string `json:"status"` }{status}}) if err != nil { return fmt.Errorf("encode task control acknowledgment: %w", err) } if err := contract.ValidateCurrent("mq", body); err != nil { return fmt.Errorf("task control acknowledgment violates MQ contract: %w", err) } var oldType string var oldBody []byte err = tx.QueryRow(`SELECT event_type,body FROM dispatcher_outbox WHERE dispatcher_id=? AND event_id=?`, dispatcherID, eventID).Scan(&oldType, &oldBody) if errors.Is(err, sql.ErrNoRows) { if _, err := tx.Exec(`INSERT INTO dispatcher_outbox(dispatcher_id,event_id,event_type,routing_key,body) VALUES(?,?,?,?,?)`, dispatcherID, eventID, "task.control", route.BindingKey, body); err != nil { return fmt.Errorf("persist task control outbox: %w", err) } return nil } if err != nil { return fmt.Errorf("inspect existing task control outbox: %w", err) } if oldType != "task.control" || string(oldBody) != string(body) { return fmt.Errorf("control event identity %q conflicts with another outbound result", eventID) } if _, err := tx.Exec(`UPDATE dispatcher_outbox SET confirmed=0,confirmed_at=NULL WHERE dispatcher_id=? AND event_id=?`, dispatcherID, eventID); err != nil { return fmt.Errorf("requeue repeated control acknowledgment: %w", err) } return nil }