forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathWALCloudSyncLogic.swift
More file actions
140 lines (131 loc) · 5.31 KB
/
Copy pathWALCloudSyncLogic.swift
File metadata and controls
140 lines (131 loc) · 5.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
import Foundation
import OmiWAL
/// Pure state transitions for WAL cloud upload — testable without network I/O.
enum WALCloudSyncLogic {
/// Apply a server upload acknowledgement. Never marks `.synced` without 200/202 ack.
static func applyUploadResult(
to wal: inout WALEntry,
result: UploadLocalFilesResult,
now: Int = Int(Date().timeIntervalSince1970)
) {
switch result {
case .done:
wal.status = .synced
wal.jobId = nil
wal.uploadedAt = 0
case .queued(let jobId):
wal.status = .uploaded
wal.jobId = jobId
wal.uploadedAt = now
}
}
/// Re-apply reconciled WAL transitions onto the current live array by id.
///
/// `reconcileUploadedWals` runs per-job network `await`s on the main actor, so it
/// operates on a value-type SNAPSHOT of `wals` taken before those suspensions. During
/// the awaits, other main-actor work (the chunk timer's `createWalFromCurrentFrames`,
/// SD/WiFi `createSdCardWal`, write-completion mutations) can append new WALs to the
/// live array. Assigning the snapshot back wholesale (`wals = workingWals`) silently
/// drops those appended WALs — permanent data loss for an in-progress recording.
///
/// This merges only the fields reconcile owns (`status`, `jobId`, `uploadedAt`),
/// matched by id, and only when reconcile actually changed them. Entries present in
/// `live` but absent from `snapshot`/`reconciled` (appended during the awaits) are
/// preserved, and concurrent updates to other fields of untouched entries are not
/// clobbered.
static func mergeReconciledUploads(
live: [WALEntry],
snapshot: [WALEntry],
reconciled: [WALEntry]
) -> [WALEntry] {
var snapshotById: [String: WALEntry] = [:]
for wal in snapshot { snapshotById[wal.id] = wal }
var reconciledById: [String: WALEntry] = [:]
for wal in reconciled { reconciledById[wal.id] = wal }
var result = live
for index in result.indices {
let id = result[index].id
guard let updated = reconciledById[id], let original = snapshotById[id] else { continue }
guard
updated.status != original.status
|| updated.jobId != original.jobId
|| updated.uploadedAt != original.uploadedAt
else { continue }
result[index].status = updated.status
result[index].jobId = updated.jobId
result[index].uploadedAt = updated.uploadedAt
}
return result
}
/// Reconcile one job's WAL members after a status fetch. Returns whether any WAL changed.
@discardableResult
static func applyReconcileFetch(
wals: inout [WALEntry],
memberWalIds: [String],
fetch: SyncJobFetch,
fileExists: (WALEntry) -> Bool,
now: Int = Int(Date().timeIntervalSince1970)
) -> Bool {
guard !memberWalIds.isEmpty else { return false }
switch fetch.outcome {
case .transient:
return false
case .notFound, .forbidden, .unauthorized:
// `.unauthorized` = the status poll could not be authenticated (token mint
// failed, or a stale 401). Revert to `.miss` like the other durable
// failures so the authenticated upload path re-uploads and refreshes auth;
// the poll itself must not invalidate the session (INV-AUTH-1).
var changed = false
for walId in memberWalIds {
guard let index = wals.firstIndex(where: { $0.id == walId }) else { continue }
changed = true
wals[index].jobId = nil
if fileExists(wals[index]) {
wals[index].status = .miss
} else {
wals[index].status = .corrupted
}
wals[index].uploadedAt = 0
_ = now
}
return changed
case .ok:
guard let status = fetch.status else { return false }
if !status.isTerminal {
return false
}
if status.status == "completed" {
var changed = false
for walId in memberWalIds {
guard let index = wals.firstIndex(where: { $0.id == walId }) else { continue }
changed = true
wals[index].status = .synced
wals[index].jobId = nil
wals[index].uploadedAt = 0
}
return changed
}
// failed / partial_failure — revert to miss for re-upload when file remains.
// We requeue both cases: for partial_failure, the failed segments may be
// retryable on the next upload (transient backend failures). Re-uploading
// successful segments is safe — the backend dedupes by conversation/timestamp.
var changed = false
for walId in memberWalIds {
guard let index = wals.firstIndex(where: { $0.id == walId }) else { continue }
changed = true
wals[index].jobId = nil
wals[index].uploadedAt = 0
wals[index].status = fileExists(wals[index]) ? .miss : .corrupted
}
return changed
}
}
/// Synced WALs whose recording start predates the retention cutoff — safe to
/// delete. Gated on `.synced` (a confirmed backend ack; never `.miss`/
/// `.uploaded`/`.corrupted`, which still need upload) AND older than the
/// cutoff, so local audio is only reclaimed after the cloud has it and a
/// retention buffer has elapsed. Pure so the delete/keep boundary is testable.
static func cleanupCandidates(wals: [WALEntry], cutoffTimestamp: Int) -> [WALEntry] {
wals.filter { $0.status == .synced && $0.timerStart < cutoffTimestamp }
}
}