feat(agent): clean delivered recording files with resumable deletion
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Recovery and result-journal instances share a root but are constructed by
|
||||
// different call/recovery paths. Locks exist only while a path is in use; they
|
||||
// are not a permanent identity history and do not serialize independent files.
|
||||
type privateStateLock struct {
|
||||
mu sync.Mutex
|
||||
users int
|
||||
}
|
||||
|
||||
var privateStateLocks = struct {
|
||||
sync.Mutex
|
||||
paths map[string]*privateStateLock
|
||||
}{paths: make(map[string]*privateStateLock)}
|
||||
|
||||
func holdPrivateState(path string) (func(), error) {
|
||||
key, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
privateStateLocks.Lock()
|
||||
lock := privateStateLocks.paths[key]
|
||||
if lock == nil {
|
||||
lock = &privateStateLock{}
|
||||
privateStateLocks.paths[key] = lock
|
||||
}
|
||||
lock.users++
|
||||
privateStateLocks.Unlock()
|
||||
lock.mu.Lock()
|
||||
return func() {
|
||||
lock.mu.Unlock()
|
||||
privateStateLocks.Lock()
|
||||
lock.users--
|
||||
if lock.users == 0 {
|
||||
delete(privateStateLocks.paths, key)
|
||||
}
|
||||
privateStateLocks.Unlock()
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RecordingCleanupResult struct {
|
||||
DeletedFiles int
|
||||
DeletedBytes int64
|
||||
RetainedReason string
|
||||
}
|
||||
|
||||
// cleanupDelivered requires the recording path lock. The original delivered
|
||||
// info remains the completion proof until its audio deletion is durable.
|
||||
func (r *RecordingRecovery) cleanupDelivered(entry RecordingRecoveryEntry) (result RecordingCleanupResult, cleanupErr error) {
|
||||
defer func() {
|
||||
attrs := append(recordingRecoveryAttrs(entry), "stage", "delivered_recording_cleanup", "deleted_files", result.DeletedFiles, "deleted_bytes", result.DeletedBytes)
|
||||
if cleanupErr != nil {
|
||||
slog.Error("Delivered recording cleanup failed; unfinished evidence retained", append(attrs, "error", cleanupErr)...)
|
||||
} else if result.RetainedReason != "" {
|
||||
slog.Info("Delivered recording retained", append(attrs, "reason", result.RetainedReason)...)
|
||||
} else {
|
||||
slog.Info("Delivered recording files cleaned", attrs...)
|
||||
}
|
||||
}()
|
||||
if entry.State != "delivered" || entry.Uploaded == nil {
|
||||
return result, errors.New("recording cleanup requires its persisted delivery receipt")
|
||||
}
|
||||
dir, audio, info, err := r.paths(entry.Bucket, entry.ObjectKey)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
// Never follow a directory link or remove shared parents/unknown sidecars.
|
||||
current := r.Root
|
||||
for _, segment := range append([]string{"", entry.Bucket}, strings.Split(entry.ObjectKey, "/")...) {
|
||||
if segment != "" {
|
||||
current = filepath.Join(current, segment)
|
||||
}
|
||||
stat, err := os.Lstat(current)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if !stat.IsDir() || stat.Mode()&os.ModeSymlink != 0 || stat.Mode().Perm()&0077 != 0 {
|
||||
return result, errors.New("recording cleanup target is not an owned private directory")
|
||||
}
|
||||
}
|
||||
items, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Name() != "recording.wav" && item.Name() != "info.json" {
|
||||
return result, errors.New("recording cleanup target contains an unrecognized file")
|
||||
}
|
||||
}
|
||||
journal := ResultJournal{Root: r.Root}
|
||||
resultPath, err := journal.path(entry.SourceEventID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
release, err := holdPrivateState(resultPath)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer release()
|
||||
pending, _, err := journal.load(entry.SourceEventID)
|
||||
if err == nil {
|
||||
if entry.DispatcherID != "" && (pending.DispatcherID != entry.DispatcherID || pending.TenantID != entry.TenantID) {
|
||||
return result, errors.New("original result reference conflicts with delivered recording ownership")
|
||||
}
|
||||
result.RetainedReason = "uncompleted_original_result_reference"
|
||||
return result, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return result, fmt.Errorf("inspect original recording result reference: %w", err)
|
||||
}
|
||||
// Only this already-confirmed deletion path accepts an absent owned file.
|
||||
remove := func(path string) error {
|
||||
stat, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !stat.Mode().IsRegular() || stat.Mode().Perm()&0077 != 0 {
|
||||
return errors.New("recording cleanup file is not an owned private regular file")
|
||||
}
|
||||
if r.removeFile != nil {
|
||||
err = r.removeFile(path)
|
||||
} else {
|
||||
err = os.Remove(path)
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
result.DeletedFiles++
|
||||
result.DeletedBytes += stat.Size()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Validate the completion proof's file before removing either member.
|
||||
stat, err := os.Lstat(info)
|
||||
if err != nil || !stat.Mode().IsRegular() || stat.Mode().Perm()&0077 != 0 {
|
||||
return result, errors.Join(errors.New("recording delivery proof is not a private regular file"), err)
|
||||
}
|
||||
if err := remove(audio); err != nil {
|
||||
return result, fmt.Errorf("delete delivered recording audio: %w", err)
|
||||
}
|
||||
if err := syncDirectory(dir); err != nil {
|
||||
return result, fmt.Errorf("persist delivered audio deletion: %w", err)
|
||||
}
|
||||
filesBeforeInfo, bytesBeforeInfo := result.DeletedFiles, result.DeletedBytes
|
||||
restoreProof := func() error {
|
||||
err := r.persistState(info, entry)
|
||||
if err == nil {
|
||||
result.DeletedFiles, result.DeletedBytes = filesBeforeInfo, bytesBeforeInfo
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := remove(info); err != nil {
|
||||
return result, fmt.Errorf("delete delivered recording info: %w", err)
|
||||
}
|
||||
if err := syncDirectory(dir); err != nil {
|
||||
return result, errors.Join(fmt.Errorf("persist delivered info deletion: %w", err), restoreProof())
|
||||
}
|
||||
if r.removeFile != nil {
|
||||
err = r.removeFile(dir)
|
||||
} else {
|
||||
err = os.Remove(dir)
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
// The audio is gone, but a restored delivered receipt lets the next
|
||||
// recovery pass retry this exact leaf without replaying PUT/report.
|
||||
return result, errors.Join(fmt.Errorf("remove completed recording leaf: %w", err), restoreProof())
|
||||
}
|
||||
if err := syncDirectory(filepath.Dir(dir)); err != nil {
|
||||
return result, fmt.Errorf("persist recording leaf deletion: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func deliveredRecordingFixture(t *testing.T, r *RecordingRecovery) RecordingRecoveryEntry {
|
||||
t.Helper()
|
||||
e := failedRecordingFixture()
|
||||
if _, err := r.SaveFailure([]byte("recording bytes"), e, &UploadHTTPError{StatusCode: 503}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, err := r.Load(e.Bucket, e.ObjectKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.State = "delivered"
|
||||
e.Uploaded = &UploadResult{StatusCode: 200, SizeBytes: e.SizeBytes, SHA256: e.SHA256}
|
||||
_, _, info, err := r.paths(e.Bucket, e.ObjectKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.persistState(info, e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestDeliveredRecordingCleanupAfterRestartWithoutAudio(t *testing.T) {
|
||||
r := &RecordingRecovery{Root: filepath.Join(t.TempDir(), "private"), Now: func() time.Time { return time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC) }}
|
||||
e := deliveredRecordingFixture(t, r)
|
||||
dir, audio, info, _ := r.paths(e.Bucket, e.ObjectKey)
|
||||
if err := os.Remove(audio); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted := &RecordingRecovery{Root: r.Root}
|
||||
if loaded, err := restarted.Load(e.Bucket, e.ObjectKey); err != nil || loaded.State != "delivered" {
|
||||
t.Fatalf("valid deletion intermediate state unreadable: %+v %v", loaded, err)
|
||||
}
|
||||
calls := 0
|
||||
after, err := restarted.Retry(context.Background(), e.Bucket, e.ObjectKey,
|
||||
func(context.Context, RecordingRecoveryEntry) (RecoveryTarget, error) {
|
||||
calls++
|
||||
return RecoveryTarget{}, errors.New("must not request a grant")
|
||||
},
|
||||
func(context.Context, RecordingRecoveryEntry) error {
|
||||
calls++
|
||||
return errors.New("must not report again")
|
||||
})
|
||||
if err != nil || after.State != "delivered" || calls != 0 {
|
||||
t.Fatalf("delivered cleanup replayed work: %+v calls=%d err=%v", after, calls, err)
|
||||
}
|
||||
for _, path := range []string{dir, audio, info} {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("completed recording remains at %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(dir)); err != nil {
|
||||
t.Fatal("cleanup deleted a shared parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveredRecordingRetainedWhileResultReferenceUnfinished(t *testing.T) {
|
||||
for _, state := range []string{"prepared", "ready", "end_pending"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
r := &RecordingRecovery{Root: filepath.Join(t.TempDir(), "private")}
|
||||
e := deliveredRecordingFixture(t, r)
|
||||
j := ResultJournal{Root: r.Root}
|
||||
entry := ResultEntry{SourceEventID: e.SourceEventID, DispatcherID: "dispatcher-1", TenantID: 1001, Payload: []byte(`{}`)}
|
||||
if err := j.save(entry, state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Retry(context.Background(), e.Bucket, e.ObjectKey, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, audio, info, _ := r.paths(e.Bucket, e.ObjectKey)
|
||||
for _, path := range []string{audio, info} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("pending reference lost recovery file: %v", err)
|
||||
}
|
||||
}
|
||||
if state == "prepared" {
|
||||
if err := j.DiscardPrepared(e.SourceEventID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if state == "end_pending" {
|
||||
if err := j.MarkEnded(e.SourceEventID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := j.Ack(e.SourceEventID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := r.Retry(context.Background(), e.Bucket, e.ObjectKey, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(info); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("reference-free delivered recording remains: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveredCleanupFailureResumesWithoutPUTOrReport(t *testing.T) {
|
||||
for _, failedStep := range []string{"info", "directory"} {
|
||||
t.Run(failedStep, func(t *testing.T) {
|
||||
r := &RecordingRecovery{Root: filepath.Join(t.TempDir(), "private")}
|
||||
e := deliveredRecordingFixture(t, r)
|
||||
dir, audio, info, _ := r.paths(e.Bucket, e.ObjectKey)
|
||||
r.removeFile = func(path string) error {
|
||||
if (failedStep == "info" && path == info) || (failedStep == "directory" && path == dir) {
|
||||
return errors.New("injected cleanup interruption")
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
if _, err := r.Retry(context.Background(), e.Bucket, e.ObjectKey, nil, nil); err == nil {
|
||||
t.Fatal("deletion interruption was swallowed")
|
||||
}
|
||||
if _, err := os.Stat(audio); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("original audio deletion did not finish: %v", err)
|
||||
}
|
||||
if loaded, err := r.Load(e.Bucket, e.ObjectKey); err != nil || loaded.State != "delivered" {
|
||||
t.Fatalf("interrupted cleanup lost its completion proof: %+v %v", loaded, err)
|
||||
}
|
||||
restarted := &RecordingRecovery{Root: r.Root}
|
||||
if _, err := restarted.Retry(context.Background(), e.Bucket, e.ObjectKey, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("restart did not finish exact-leaf cleanup: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveredMissingAudioStillValidatesOriginalReceipt(t *testing.T) {
|
||||
r := &RecordingRecovery{Root: filepath.Join(t.TempDir(), "private")}
|
||||
e := deliveredRecordingFixture(t, r)
|
||||
_, audio, info, _ := r.paths(e.Bucket, e.ObjectKey)
|
||||
if err := os.Remove(audio); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.SHA256 = "abc"
|
||||
e.Uploaded.SHA256 = "abc"
|
||||
if err := r.persistState(info, e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Load(e.Bucket, e.ObjectKey); !errors.Is(err, ErrRecordingRecoveryIncomplete) {
|
||||
t.Fatalf("malformed original receipt accepted after audio deletion: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(info); err != nil {
|
||||
t.Fatal("malformed evidence was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveredCleanupPreservesUnexpectedFilesAndNeighborRecords(t *testing.T) {
|
||||
r := &RecordingRecovery{Root: filepath.Join(t.TempDir(), "private")}
|
||||
e := deliveredRecordingFixture(t, r)
|
||||
dir, audio, info, _ := r.paths(e.Bucket, e.ObjectKey)
|
||||
unknown := filepath.Join(dir, "operator-notes")
|
||||
if err := os.WriteFile(unknown, []byte("retained"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Retry(context.Background(), e.Bucket, e.ObjectKey, nil, nil); err == nil {
|
||||
t.Fatal("unknown leaf file was silently ignored")
|
||||
}
|
||||
for _, path := range []string{unknown, audio, info} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("unknown leaf cleanup deleted data: %v", err)
|
||||
}
|
||||
}
|
||||
if err := os.Remove(unknown); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Retry(context.Background(), e.Bucket, e.ObjectKey, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveredRecoveryScanFinishesPartialDeletionDespiteIndependentBadDirectory(t *testing.T) {
|
||||
r := &RecordingRecovery{Root: filepath.Join(t.TempDir(), "private")}
|
||||
e := deliveredRecordingFixture(t, r)
|
||||
_, audio, info, _ := r.paths(e.Bucket, e.ObjectKey)
|
||||
if err := os.Remove(audio); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blockedDir := filepath.Join(r.Root, "blocked-bucket")
|
||||
if err := os.Mkdir(blockedDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(blockedDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
processed, err := r.RecoverDue(context.Background(),
|
||||
func(context.Context, RecordingRecoveryEntry) (RecoveryTarget, error) {
|
||||
calls++
|
||||
return RecoveryTarget{}, errors.New("must not request a grant")
|
||||
},
|
||||
func(context.Context, RecordingRecoveryEntry) error {
|
||||
calls++
|
||||
return errors.New("must not report again")
|
||||
})
|
||||
if err == nil || processed != 0 || calls != 0 {
|
||||
t.Fatalf("independent bad directory handling: processed=%d calls=%d err=%v", processed, calls, err)
|
||||
}
|
||||
if _, err := os.Stat(info); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("delivered intermediate state was not cleaned: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(blockedDir); err != nil {
|
||||
t.Fatal("unknown directory was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingCleanupLeavesNoPermanentInMemoryLocks(t *testing.T) {
|
||||
r := &RecordingRecovery{Root: filepath.Join(t.TempDir(), "private")}
|
||||
e := deliveredRecordingFixture(t, r)
|
||||
dir, _, _, _ := r.paths(e.Bucket, e.ObjectKey)
|
||||
resultPath, err := (ResultJournal{Root: r.Root}).path(e.SourceEventID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Retry(context.Background(), e.Bucket, e.ObjectKey, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateStateLocks.Lock()
|
||||
recordingHeld, resultHeld := privateStateLocks.paths[dir] != nil, privateStateLocks.paths[resultPath] != nil
|
||||
privateStateLocks.Unlock()
|
||||
if recordingHeld || resultHeld {
|
||||
t.Fatalf("completed entry kept permanent path locks: recording=%t result=%t", recordingHeld, resultHeld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAudioDoesNotRelaxUnfinishedRecordingValidation(t *testing.T) {
|
||||
r := &RecordingRecovery{Root: filepath.Join(t.TempDir(), "private")}
|
||||
entry := failedRecordingFixture()
|
||||
if _, err := r.SaveFailure([]byte("recording bytes"), entry, &UploadHTTPError{StatusCode: 503}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, audio, _, _ := r.paths(entry.Bucket, entry.ObjectKey)
|
||||
if err := os.Remove(audio); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Load(entry.Bucket, entry.ObjectKey); !errors.Is(err, ErrRecordingRecoveryIncomplete) {
|
||||
t.Fatalf("unfinished missing audio was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -48,13 +47,11 @@ type RecordingRecovery struct {
|
||||
Now func() time.Time
|
||||
Upload UploadClient
|
||||
|
||||
mu sync.Mutex
|
||||
writeState func(string, any) error // deterministic failure injection in package tests
|
||||
removeFile func(string) error // deletion failure injection in package tests
|
||||
}
|
||||
|
||||
func (r *RecordingRecovery) SaveFailure(audio []byte, entry RecordingRecoveryEntry, cause error) (RecordingRecoveryEntry, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var rejected *UploadHTTPError
|
||||
if !errors.Is(cause, ErrUploadOutcomeUnknown) && (!errors.As(cause, &rejected) || rejected.StatusCode < 300) {
|
||||
return RecordingRecoveryEntry{}, errors.New("only a failed OSS PUT may create recording recovery files")
|
||||
@@ -69,6 +66,11 @@ func (r *RecordingRecovery) SaveFailure(audio []byte, entry RecordingRecoveryEnt
|
||||
if err != nil {
|
||||
return RecordingRecoveryEntry{}, err
|
||||
}
|
||||
release, err := holdPrivateState(dir)
|
||||
if err != nil {
|
||||
return RecordingRecoveryEntry{}, err
|
||||
}
|
||||
defer release()
|
||||
sha := sha256.Sum256(audio)
|
||||
digest := hex.EncodeToString(sha[:])
|
||||
if entry.SHA256 != "" && !strings.EqualFold(entry.SHA256, digest) {
|
||||
@@ -113,6 +115,20 @@ func (r *RecordingRecovery) SaveFailure(audio []byte, entry RecordingRecoveryEnt
|
||||
}
|
||||
|
||||
func (r *RecordingRecovery) Load(bucket, objectKey string) (RecordingRecoveryEntry, error) {
|
||||
dir, _, _, err := r.paths(bucket, objectKey)
|
||||
if err != nil {
|
||||
return RecordingRecoveryEntry{}, err
|
||||
}
|
||||
release, err := holdPrivateState(dir)
|
||||
if err != nil {
|
||||
return RecordingRecoveryEntry{}, err
|
||||
}
|
||||
defer release()
|
||||
return r.load(bucket, objectKey)
|
||||
}
|
||||
|
||||
// load requires the caller's per-recording lock when used by recovery/cleanup.
|
||||
func (r *RecordingRecovery) load(bucket, objectKey string) (RecordingRecoveryEntry, error) {
|
||||
_, audioPath, infoPath, err := r.paths(bucket, objectKey)
|
||||
if err != nil {
|
||||
return RecordingRecoveryEntry{}, err
|
||||
@@ -139,6 +155,10 @@ func (r *RecordingRecovery) Load(bucket, objectKey string) (RecordingRecoveryEnt
|
||||
if entry.Bucket != bucket || entry.ObjectKey != objectKey || entry.CallID == "" || entry.SourceEventID == "" || entry.RecordingID == "" || entry.UploadID == "" || entry.SizeBytes <= 0 || entry.SHA256 == "" || !json.Valid(entry.ResultPayload) || entry.Attempts < 1 || entry.SavedAt.IsZero() || !entry.ExpiresAt.Equal(entry.SavedAt.Add(recordingRetryWindow)) {
|
||||
return RecordingRecoveryEntry{}, ErrRecordingRecoveryIncomplete
|
||||
}
|
||||
digest, decodeErr := hex.DecodeString(entry.SHA256)
|
||||
if decodeErr != nil || len(digest) != sha256.Size || hex.EncodeToString(digest) != entry.SHA256 {
|
||||
return RecordingRecoveryEntry{}, fmt.Errorf("%w: invalid original audio checksum", ErrRecordingRecoveryIncomplete)
|
||||
}
|
||||
switch entry.State {
|
||||
case "retry_pending", "outcome_unknown", "put_in_flight", "uploaded_unreported", "delivered", "expired":
|
||||
default:
|
||||
@@ -152,6 +172,9 @@ func (r *RecordingRecovery) Load(bucket, objectKey string) (RecordingRecoveryEnt
|
||||
return RecordingRecoveryEntry{}, ErrRecordingRecoveryIncomplete
|
||||
}
|
||||
audio, err := os.ReadFile(audioPath)
|
||||
if errors.Is(err, os.ErrNotExist) && entry.State == "delivered" {
|
||||
return entry, nil // legal interruption after confirmed audio deletion
|
||||
}
|
||||
if err != nil {
|
||||
return RecordingRecoveryEntry{}, fmt.Errorf("%w: read original audio: %v", ErrRecordingRecoveryIncomplete, err)
|
||||
}
|
||||
|
||||
@@ -54,9 +54,16 @@ func (r *RecordingRecovery) Retry(ctx context.Context, bucket, objectKey string,
|
||||
slog.InfoContext(ctx, "Recording recovery transition complete", attrs...)
|
||||
}
|
||||
}()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
entry, err := r.Load(bucket, objectKey)
|
||||
dir, _, _, err := r.paths(bucket, objectKey)
|
||||
if err != nil {
|
||||
return RecordingRecoveryEntry{}, err
|
||||
}
|
||||
release, err := holdPrivateState(dir)
|
||||
if err != nil {
|
||||
return RecordingRecoveryEntry{}, err
|
||||
}
|
||||
defer release()
|
||||
entry, err := r.load(bucket, objectKey)
|
||||
if err != nil {
|
||||
return RecordingRecoveryEntry{}, err
|
||||
}
|
||||
@@ -71,8 +78,9 @@ func (r *RecordingRecovery) Retry(ctx context.Context, bucket, objectKey string,
|
||||
}
|
||||
switch entry.State {
|
||||
case "delivered":
|
||||
phase = "recovery_already_delivered"
|
||||
return entry, nil
|
||||
phase = "delivered_recording_cleanup"
|
||||
_, err := r.cleanupDelivered(entry)
|
||||
return entry, err
|
||||
case "expired":
|
||||
return entry, ErrRecordingRetryExpired
|
||||
case "outcome_unknown", "put_in_flight":
|
||||
@@ -184,7 +192,8 @@ func (r *RecordingRecovery) reportUploaded(ctx context.Context, infoPath string,
|
||||
return entry, fmt.Errorf("persist delivered result receipt: %w", err)
|
||||
}
|
||||
slog.InfoContext(ctx, "Original result recovery acknowledged", append(recordingRecoveryAttrs(entry), "stage", "original_result_acknowledged")...)
|
||||
return entry, nil
|
||||
_, err := r.cleanupDelivered(entry)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
func (r *RecordingRecovery) persistState(infoPath string, entry RecordingRecoveryEntry) error {
|
||||
|
||||
@@ -85,12 +85,12 @@ func TestRecordingRecoveryBackoffAndResultRetryDoNotRepeatSuccessfulPUT(t *testi
|
||||
return RecoveryTarget{}, errors.New("duplicate grant")
|
||||
}
|
||||
processed, err := restarted.RecoverDue(context.Background(), neverGrant, report)
|
||||
finished, loadErr := restarted.Load(entry.Bucket, entry.ObjectKey)
|
||||
if err != nil || loadErr != nil || processed != 1 || finished.State != "delivered" || puts.Load() != 2 || grants.Load() != 2 || reports.Load() != 2 {
|
||||
t.Fatalf("restart must resend only original result: recovered=%d state=%s puts=%d grants=%d reports=%d err=%v load=%v", processed, finished.State, puts.Load(), grants.Load(), reports.Load(), err, loadErr)
|
||||
_, loadErr := restarted.Load(entry.Bucket, entry.ObjectKey)
|
||||
if err != nil || !errors.Is(loadErr, os.ErrNotExist) || processed != 1 || puts.Load() != 2 || grants.Load() != 2 || reports.Load() != 2 {
|
||||
t.Fatalf("restart must resend only original result then clean delivered files: recovered=%d puts=%d grants=%d reports=%d err=%v load=%v", processed, puts.Load(), grants.Load(), reports.Load(), err, loadErr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "mock-bucket", "tenant", "call", "rec.wav", "recording.wav")); err != nil {
|
||||
t.Fatalf("failed-path original audio must not be cleared automatically: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(root, "mock-bucket", "tenant", "call", "rec.wav", "recording.wav")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("delivered original audio remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ func (r *RecordingRecovery) RecoverDue(ctx context.Context, grant RecoveryGrant,
|
||||
return 0, err
|
||||
}
|
||||
processed := 0
|
||||
handled := make(map[string]bool)
|
||||
var blocked []error
|
||||
addBlocked := func(dir, stage string, cause error, identity ...any) {
|
||||
digest := sha256.Sum256([]byte(dir))
|
||||
@@ -51,28 +52,40 @@ func (r *RecordingRecovery) RecoverDue(ctx context.Context, grant RecoveryGrant,
|
||||
}
|
||||
walkErr := filepath.WalkDir(r.Root, func(path string, item fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
if path == r.Root {
|
||||
return err
|
||||
}
|
||||
addBlocked(path, "recovery_walk", err)
|
||||
if item != nil && item.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.IsDir() {
|
||||
if err := checkRecoveryDirectory(path); err != nil {
|
||||
addBlocked(path, "recovery_directory", err)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if path == filepath.Join(r.Root, ".results") {
|
||||
if err := checkRecoveryDirectory(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.SkipDir // result-only files have their own scanner
|
||||
}
|
||||
return checkRecoveryDirectory(path)
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
switch item.Name() {
|
||||
case "recording.wav":
|
||||
if handled[dir] {
|
||||
return nil
|
||||
} // info processing may have cleaned this pair
|
||||
if _, err := os.Stat(filepath.Join(dir, "info.json")); err != nil {
|
||||
addBlocked(dir, "recovery_validate", fmt.Errorf("%w: original audio has no call information", ErrRecordingRecoveryIncomplete))
|
||||
}
|
||||
return nil
|
||||
case "info.json":
|
||||
handled[dir] = true
|
||||
default:
|
||||
addBlocked(dir, "recovery_validate", fmt.Errorf("%w: unexpected recovery file", ErrRecordingRecoveryIncomplete))
|
||||
return nil
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestRecordingRecoveryScannerReportsOrphanInsteadOfInventingResult(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingRecoveryConcurrentRetryOnlyPutsOnce(t *testing.T) {
|
||||
func TestRecordingRecoveryConcurrentInstancesOnlyPutOnce(t *testing.T) {
|
||||
root := privateRecoveryRoot(t)
|
||||
now := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)
|
||||
recovery := RecordingRecovery{Root: root, Now: func() time.Time { return now }, Upload: UploadClient{AllowInsecureHTTP: true, Now: func() time.Time { return now }}}
|
||||
@@ -122,20 +122,30 @@ func TestRecordingRecoveryConcurrentRetryOnlyPutsOnce(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
instance := &RecordingRecovery{Root: recovery.Root, Now: recovery.Now, Upload: recovery.Upload}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := recovery.Retry(context.Background(), entry.Bucket, entry.ObjectKey, grant, report)
|
||||
_, err := instance.Retry(context.Background(), entry.Bucket, entry.ObjectKey, grant, report)
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
completed, removed := 0, 0
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
switch {
|
||||
case err == nil:
|
||||
completed++
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
removed++ // the first caller removed its confirmed entry; no tombstone remains
|
||||
default:
|
||||
t.Fatalf("serialized retry failed: %v", err)
|
||||
}
|
||||
}
|
||||
if completed != 1 || removed != 1 {
|
||||
t.Fatalf("concurrent callers invented a retained completion history: completed=%d removed=%d", completed, removed)
|
||||
}
|
||||
if puts.Load() != 1 || reports.Load() != 1 {
|
||||
t.Fatalf("concurrent retry repeated a completed side effect: puts=%d reports=%d", puts.Load(), reports.Load())
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ func (j ResultJournal) path(id string) (string, error) {
|
||||
return filepath.Join(j.Root, ".results", hex.EncodeToString(sum[:])+".json"), nil
|
||||
}
|
||||
|
||||
func (j ResultJournal) hold(id string) (func(), error) {
|
||||
path, err := j.path(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return holdPrivateState(path)
|
||||
}
|
||||
|
||||
func (j ResultJournal) prepare() (string, error) {
|
||||
if j.Root == "" {
|
||||
return "", errors.New("private result journal root is required")
|
||||
@@ -64,6 +72,11 @@ func (j ResultJournal) save(entry ResultEntry, state string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release, err := holdPrivateState(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
dir, err := j.prepare()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -128,6 +141,11 @@ func (j ResultJournal) load(id string) (ResultEntry, string, error) {
|
||||
}
|
||||
|
||||
func (j ResultJournal) MarkEnded(id string) error {
|
||||
release, err := j.hold(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
entry, path, err := j.load(id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -140,6 +158,11 @@ func (j ResultJournal) MarkEnded(id string) error {
|
||||
}
|
||||
|
||||
func (j ResultJournal) MarkUploaded(id string, observation *agentpb.UploadObservation) error {
|
||||
release, err := j.hold(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
entry, path, err := j.load(id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -153,6 +176,11 @@ func (j ResultJournal) MarkUploaded(id string, observation *agentpb.UploadObserv
|
||||
}
|
||||
|
||||
func (j ResultJournal) Ack(id string) error {
|
||||
release, err := j.hold(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
entry, path, err := j.load(id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -169,6 +197,11 @@ func (j ResultJournal) Ack(id string) error {
|
||||
// DiscardPrepared is only called after the durable audio+result recovery entry
|
||||
// has itself reported the same final result. Old entries have no journal.
|
||||
func (j ResultJournal) DiscardPrepared(id string) error {
|
||||
release, err := j.hold(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
entry, path, err := j.load(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
@@ -23,7 +23,8 @@ import (
|
||||
// UploadClient performs the Agent-direct data-plane upload using a restricted
|
||||
// Dispatcher grant. It never sends file bytes through Dispatcher or writes an
|
||||
// OSS credential to logs. It never deletes or moves the source asset; the
|
||||
// lifecycle owner retains it until upload-fact delivery to MQ is durably recorded.
|
||||
// lifecycle owner retains recovery files until the Dispatcher accepts the final
|
||||
// result and the Agent durably records that delivery; it never deletes OSS data.
|
||||
type UploadClient struct {
|
||||
HTTPClient *http.Client
|
||||
AllowedHosts map[string]struct{}
|
||||
|
||||
Reference in New Issue
Block a user