276 lines
11 KiB
Go
276 lines
11 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/contract"
|
|
)
|
|
|
|
// CleanupResult reports deleted logical data, not a reduction in SQLite's
|
|
// physical size. Completed pages may be reused without shrinking the file.
|
|
type CleanupResult struct {
|
|
DeletedRows int
|
|
DataBytes int64
|
|
RetainedReason string
|
|
}
|
|
|
|
// HoldCallLifecycle protects in-flight handlers and detached pending batches
|
|
// from cleanup. It does not create a lasting execution identity or journal.
|
|
func (s *Store) HoldCallLifecycle() func() {
|
|
s.cleanupMu.RLock()
|
|
return s.cleanupMu.RUnlock
|
|
}
|
|
|
|
type cleanupFeedback 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 struct {
|
|
Status string `json:"status"`
|
|
TaskID string `json:"task_id"`
|
|
Action string `json:"action"`
|
|
TaskState string `json:"task_state"`
|
|
Callee string `json:"callee"`
|
|
TrunkID string `json:"trunk_id"`
|
|
Recording json.RawMessage `json:"recording"`
|
|
} `json:"payload"`
|
|
}
|
|
|
|
func confirmedCleanupFeedback(tx *sql.Tx, dispatcherID, eventType, eventID string) (cleanupFeedback, []byte, bool, error) {
|
|
var body []byte
|
|
var confirmed int
|
|
var confirmedAt sql.NullString
|
|
err := tx.QueryRow(`SELECT body,confirmed,confirmed_at FROM dispatcher_outbox WHERE dispatcher_id=? AND event_type=? AND event_id=?`, dispatcherID, eventType, eventID).Scan(&body, &confirmed, &confirmedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return cleanupFeedback{}, nil, false, nil
|
|
}
|
|
if err != nil {
|
|
return cleanupFeedback{}, nil, false, fmt.Errorf("inspect cleanup feedback: %w", err)
|
|
}
|
|
if confirmed != 1 {
|
|
return cleanupFeedback{}, nil, false, nil
|
|
}
|
|
if !confirmedAt.Valid || confirmedAt.String == "" {
|
|
return cleanupFeedback{}, nil, false, errors.New("confirmed feedback lacks its confirmation time")
|
|
}
|
|
if err := contract.ValidateCurrent("mq", body); err != nil {
|
|
return cleanupFeedback{}, nil, false, fmt.Errorf("invalid cleanup feedback: %w", err)
|
|
}
|
|
var event cleanupFeedback
|
|
if err := json.Unmarshal(body, &event); err != nil {
|
|
return event, nil, false, err
|
|
}
|
|
if event.DispatcherID != dispatcherID || event.EventID != eventID || event.EventType != eventType {
|
|
return event, nil, false, errors.New("cleanup feedback envelope differs from its durable identity")
|
|
}
|
|
return event, body, true, nil
|
|
}
|
|
|
|
// CleanupCall rechecks completion in the deletion transaction. The final-result
|
|
// row is committed only after all fallible ReportCallResult validation succeeds;
|
|
// no new Agent acknowledgment protocol or database layout is required.
|
|
func (s *Store) CleanupCall(dispatcherID, eventID string) (result CleanupResult, err error) {
|
|
if dispatcherID == "" || eventID == "" {
|
|
return result, errors.New("cleanup requires Dispatcher and call identity")
|
|
}
|
|
s.cleanupMu.Lock()
|
|
defer s.cleanupMu.Unlock()
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return result, fmt.Errorf("begin call cleanup: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
var tenantID int64
|
|
var state, taskID, callee, trunkID, issuedAt string
|
|
var snapshot []byte
|
|
err = tx.QueryRow(`SELECT tenant_id,task_id,callee,status,COALESCE(selected_trunk_id,''),issued_at,snapshot_json FROM dispatcher_inbox WHERE dispatcher_id=? AND event_id=?`, dispatcherID, eventID).Scan(&tenantID, &taskID, &callee, &state, &trunkID, &issuedAt, &snapshot)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
result.RetainedReason = "no_retained_execution"
|
|
return result, nil
|
|
}
|
|
if err != nil {
|
|
return result, fmt.Errorf("inspect call cleanup identity: %w", err)
|
|
}
|
|
if state != "finished" && state != "rejected" && state != "suppressed" {
|
|
result.RetainedReason = "execution_" + state
|
|
return result, nil
|
|
}
|
|
var ackBody, resultBody []byte
|
|
if state == "suppressed" {
|
|
if trunkID != "" || len(snapshot) != 0 {
|
|
return result, errors.New("suppressed call contains evidence of an issued reservation")
|
|
}
|
|
var references int
|
|
if err := tx.QueryRow(`SELECT (SELECT COUNT(*) FROM dispatcher_recordings WHERE dispatcher_id=? AND source_event_id=?)+(SELECT COUNT(*) FROM dispatcher_outbox WHERE dispatcher_id=? AND event_id=? AND event_type IN ('call.execute','call.execute.result'))`, dispatcherID, eventID, dispatcherID, eventID).Scan(&references); err != nil {
|
|
return result, err
|
|
}
|
|
if references != 0 {
|
|
return result, errors.New("suppressed call contains conflicting upload or call feedback")
|
|
}
|
|
ready, err := confirmedStoppedTask(tx, dispatcherID, tenantID, taskID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if !ready {
|
|
result.RetainedReason = "stop_not_confirmed"
|
|
return result, nil
|
|
}
|
|
} else {
|
|
ack, body, ready, err := confirmedCleanupFeedback(tx, dispatcherID, "call.execute", eventID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if !ready {
|
|
result.RetainedReason = "call_receipt_not_confirmed"
|
|
return result, nil
|
|
}
|
|
if ack.TenantID != tenantID || ack.IssuedAt != issuedAt {
|
|
return result, errors.New("call cleanup receipt differs from its accepted command")
|
|
}
|
|
ackBody = body
|
|
if state == "rejected" {
|
|
if ack.Payload.Status != "rejected" {
|
|
return result, errors.New("rejected call has a non-rejection receipt")
|
|
}
|
|
var conflicting int
|
|
if err := tx.QueryRow(`SELECT (SELECT COUNT(*) FROM dispatcher_recordings WHERE dispatcher_id=? AND source_event_id=?)+(SELECT COUNT(*) FROM dispatcher_outbox WHERE dispatcher_id=? AND event_id=? AND event_type='call.execute.result')`, dispatcherID, eventID, dispatcherID, eventID).Scan(&conflicting); err != nil {
|
|
return result, err
|
|
}
|
|
if conflicting != 0 {
|
|
return result, errors.New("rejected call has recording or final-result evidence")
|
|
}
|
|
// File evidence cannot lose its confirmed receipt before its own
|
|
// coordinated cleanup has completed.
|
|
entries, err := os.ReadDir(filepath.Join(filepath.Dir(s.path), "rejected-data"))
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return result, fmt.Errorf("inspect rejection evidence before call cleanup: %w", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
result.RetainedReason = "rejection_files_require_cleanup"
|
|
return result, nil
|
|
}
|
|
} else {
|
|
if ack.Payload.Status != "dispatched" || trunkID == "" || len(snapshot) == 0 {
|
|
return result, errors.New("finished call lacks its frozen dispatch evidence")
|
|
}
|
|
final, body, ready, err := confirmedCleanupFeedback(tx, dispatcherID, "call.execute.result", eventID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if !ready {
|
|
result.RetainedReason = "final_result_not_confirmed"
|
|
return result, nil
|
|
}
|
|
if final.TenantID != tenantID || final.Payload.TaskID != taskID || final.Payload.Callee != callee || final.Payload.TrunkID != trunkID {
|
|
return result, errors.New("final cleanup result differs from the finished call")
|
|
}
|
|
if err := validateCleanupRecording(tx, dispatcherID, eventID, final.Payload.Recording); err != nil {
|
|
return result, err
|
|
}
|
|
resultBody = body
|
|
}
|
|
}
|
|
queries := []struct {
|
|
query string
|
|
args []any
|
|
}{
|
|
{`DELETE FROM dispatcher_recordings WHERE dispatcher_id=? AND source_event_id=?`, []any{dispatcherID, eventID}},
|
|
{`DELETE FROM dispatcher_outbox WHERE dispatcher_id=? AND event_id=? AND event_type IN ('call.execute','call.execute.result')`, []any{dispatcherID, eventID}},
|
|
{`DELETE FROM dispatcher_inbox WHERE dispatcher_id=? AND event_id=? AND status=?`, []any{dispatcherID, eventID, state}},
|
|
}
|
|
for _, deletion := range queries {
|
|
affected, err := tx.Exec(deletion.query, deletion.args...)
|
|
if err != nil {
|
|
return CleanupResult{}, fmt.Errorf("delete completed call data: %w", err)
|
|
}
|
|
n, err := affected.RowsAffected()
|
|
if err != nil {
|
|
return CleanupResult{}, err
|
|
}
|
|
result.DeletedRows += int(n)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return CleanupResult{}, fmt.Errorf("commit completed call cleanup: %w", err)
|
|
}
|
|
result.DataBytes = int64(len(snapshot) + len(ackBody) + len(resultBody))
|
|
return result, nil
|
|
}
|
|
|
|
func confirmedStoppedTask(tx *sql.Tx, dispatcherID string, tenantID int64, taskID string) (bool, error) {
|
|
var stopped int
|
|
if err := tx.QueryRow(`SELECT COUNT(*) FROM dispatcher_tasks WHERE dispatcher_id=? AND tenant_id=? AND task_id=? AND control_state='stopped'`, dispatcherID, tenantID, taskID).Scan(&stopped); err != nil {
|
|
return false, err
|
|
}
|
|
if stopped == 0 {
|
|
return false, nil
|
|
}
|
|
rows, err := tx.Query(`SELECT event_id FROM dispatcher_outbox WHERE dispatcher_id=? AND event_type='task.control' AND confirmed=1
|
|
AND CASE WHEN json_valid(body) THEN json_extract(body,'$.tenant_id') END=?
|
|
AND CASE WHEN json_valid(body) THEN json_extract(body,'$.payload.task_id') END=?`, dispatcherID, tenantID, taskID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return false, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, id := range ids {
|
|
event, _, confirmed, err := confirmedCleanupFeedback(tx, dispatcherID, "task.control", id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if confirmed && event.TenantID == tenantID && event.Payload.TaskID == taskID && event.Payload.Action == "stop" && event.Payload.Status == "applied" && event.Payload.TaskState == "stopped" {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func validateCleanupRecording(tx *sql.Tx, dispatcherID, eventID string, raw json.RawMessage) error {
|
|
grant, err := loadRecordingUpload(tx.QueryRow(`SELECT dispatcher_id,source_event_id,upload_id,recording_id,bucket,object_key,checksum_sha256,size_bytes,format,channels,sample_rate_hz,duration_ms,COALESCE(confirmed_at,'') FROM dispatcher_recordings WHERE dispatcher_id=? AND source_event_id=?`, dispatcherID, eventID))
|
|
var recording struct {
|
|
Status string `json:"status"`
|
|
Bucket string `json:"bucket"`
|
|
ObjectKey string `json:"object_key"`
|
|
Checksum string `json:"checksum_sha256"`
|
|
Size int64 `json:"size_bytes"`
|
|
Format string `json:"format"`
|
|
Channels int `json:"channels"`
|
|
SampleRate int `json:"sample_rate_hz"`
|
|
Duration int64 `json:"duration_ms"`
|
|
}
|
|
if decodeErr := json.Unmarshal(raw, &recording); decodeErr != nil {
|
|
return fmt.Errorf("decode final cleanup recording: %w", decodeErr)
|
|
}
|
|
if errors.Is(err, ErrUploadNotFound) {
|
|
if recording.Status != "" {
|
|
return errors.New("final cleanup recording has no original upload binding")
|
|
}
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if grant.ConfirmedAt == "" || recording.Status != "uploaded" || recording.Bucket != grant.Bucket || recording.ObjectKey != grant.ObjectKey || recording.Checksum != grant.ChecksumSHA256 || recording.Size != grant.SizeBytes || recording.Format != grant.Format || recording.Channels != grant.Channels || recording.SampleRate != grant.SampleRateHz || recording.Duration != grant.DurationMS {
|
|
return errors.New("cleanup recording differs from its confirmed original upload")
|
|
}
|
|
return nil
|
|
}
|