Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ec908ca
fix(queue): keep buffered events through network outages
posthog[bot] Aug 31, 2026
9ad59af
docs(queue): note maxRetryWindowSeconds is process-scoped
posthog[bot] Aug 31, 2026
9ee7767
fix(queue): scope maxRetries to HTTP 413, not transport/5xx failures
posthog[bot] Aug 31, 2026
d6a6cb6
fix(queue): reset the failure window on transport failures
posthog[bot] Aug 31, 2026
0f5ebc4
chore(api): add maxRetryWindowSeconds to public API snapshot
posthog[bot] Aug 31, 2026
9840581
docs(config): note maxRetries still bounds push registration retries
posthog[bot] Aug 31, 2026
adf3655
fix(queue): don't pop the drop diagnostic after a full clear
posthog[bot] Aug 31, 2026
0df92b2
fix(queue): preserve durable records across retries
dustinbyrne Aug 31, 2026
3233d8b
test(queue): verify FIFO trimming on reload
dustinbyrne Aug 31, 2026
2e86090
fix(queue): clear reachability pause when the notifier fails to start
posthog[bot] Sep 4, 2026
ebfbf47
fix(queue): enumerate the queue directory under itemsLock on reload
posthog[bot] Sep 4, 2026
3db5530
fix(api): keep Retry-After when a response arrives with an error
posthog[bot] Sep 4, 2026
1332b7a
fix(queue): preserve existing records when enqueue fails
dustinbyrne Sep 7, 2026
3506456
chore(release): classify durable queue changes as minor
dustinbyrne Sep 7, 2026
06e5368
fix(push): classify HTTP 408 as retryable for push subscriptions
posthog[bot] Sep 9, 2026
ef27c0c
fix(queue): sort durable records in a total order before trimming
posthog[bot] Sep 10, 2026
20259ef
fix(api): do not report a failed redirect as a delivery status
posthog[bot] Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/durable-queue-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"posthog-ios": minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] Re-scoping maxRetries is 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 a minor bump. Is minor the call because there's no API break, or should this carry the prefix and go major?

---

- 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 3xx sentence is also a follow-up to the bullet above it, so it can fold in. Missing entirely: queues now pause while the device reports no network and flush as soon as connectivity returns, which is the most user-visible change here.

Suggested change
- 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.
- Keep events, replay snapshots, and logs on disk when an upload fails with a retryable error, instead of clearing the queue.
- Limit `maxRetries` to push-subscription registration; it no longer bounds event, replay, or log upload attempts.
- Fix records captured while an upload is in flight being deleted when that upload succeeds.
- Fix a failed write to a full queue evicting an existing record.
- Apply the HTTP status and `Retry-After` from a response that arrives alongside a transport error, instead of always retrying.
- Pause uploads while the device reports no network, and flush as soon as connectivity returns.
- Trim stored queues to `maxQueueSize` (`logs.maxBufferSize` for logs), oldest first, when loading them from disk.
- Retry push-subscription requests that return HTTP 408 instead of discarding them.

