Skip to content

Commit a37f691

Browse files
ypopovychclaude
andcommitted
Bound flush() retries so a persistently-retriable upload can't hang teardown
The synchronous `flush()` on the shutdown path retried a `.retry` upload result in an unbounded loop. A persistently retriable server (503/500/ 408/429) or a downed network — both surface as `needsRetry` — would loop forever, hanging process teardown. Cap flush retries at 3 (1 initial attempt + 3 retries). On giving up, report failure and leave the undelivered batch on disk for a later run. The background worker's indefinite cross-tick retry behaviour is unchanged; only the bounded shutdown flush is affected. Adds a regression test that a persistently-retriable upload returns failure instead of hanging, attempts exactly 4 uploads, and leaves the batch on disk. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 3dd5144 commit a37f691

2 files changed

Lines changed: 56 additions & 0 deletions

File tree

Sources/EventsExporter/Upload/DataUploadWorker.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ internal final class DataUploadWorker: DataUploadWorkerType {
9191
queue.sync { fileReader.update(dataFormat: dataFormat) }
9292
}
9393

94+
/// Maximum number of retries per batch during a synchronous `flush()`.
95+
/// Unlike the background worker (which retries indefinitely across ticks),
96+
/// `flush()` runs on the shutdown path and must be bounded: a persistently
97+
/// retriable server (503/500/408/429) or a downed network — both map to
98+
/// `needsRetry` — would otherwise loop forever, hanging process teardown.
99+
private static let maxFlushRetries = 3
100+
94101
/// Drains all pending batches synchronously. Holds the upload queue for
95102
/// the duration so the periodic worker can't interleave reads with the flush.
96103
func flush() throws -> Bool {
@@ -103,6 +110,7 @@ internal final class DataUploadWorker: DataUploadWorkerType {
103110
break
104111
}
105112
var status: UploadResult
113+
var retries = 0
106114
repeat {
107115
status = upload(data: batch.data)
108116
switch status {
@@ -112,6 +120,15 @@ internal final class DataUploadWorker: DataUploadWorkerType {
112120
case .failed:
113121
result = false
114122
case .retry:
123+
retries += 1
124+
guard retries <= Self.maxFlushRetries else {
125+
// Give up rather than hang teardown; leave the batch
126+
// on disk for a later run to pick up.
127+
log.print("[\(featureName)] flush giving up after \(Self.maxFlushRetries) retries")
128+
result = false
129+
status = .failed
130+
break
131+
}
115132
Thread.sleep(forTimeInterval: delay.current)
116133
}
117134
} while status.isRetry

Tests/EventsExporter/Upload/DataUploadWorkerTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,45 @@ class DataUploadWorkerTests: XCTestCase {
304304

305305
worker.stop()
306306
}
307+
308+
func testFlushGivesUpAfterMaxRetriesInsteadOfHanging() throws {
309+
// A server that always asks to retry (e.g. persistent 503) used to make
310+
// `flush()` loop forever, hanging the synchronous shutdown flush.
311+
let uploadCount = LockedInt()
312+
var mockDataUploader = DataUploaderMock(uploadStatus: .mockWith(needsRetry: true))
313+
mockDataUploader.onUpload = { uploadCount.increment() }
314+
315+
writer.write(value: ["k1": "v1"])
316+
writer.queue.sync {}
317+
318+
let worker = DataUploadWorker(
319+
fileReader: reader,
320+
dataUploader: mockDataUploader,
321+
delay: MockDelay(),
322+
featureName: .mockAny(),
323+
priority: .userInteractive,
324+
log: Log()
325+
)
326+
327+
// When: this must return (not hang) and report failure.
328+
let flushed = try worker.flush()
329+
330+
// Then
331+
XCTAssertFalse(flushed, "A persistently-retriable upload should end in failure, not success")
332+
XCTAssertEqual(uploadCount.value, 4, "1 initial attempt + 3 retries, then give up")
333+
XCTAssertEqual(try temporaryDirectory.files().count, 1, "Undelivered batch is left on disk for a later run")
334+
335+
worker.stop()
336+
}
337+
}
338+
339+
/// Thread-safe integer counter for asserting on callbacks made from the worker queue.
340+
private final class LockedInt: @unchecked Sendable {
341+
private let lock = NSLock()
342+
private var _value = 0
343+
344+
var value: Int { lock.withLock { _value } }
345+
func increment() { lock.withLock { _value += 1 } }
307346
}
308347

309348
struct MockDelay: Delay {

0 commit comments

Comments
 (0)