Skip to content

Commit 8989162

Browse files
authored
Merge pull request #195 from straff2002/claude/offline-queue-durability
Offline-queue durability: restart recovery, launch flush, bounded drain (BM P1)
2 parents a8f480d + ef196c2 commit 8989162

4 files changed

Lines changed: 170 additions & 23 deletions

File tree

OpenGlasses/Sources/App/OpenGlassesApp.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,17 @@ class AppState: ObservableObject, AppStateProtocol {
792792
syncEngine.onConflict = { [weak self] _, reason in
793793
Task { @MainActor in await self?.speechService.speak("Heads up — \(reason).") }
794794
}
795+
// Launch-time flush (Plan BM P1): a relaunch that comes up already-online produces no
796+
// reachability edge, so bind() alone would never sync work captured before the last quit.
797+
// OfflineQueue.init already re-armed any inFlight strands. Reclaim space either way.
798+
Task { @MainActor [weak self] in
799+
guard let self else { return }
800+
if self.reachability.isOnline, self.offlineQueue.pendingCount > 0 {
801+
await self.syncEngine.flush() // drains (in pages) and maintains
802+
} else {
803+
self.syncEngine.runMaintenance()
804+
}
805+
}
795806

796807
// Register live translation tool with its service reference
797808
var translationTool = LiveTranslationTool()

OpenGlasses/Sources/Services/Offline/OfflineQueue.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ final class OfflineQueue {
3232
)
3333
""")
3434
exec("CREATE INDEX IF NOT EXISTS idx_ops_state ON ops(state)")
35+
recoverInFlight()
36+
}
37+
38+
// MARK: - Startup recovery
39+
40+
/// Re-arm ops stranded `inFlight` by a process killed mid-delivery. Only one process ever owns
41+
/// this file, so any `inFlight` row seen at open time is a strand — `pending()` would never
42+
/// surface it again, silently losing the work. Runs once on open; returns rows recovered.
43+
@discardableResult
44+
func recoverInFlight() -> Int {
45+
exec("UPDATE ops SET state = 'pending' WHERE state = 'inFlight'")
46+
return Int(sqlite3_changes(db))
3547
}
3648

3749
deinit {
@@ -78,9 +90,11 @@ final class OfflineQueue {
7890
_ = sqlite3_step(stmt)
7991
}
8092

81-
/// Delete delivered (tombstone) ops to reclaim space.
93+
/// Delete delivered (tombstone) ops to reclaim DB space. Excludes `photoUpload` — those keep
94+
/// their delivered tombstone so `prunePhotoEvidence` can bound the on-disk photo cache and
95+
/// evict the actual files; dropping their rows here would orphan the images on disk.
8296
func purgeDone() {
83-
exec("DELETE FROM ops WHERE state = 'done'")
97+
exec("DELETE FROM ops WHERE state = 'done' AND kind != 'photoUpload'")
8498
}
8599

86100
/// Delete a single op by id.

OpenGlasses/Sources/Services/Offline/SyncEngine.swift

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ final class SyncEngine: ObservableObject {
4747
var onConflict: ((QueuedOp, String) -> Void)?
4848
/// Max delivery attempts before an op is marked `failed`.
4949
var maxAttempts = 6
50+
/// Ops fetched per drain page. A backlog larger than this is drained across loop iterations.
51+
var batchSize = 500
52+
/// Disk cap for delivered photo evidence; pruned after every flush and on launch so the queue
53+
/// can't grow without bound. `nil` disables photo pruning.
54+
var photoEvidenceCapBytes: Int? = 256 * 1024 * 1024 // 256 MB
5055

5156
private var reachabilityChange: ((Bool) -> Void)?
5257

@@ -65,7 +70,9 @@ final class SyncEngine: ObservableObject {
6570
}
6671

6772
/// Deliver all pending ops in FIFO order. Returns the number delivered this pass. Re-entrant
68-
/// calls are coalesced (a flush already running wins).
73+
/// calls are coalesced (a flush already running wins). Loops in `batchSize` pages until the
74+
/// whole backlog is drained — a >`batchSize` queue no longer needs multiple reconnects — while
75+
/// tracking handled ids so a transient op re-queued this flush isn't reprocessed in a spin.
6976
@discardableResult
7077
func flush() async -> Int {
7178
guard !isFlushing else { return 0 }
@@ -74,32 +81,50 @@ final class SyncEngine: ObservableObject {
7481

7582
var delivered = 0
7683
var conflicts = 0
77-
for op in queue.pending() {
78-
queue.mark(op.id, state: .inFlight)
79-
switch await sink.deliver(op) {
80-
case .done:
81-
queue.mark(op.id, state: .done)
82-
delivered += 1
83-
case .conflict(let reason):
84-
queue.mark(op.id, state: .conflict)
85-
conflicts += 1
86-
onConflict?(op, reason)
87-
case .transient(let reason):
88-
let attempts = op.attempts + 1
89-
if attempts >= maxAttempts {
90-
queue.mark(op.id, state: .failed, attempts: attempts)
91-
NSLog("[SyncEngine] op %@ failed after %d attempts: %@", op.id, attempts, reason)
92-
} else {
93-
queue.mark(op.id, state: .pending, attempts: attempts) // retained for a later flush
84+
// Transient failures are re-armed to `pending` only *after* the drain completes. During the
85+
// loop they stay `inFlight` (out of the pending set) so `pending()` strictly shrinks each
86+
// page and the loop can't spin on a retained op — the whole backlog drains in one flush.
87+
var retry: [(id: String, attempts: Int)] = []
88+
while true {
89+
let batch = queue.pending(limit: batchSize)
90+
if batch.isEmpty { break }
91+
for op in batch {
92+
queue.mark(op.id, state: .inFlight)
93+
switch await sink.deliver(op) {
94+
case .done:
95+
queue.mark(op.id, state: .done)
96+
delivered += 1
97+
case .conflict(let reason):
98+
queue.mark(op.id, state: .conflict)
99+
conflicts += 1
100+
onConflict?(op, reason)
101+
case .transient(let reason):
102+
let attempts = op.attempts + 1
103+
if attempts >= maxAttempts {
104+
queue.mark(op.id, state: .failed, attempts: attempts)
105+
NSLog("[SyncEngine] op %@ failed after %d attempts: %@", op.id, attempts, reason)
106+
} else {
107+
retry.append((op.id, attempts)) // re-armed below, once the drain is done
108+
}
109+
case .permanent(let reason):
110+
queue.mark(op.id, state: .failed)
111+
NSLog("[SyncEngine] op %@ permanently failed: %@", op.id, reason)
94112
}
95-
case .permanent(let reason):
96-
queue.mark(op.id, state: .failed)
97-
NSLog("[SyncEngine] op %@ permanently failed: %@", op.id, reason)
98113
}
99114
}
115+
for r in retry { queue.mark(r.id, state: .pending, attempts: r.attempts) }
100116

101117
lastSyncedCount = delivered
102118
lastConflictCount = conflicts
119+
runMaintenance()
103120
return delivered
104121
}
122+
123+
/// Reclaim queue space: drop delivered non-photo tombstones and evict oldest delivered photo
124+
/// evidence past `photoEvidenceCapBytes`. Runs at the tail of every flush and can be called
125+
/// standalone on launch so space is reclaimed even when there's nothing to send.
126+
func runMaintenance() {
127+
if let cap = photoEvidenceCapBytes { queue.prunePhotoEvidence(maxBytes: cap) }
128+
queue.purgeDone()
129+
}
105130
}

OpenGlassesTests/OfflineQueueTests.swift

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,28 @@ import XCTest
88
final class OfflineQueueTests: XCTestCase {
99

1010
private var tempFiles: [URL] = []
11+
private var tempAux: [URL] = []
1112

1213
override func tearDown() {
1314
for url in tempFiles {
1415
for suffix in ["", "-wal", "-shm"] {
1516
try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + suffix))
1617
}
1718
}
19+
for url in tempAux { try? FileManager.default.removeItem(at: url) }
1820
tempFiles.removeAll()
21+
tempAux.removeAll()
1922
super.tearDown()
2023
}
2124

25+
/// Create a throwaway file of `bytes` and return its path (tracked for cleanup).
26+
private func makePhotoFile(bytes: Int) -> String {
27+
let url = FileManager.default.temporaryDirectory.appendingPathComponent("photo_\(UUID().uuidString).jpg")
28+
try? Data(repeating: 0xAB, count: bytes).write(to: url)
29+
tempAux.append(url)
30+
return url.path
31+
}
32+
2233
private func tempPath() -> URL {
2334
let url = FileManager.default.temporaryDirectory.appendingPathComponent("oq_\(UUID().uuidString).sqlite")
2435
tempFiles.append(url)
@@ -170,6 +181,92 @@ final class OfflineQueueTests: XCTestCase {
170181
XCTAssertEqual(q.pendingCount, 0)
171182
}
172183

184+
// MARK: - Plan BM P1: durability
185+
186+
func testInFlightStrandRecoveredOnReopen() {
187+
let path = tempPath()
188+
let id: String
189+
do {
190+
let q = OfflineQueue(path: path)
191+
let a = op("s", at: 100)
192+
id = a.id
193+
q.enqueue(a)
194+
q.mark(a.id, state: .inFlight) // killed mid-delivery
195+
XCTAssertTrue(q.pending().isEmpty) // inFlight is invisible to pending()
196+
}
197+
let reopened = OfflineQueue(path: path) // startup recovery re-arms it
198+
XCTAssertEqual(reopened.pendingCount, 1)
199+
XCTAssertEqual(reopened.pending().first?.id, id)
200+
}
201+
202+
func testRecoverInFlightReturnsCount() {
203+
let q = OfflineQueue(path: tempPath())
204+
let a = op("s", at: 100), b = op("s", at: 200)
205+
q.enqueue(a); q.enqueue(b)
206+
q.mark(a.id, state: .inFlight)
207+
q.mark(b.id, state: .inFlight)
208+
XCTAssertEqual(q.recoverInFlight(), 2)
209+
XCTAssertEqual(q.pendingCount, 2)
210+
XCTAssertEqual(q.recoverInFlight(), 0) // nothing left to recover
211+
}
212+
213+
func testFlushDrainsBacklogBeyondBatchSize() async {
214+
let q = OfflineQueue(path: tempPath())
215+
for i in 0..<5 { q.enqueue(op("s", at: TimeInterval(i))) }
216+
let engine = SyncEngine(queue: q, sink: LocalSyncSink())
217+
engine.batchSize = 2 // 5 ops, 2 per page → must loop
218+
let delivered = await engine.flush()
219+
XCTAssertEqual(delivered, 5)
220+
XCTAssertEqual(q.pendingCount, 0)
221+
}
222+
223+
func testAllTransientBacklogTerminatesAndRetainsOncePerOp() async {
224+
let q = OfflineQueue(path: tempPath())
225+
for i in 0..<4 { q.enqueue(op("s", at: TimeInterval(i))) }
226+
let sink = FakeSink(); sink.defaultOutcome = .transient(reason: "no signal")
227+
let engine = SyncEngine(queue: q, sink: sink)
228+
engine.batchSize = 1 // small page must not spin or skip ops
229+
_ = await engine.flush()
230+
XCTAssertEqual(q.pendingCount, 4) // all retained, none lost
231+
XCTAssertEqual(Set(q.pending().map(\.attempts)), [1]) // each attempted exactly once
232+
XCTAssertEqual(sink.deliveredIds.count, 4) // every op tried, no spin
233+
}
234+
235+
func testFlushMaintenancePurgesDeliveredNonPhotoTombstones() async {
236+
let q = OfflineQueue(path: tempPath())
237+
q.enqueue(op("s", at: 100)); q.enqueue(op("s", at: 200))
238+
let engine = SyncEngine(queue: q, sink: LocalSyncSink())
239+
_ = await engine.flush() // delivers → done → maintenance purges
240+
XCTAssertTrue(q.all().isEmpty, "delivered log tombstones are reclaimed post-flush")
241+
}
242+
243+
func testPurgeDoneKeepsPhotoTombstones() {
244+
let q = OfflineQueue(path: tempPath())
245+
let photo = QueuedOp.make(kind: .photoUpload, sessionId: "s", json: ["path": "/tmp/x.jpg"])
246+
let log = op("s", at: 100)
247+
q.enqueue(photo); q.enqueue(log)
248+
q.mark(photo.id, state: .done)
249+
q.mark(log.id, state: .done)
250+
q.purgeDone()
251+
// Photo tombstone survives (so prunePhotoEvidence can still manage its file); log is gone.
252+
XCTAssertEqual(q.all().map(\.id), [photo.id])
253+
}
254+
255+
func testFlushPrunesDeliveredPhotoEvidencePastCap() async {
256+
let q = OfflineQueue(path: tempPath())
257+
let paths = (0..<3).map { _ in makePhotoFile(bytes: 100) }
258+
for p in paths {
259+
q.enqueue(QueuedOp.make(kind: .photoUpload, sessionId: "s", json: ["path": p]))
260+
}
261+
let engine = SyncEngine(queue: q, sink: LocalSyncSink())
262+
engine.photoEvidenceCapBytes = 150 // 300 bytes on disk, cap 150 → evict 2 oldest
263+
_ = await engine.flush()
264+
265+
let onDisk = paths.filter { FileManager.default.fileExists(atPath: $0) }
266+
XCTAssertEqual(onDisk.count, 1, "only the newest delivered photo stays under the cap")
267+
XCTAssertEqual(q.all(limit: 500).filter { $0.kind == .photoUpload }.count, 1)
268+
}
269+
173270
// MARK: - ConflictResolver
174271

175272
func testConflictResolverDetectsServerAdvance() {

0 commit comments

Comments
 (0)