18 changes: 14 additions & 4 deletions PostHog/PostHogApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,30 @@ import Foundation
/// Common URLSession upload-response handler shared by `/batch`, `/snapshot`,
/// and `/i/v1/logs`. Routes through `as?` so a missing HTTP response can't
/// crash inside a customer process.
private func processUploadResponse(
func processUploadResponse(
endpointName: String,
data: Data?,
response: URLResponse?,
error: Error?,
completion: @escaping (PostHogUploadInfo) -> Void
) {
let httpResponse = response as? HTTPURLResponse
// Parsed before the error branch: URLSession can deliver headers and then fail the
// transfer, and a rate-limited response still carries the delay the server asked for.
let retryAfter = httpResponse.flatMap { $0.value(forHTTPHeaderField: "Retry-After") }.flatMap(parseRetryAfter)

if let error {
hedgeLog("Error calling the \(endpointName) API: \(error).")
return completion(PostHogUploadInfo(statusCode: nil, error: error))
// A 3xx left on a failed task is the redirect URLSession was still following, not an
// outcome for the payload, so it's reported as no status. Honoring it would let the
// policies that treat 3xx as terminal (logs, push unregister) delete durable records
// that never reached the final host.
let status = httpResponse?.statusCode
let delivered = status.flatMap { 300 ... 399 ~= $0 ? nil : $0 }
return completion(PostHogUploadInfo(statusCode: delivered, error: error, retryAfter: retryAfter))
}

guard let httpResponse = response as? HTTPURLResponse else {
guard let httpResponse else {
hedgeLog("\(endpointName) API returned no HTTP response")
return completion(PostHogUploadInfo(statusCode: nil, error: nil))
}
Expand All @@ -34,7 +45,6 @@ private func processUploadResponse(
hedgeLog("\(endpointName) sent successfully.")
}

let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After").flatMap(parseRetryAfter)
completion(PostHogUploadInfo(statusCode: httpResponse.statusCode, error: nil, retryAfter: retryAfter))
}

Expand Down
7 changes: 3 additions & 4 deletions PostHog/PostHogConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] After this the queue never reads maxRetries β€” the only caller left is PostHogPushSubscriptionHandler. Anyone who lowered it to bound retry traffic against a broken backend now gets unbounded retries, and nothing fails at build time. Is patch the intent here, or should this be a minor with the semantics change spelled out?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your reading of the code is right β€” maxRetries now has exactly one production reader left, the push-subscription retry ceiling, and no queue reads it any more. Both halves of the question you asked have since been answered in the branch, after you wrote this:

  • patch was not the intent. Commit 3774ddf changed the bump from patch to minor; that one-line change is the whole commit.
  • The semantics change is now spelled out. Commit e4284e5 expanded the changeset from a single sentence into a bulleted list, and one bullet is exactly this: "Limit maxRetries to push-subscription registration; it no longer bounds event, replay, or log queue flush attempts."

At the commit you were looking at the entry really was a bare patch with no mention of maxRetries at all, so the comment was accurate when written. The property's own doc comment now states the narrowed scope too, so someone reading the declaration in an editor sees it, not just someone reading release notes.

The other half of what you raised β€” that anyone who lowered maxRetries to bound retry traffic against a broken backend now gets unbounded retries β€” is real and is not closed by relabelling the release. That is the same decision as your comment on the queue's retry path, and I've escalated it there rather than answering it twice: it needs a human call on whether to add a ceiling that ends the active flush sequence without clearing the queue, and what bound that ceiling reads.

I deliberately didn't reach for the two obvious-looking mitigations here. Marking maxRetries deprecated would be wrong β€” it is still live and load-bearing for push registration, so a deprecation warning would tell users to stop using a working option, and it would churn the public API snapshot. And there's no honest way to make a silently re-scoped option fail at build time short of a real public API break (rename or split into two options), which is a maintainer decision about backwards compatibility, not something I should land unattended. If you do want the option split β€” say maxRetries for push and a separate bound for queue flushes β€” that's worth deciding alongside the ceiling question on the other thread, since the two answers constrain each other.

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.
Expand Down
102 changes: 85 additions & 17 deletions PostHog/PostHogFileBackedQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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) {
Expand All @@ -70,13 +79,46 @@ class PostHogFileBackedQueue {
deleteFiles(count)
}

func add(_ contents: Data) {
func remove(ids: [String]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This takes the last production caller of pop(_:), and delete(index:) lost its own when PostHogQueue.add stopped calling it β€” both are now reachable only from PostHogFileBackedQueueAlignmentTest. Worth deleting them, or a line on why they're kept?

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The write and the deleteSafely just below now both run inside itemsLock; before, neither did. With a full queue during an outage every capture pays a write plus an unlink under the lock, on the caller's thread. remove(ids:) and reindexFromDisk use the collect-under-lock, IO-after shape β€” we could write the file before taking the lock and unlink the evicted id after releasing it, and still keep the capacity check atomic.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 nil β€” which the caller reads as "nothing was evicted" and skips the "Queue is full" log. Evicting only after a successful write would keep the two in step.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 nil return honestly means "nothing was evicted" rather than hiding a lost record. The caller's "Queue is full" log is back in step too, since a non-nil return is now only reachable after a successful write.

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:

  • On the success path the new file is written before the capacity check, so the directory briefly holds one more file than the configured maximum. The in-memory index never does, because the eviction runs before the new name is appended β€” and because the new name isn't in the index yet, a record can't evict itself. Nothing after the write in that block can throw, so the reorder can't orphan the file it just wrote.
  • The regression test is skipped when the test suite runs as root, since chmod doesn't restrict root and the write would succeed. That's fine for the macOS CI runners, which run as a normal user, but worth remembering if anyone runs the suite in a root container and wonders why the case looks uncovered.

Nothing committed for this thread.

}
}

Expand All @@ -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))
Comment thread
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 }
Expand All @@ -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)")
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading