-
Notifications
You must be signed in to change notification settings - Fork 99
fix(queue): preserve durable records across retries #788
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ec908ca
9ad59af
9ee7767
d6a6cb6
0f5ebc4
9840581
adf3655
0df92b2
3233d8b
2e86090
ebfbf47
3db5530
1332b7a
3506456
06e5368
ef27c0c
20259ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,11 @@ | ||||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||||
| "posthog-ios": minor | ||||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| - Preserve bounded durable event, replay, and log queues across retryable upload failures instead of clearing them. | ||||||||||||||||||||||||||||||||
| - Limit `maxRetries` to push-subscription registration; it no longer bounds event, replay, or log queue flush attempts. | ||||||||||||||||||||||||||||||||
| - Acknowledge successful and terminal uploads by their exact persisted entry identities so full-queue replacements accepted during an upload are not deleted. | ||||||||||||||||||||||||||||||||
| - Trim persisted queues to `maxQueueSize` (or `logs.maxBufferSize` for logs) in FIFO order when loading them from disk. | ||||||||||||||||||||||||||||||||
| - Honor the received HTTP status and `Retry-After` when an upload also returns a transport error: successful and terminal responses remove the sent entries, while retryable responses retain them. A `3xx` left on a failed request is reported as no status, because a redirect does not confirm delivery to the final host. | ||||||||||||||||||||||||||||||||
| - Preserve existing queued records when writing a new record to a full queue fails. | ||||||||||||||||||||||||||||||||
| - Retry push-subscription requests that answer with HTTP 408 instead of treating the timeout as terminal, so a pending logout unregister survives until it succeeds. | ||||||||||||||||||||||||||||||||
|
Comment on lines
+5
to
+11
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] A few of these carry their rationale inline ("because a redirect does not confirm delivery", "so a pending logout unregister survives"), which belongs in the PR body, and a couple lean on internal vocabulary ("bounded durable", "persisted entry identities") rather than what a user observes. The
Suggested change
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -89,10 +89,9 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? | |
| /// Default: `30`. | ||
| @objc public var flushIntervalSeconds: TimeInterval = Defaults.flushIntervalSeconds | ||
|
|
||
| /// Maximum number of consecutive flush attempts before the entire queue is | ||
| /// dropped to avoid infinite retries against a permanently-broken backend. | ||
| /// Increments on every retriable failure including HTTP 413 cap halving; | ||
| /// resets on a successful 2xx response. Default 3. | ||
| /// Maximum number of retries for push-subscription registration failures. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [question] After this the queue never reads
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your reading of the code is right β
At the commit you were looking at the entry really was a bare The other half of what you raised β that anyone who lowered I deliberately didn't reach for the two obvious-looking mitigations here. Marking Nothing committed for this thread. |
||
| /// Durable event, replay, and log queues retain retryable records and use | ||
| /// their configured capacity as the storage bound. Default: `3`. | ||
| @objc public var maxRetries: Int = Defaults.maxRetries | ||
|
|
||
| /// Maximum number of retries for feature flag requests after transient network errors or retryable HTTP responses. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,16 +8,23 @@ | |
| import Foundation | ||
|
|
||
| class PostHogFileBackedQueue { | ||
| struct Entry { | ||
| let id: String | ||
| let data: Data | ||
| } | ||
|
|
||
| let queue: URL | ||
| private let maxSize: Int? | ||
| private var items = [String]() | ||
| private let itemsLock = NSLock() | ||
|
|
||
| var depth: Int { | ||
| itemsLock.withLock { items.count } | ||
| } | ||
|
|
||
| init(queue: URL, oldQueues: [URL] = []) { | ||
| init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil) { | ||
| self.queue = queue | ||
| self.maxSize = maxSize.map { max(1, $0) } | ||
| setup(oldQueues: oldQueues) | ||
| } | ||
|
|
||
|
|
@@ -42,17 +49,19 @@ class PostHogFileBackedQueue { | |
| } | ||
|
|
||
| do { | ||
| // when copying over buffered snapshots, content modification date will change, so we work off creation date instead. | ||
| let sortedItems = try FileManager.default.contentsOfDirectory(at: queue, sortedBy: .creationDateKey) | ||
| itemsLock.withLock { items = sortedItems } | ||
| try reindexFromDisk() | ||
| } catch { | ||
| hedgeLog("Failed to load files for queue \(error)") | ||
| // failed to read directory β bad permissions, perhaps? | ||
| } | ||
| } | ||
|
|
||
| func peek(_ count: Int) -> [Data] { | ||
| loadFiles(count) | ||
| peekEntries(count).map(\.data) | ||
| } | ||
|
|
||
| func peekEntries(_ count: Int) -> [Entry] { | ||
| loadEntries(count) | ||
| } | ||
|
|
||
| func delete(index: Int) { | ||
|
|
@@ -70,13 +79,46 @@ class PostHogFileBackedQueue { | |
| deleteFiles(count) | ||
| } | ||
|
|
||
| func add(_ contents: Data) { | ||
| func remove(ids: [String]) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] This takes the last production caller of |
||
| let ids = Set(ids) | ||
| let removed: [String] = itemsLock.withLock { | ||
| let removed = items.filter { ids.contains($0) } | ||
| items.removeAll { ids.contains($0) } | ||
| return removed | ||
| } | ||
|
|
||
| for item in removed { | ||
| deleteSafely(queue.appendingPathComponent(item)) | ||
| } | ||
| } | ||
|
|
||
| /// Persists one entry and optionally enforces a FIFO capacity in the same | ||
| /// critical section. Returning an evicted id lets the queue report | ||
| /// backpressure without racing a separate depth check against other adds. | ||
| @discardableResult | ||
| func add(_ contents: Data, maxSize: Int? = nil) -> String? { | ||
| do { | ||
| let filename = UUID.v7String() | ||
| try contents.write(to: queue.appendingPathComponent(filename)) | ||
| itemsLock.withLock { items.append(filename) } | ||
| let effectiveMaxSize = maxSize.map { max(1, $0) } ?? self.maxSize | ||
| var evicted: String? | ||
|
|
||
| try itemsLock.withLock { | ||
| try contents.write(to: queue.appendingPathComponent(filename)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] The write and the |
||
|
|
||
| if let effectiveMaxSize, items.count >= effectiveMaxSize { | ||
| evicted = items.removeFirst() | ||
| if let evicted { | ||
| deleteSafely(queue.appendingPathComponent(evicted)) | ||
| } | ||
| } | ||
|
|
||
| items.append(filename) | ||
| } | ||
|
|
||
| return evicted | ||
| } catch { | ||
| hedgeLog("Could not write file \(error)") | ||
| return nil | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] If the write throws we've already evicted and deleted the oldest entry, so we lose a record and return
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed already β commit e4284e5 does exactly what you suggested. The write was moved ahead of the capacity check, so the eviction only happens once the new record is safely on disk. It's a pure reorder of three lines inside the same lock. So the failure path now behaves the way you wanted: if the write throws, nothing has been evicted and nothing deleted, the existing records are intact, and the The same commit added a regression test for it. It fills a queue with capacity 2, makes the directory read-only so the write really does throw, and then asserts the add returns nil, the depth is still 2, the two original entries are unchanged and still readable, and the directory contains exactly the original two files β so a half-written file can't be left behind either. It then restores write permission and asserts a successful add does return the evicted id, which pins both directions of the nil contract, not just the failure case. Two details worth knowing, since neither is obvious from the diff:
Nothing committed for this thread. |
||
| } | ||
| } | ||
|
|
||
|
|
@@ -90,15 +132,35 @@ class PostHogFileBackedQueue { | |
| /// Use after externally adding files to the queue directory. | ||
| func reloadFromDisk() { | ||
| do { | ||
| let sortedItems = try FileManager.default.contentsOfDirectory(at: queue, sortedBy: .creationDateKey) | ||
| itemsLock.withLock { items = sortedItems } | ||
| try reindexFromDisk() | ||
| } catch { | ||
| hedgeLog("Failed to reload files for queue \(error)") | ||
| } | ||
| } | ||
|
|
||
| private func loadFiles(_ count: Int) -> [Data] { | ||
| var results = [Data]() | ||
| /// Re-reads the queue directory and replaces the in-memory index with it, | ||
| /// enforcing the FIFO capacity. The enumeration runs inside `itemsLock` so a | ||
| /// filename appended by a concurrent `add` can't be dropped from the index by | ||
| /// the replacement while its file stays on disk. | ||
| private func reindexFromDisk() throws { | ||
| let dropped: [String] = try itemsLock.withLock { | ||
| // when copying over buffered snapshots, content modification date will change, so we work off creation date instead. | ||
| let sortedItems = try FileManager.default.contentsOfDirectory(at: queue, sortedBy: .creationDateKey) | ||
| let overflow = maxSize.map { max(0, sortedItems.count - $0) } ?? 0 | ||
| items = Array(sortedItems.dropFirst(overflow)) | ||
| return Array(sortedItems.prefix(overflow)) | ||
|
posthog[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| for item in dropped { | ||
| deleteSafely(queue.appendingPathComponent(item)) | ||
| } | ||
| if !dropped.isEmpty { | ||
| hedgeLog("Dropped \(dropped.count) oldest cached records to enforce queue capacity") | ||
| } | ||
| } | ||
|
|
||
| private func loadEntries(_ count: Int) -> [Entry] { | ||
| var results = [Entry]() | ||
| var skipped = Set<String>() | ||
|
|
||
| let itemsCopy = itemsLock.withLock { items } | ||
|
|
@@ -113,7 +175,7 @@ class PostHogFileBackedQueue { | |
| } | ||
| let contents = try Data(contentsOf: itemURL) | ||
|
|
||
| results.append(contents) | ||
| results.append(Entry(id: item, data: contents)) | ||
| } catch { | ||
| if isTemporarilyUnavailable(error) { | ||
| hedgeLog("File \(itemURL) is temporarily unavailable, will retry \(error)") | ||
|
|
@@ -191,12 +253,18 @@ private func migrateOldQueueFolder(queue: URL, oldQueueFolder: URL) { | |
| } | ||
|
|
||
| private extension FileManager { | ||
| /// Returns filenames sorted by resource key | ||
| /// Returns filenames in a total order: by resource key, then by filename. | ||
| /// `reindexFromDisk` deletes the head of this order to enforce capacity, so a file | ||
| /// whose date can't be read must not sort oldest, and equal dates need a tie-breaker | ||
| /// because `sorted` isn't stable. UUID v7 names sort by their embedded timestamp. | ||
| func contentsOfDirectory(at url: URL, sortedBy key: URLResourceKey) throws -> [String] { | ||
| let urls = try contentsOfDirectory(at: url, includingPropertiesForKeys: [key]) | ||
| return urls.sorted { | ||
| let date1 = (try? $0.resourceValues(forKeys: [key]).allValues[key] as? Date) ?? .distantPast | ||
| let date2 = (try? $1.resourceValues(forKeys: [key]).allValues[key] as? Date) ?? .distantPast | ||
| return urls.sorted { lhs, rhs in | ||
| let date1 = (try? lhs.resourceValues(forKeys: [key]).allValues[key] as? Date) ?? .distantFuture | ||
| let date2 = (try? rhs.resourceValues(forKeys: [key]).allValues[key] as? Date) ?? .distantFuture | ||
| if date1 == date2 { | ||
| return lhs.lastPathComponent < rhs.lastPathComponent | ||
| } | ||
| return date1 < date2 | ||
| }.map(\.lastPathComponent) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[question] Re-scoping
maxRetriesis a silent behavior break β anyone who lowered it to bound retry traffic gets a different SDK with nothing failing at build time. Our changelog style puts a**Breaking:**prefix on those and sorts them first, but that reads oddly under aminorbump. Isminorthe call because there's no API break, or should this carry the prefix and gomajor?