Files
go-sip/internal/store/cleanup.go
T

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
}