479 lines
20 KiB
Go
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
|
|
}
|