package store import ( "database/sql" "errors" "fmt" ) var ErrCurrentSIPPending = errors.New("new SIP revision is pending drain or verified load") // NoteSIPChange durably closes admission before the control queue is ACKed. // Superseded notifications cannot roll back an already applied revision. func (s *CurrentStore) NoteSIPChange(dispatcherID string, revision int64) error { if dispatcherID == "" || revision <= 0 { return errors.New("SIP notification requires Dispatcher identity and positive revision") } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`INSERT OR IGNORE INTO dispatcher_state(dispatcher_id) VALUES(?)`, dispatcherID); err != nil { return fmt.Errorf("persist SIP notification owner: %w", err) } var applied, pending int64 if err := tx.QueryRow(`SELECT applied_sip_revision,pending_sip_revision FROM dispatcher_state WHERE dispatcher_id=?`, dispatcherID).Scan(&applied, &pending); err != nil { return fmt.Errorf("read SIP notification state: %w", err) } if revision <= applied && pending == 0 { return tx.Commit() } if revision > pending { pending = revision } if _, err := tx.Exec(`UPDATE dispatcher_state SET discovery_ready=0,pending_sip_revision=? WHERE dispatcher_id=?`, pending, dispatcherID); err != nil { return fmt.Errorf("close admission for pending SIP revision: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit pending SIP revision: %w", err) } return nil } func (s *CurrentStore) SIPState(dispatcherID string) (applied, pending int64, err error) { err = s.db.QueryRow(`SELECT applied_sip_revision,pending_sip_revision FROM dispatcher_state WHERE dispatcher_id=?`, dispatcherID).Scan(&applied, &pending) if err != nil { return 0, 0, fmt.Errorf("read durable SIP revision state: %w", err) } return applied, pending, nil } // OccupiedCalls includes dispatching, dispatched, and unknown executions. // No lease expiry, SIP reload, or broker restart releases an uncertain call. func (s *CurrentStore) OccupiedCalls(dispatcherID string) (int64, error) { var count int64 if err := s.db.QueryRow(`SELECT COUNT(*) FROM dispatcher_inbox WHERE dispatcher_id=? AND status IN ('dispatching','dispatched','unknown')`, dispatcherID).Scan(&count); err != nil { return 0, fmt.Errorf("count calls before SIP reload: %w", err) } return count, nil } // MarkReadyForSIP opens admission only after all present task bindings match // this exact verified SIP revision and any prior SIP calls have drained. // The loaded Agent/Asterisk state is independently checked by the caller. func (s *CurrentStore) MarkReadyForSIP(dispatcherID string, revision int64) error { if dispatcherID == "" || revision <= 0 { return errors.New("admission requires a verified SIP revision") } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() var applied, pending int64 err = tx.QueryRow(`SELECT applied_sip_revision,pending_sip_revision FROM dispatcher_state WHERE dispatcher_id=?`, dispatcherID).Scan(&applied, &pending) if errors.Is(err, sql.ErrNoRows) { return errors.New("cannot open admission without committed discovery") } if err != nil { return fmt.Errorf("read admission SIP revision: %w", err) } if revision < applied { return fmt.Errorf("SIP revision %d regresses from applied %d", revision, applied) } if revision < pending { return fmt.Errorf("%w: verified revision %d is behind pending %d", ErrCurrentSIPPending, revision, pending) } var missing int64 if err := tx.QueryRow(`SELECT COUNT(*) FROM dispatcher_tasks t LEFT 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 WHERE t.dispatcher_id=? AND t.present=1 AND t.status<>'stopped' AND t.control_state NOT IN ('stopped','stopping') AND (c.task_revision IS NULL OR c.task_revision<>t.task_revision OR c.sip_revision<>?)`, dispatcherID, revision).Scan(&missing); err != nil { return fmt.Errorf("verify all task bindings for SIP admission: %w", err) } if missing != 0 { return fmt.Errorf("%d assigned tasks lack the verified SIP revision %d", missing, revision) } if pending != 0 || (applied != 0 && applied != revision) { var occupied int64 if err := tx.QueryRow(`SELECT COUNT(*) FROM dispatcher_inbox WHERE dispatcher_id=? AND status IN ('dispatching','dispatched','unknown')`, dispatcherID).Scan(&occupied); err != nil { return fmt.Errorf("check old calls before SIP admission: %w", err) } if occupied != 0 { return fmt.Errorf("%w: %d old or unknown calls have not drained for revision %d", ErrCurrentSIPPending, occupied, revision) } } result, err := tx.Exec(`UPDATE dispatcher_state SET discovery_ready=1,applied_sip_revision=?,pending_sip_revision=0 WHERE dispatcher_id=?`, revision, dispatcherID) if err != nil { return fmt.Errorf("commit applied SIP revision: %w", err) } if n, err := result.RowsAffected(); err != nil || n != 1 { return fmt.Errorf("cannot open SIP admission without committed discovery: rows=%d %v", n, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit SIP admission: %w", err) } return nil }