Isolate repeated send throws to their lane and document snapshot availability
4 files changed,
+57
−5
Sources/RastrilloApple/OutboxDrainer.swift+18 −3Sources/RastrilloNative/Outbox.swift+8 −2Tests/RastrilloAppleTests/OutboxDrainerTests.swift+22 −0docs/testing/k3-evidence.md+9 −0
CI failed — run details
diff --git a/Sources/RastrilloApple/OutboxDrainer.swift b/Sources/RastrilloApple/OutboxDrainer.swift| index d3fc1cf..9dde528 100644 |
| --- a/Sources/RastrilloApple/OutboxDrainer.swift |
| +++ b/Sources/RastrilloApple/OutboxDrainer.swift |
| @@ -13,7 +13,7 @@ import RastrilloNative |
| /// | `.unauthorized` | session ended: stop every lane; never hold or drop | |
| /// | `.rateLimited(after)` | back off at least `after` | |
| /// | `.server`, `.transport`, `.invalidResponse` | back off, retry the same batch | |
| -/// | `BlobStoreError.unavailable` | store locked: wait for the next change (foreground, unlock) | |
| +/// | `BlobStoreError.unavailable` | when thrown by send, retry this lane; hold after three consecutive throws | |
| /// | `.forbidden`, `.notFound`, `.conflict`, `.tooLarge`, `.rejected` | hold the batch rather than spin | |
| /// |
| /// A failure to acknowledge or hold after a send is a `.retry`: the server |
| @@ -46,7 +46,7 @@ import RastrilloNative |
| case waitingForNetwork |
| /// Retry after this delay (a connectivity change may cut it short). |
| case backingOff(TimeInterval) |
| - /// The server's Retry-After: nothing but "Try again" crosses it. |
| + /// The server’s Retry-After: neither connectivity nor "Try again" crosses it. |
| case rateLimited(TimeInterval) |
| case sessionEnded |
| case storeUnavailable |
| @@ -67,6 +67,7 @@ import RastrilloNative |
| private let maxBatchBytes: Int? |
| private var backoff: Backoff |
| @ObservationIgnored private var knownStatuses: [String: SyncStatus] = [:] |
| + private var consecutiveThrows: [String: Int] = [:] |
| private var draining = false |
| private var drainWaiters: [CheckedContinuation<Outcome, Never>] = [] |
| private var worker: Task<Void, Never>? |
| @@ -172,8 +173,12 @@ import RastrilloNative |
| phase = .sending |
| do { |
| let dispositions: [UUID: Disposition] |
| - do { dispositions = try await send(first.lane, batch, transport) } |
| + do { |
| + dispositions = try await send(first.lane, batch, transport) |
| + consecutiveThrows[first.lane] = nil |
| + } |
| catch let error as NativeClientError { |
| + consecutiveThrows[first.lane] = nil |
| let hold: Hold? |
| switch error { |
| case .forbidden, .notFound: hold = Hold(.accessLost) |
| @@ -185,6 +190,16 @@ import RastrilloNative |
| guard let hold else { throw error } |
| dispositions = Dictionary(uniqueKeysWithValues: ids.map { ($0, .hold(hold)) }) |
| } |
| + catch { |
| + guard !Task.isCancelled else { throw error } |
| + let count = (consecutiveThrows[first.lane] ?? 0) + 1 |
| + consecutiveThrows[first.lane] = count |
| + // An app decoder/programming failure belongs to this lane, not the |
| + // account. Three consecutive throws require an explicit resolution. |
| + let disposition: Disposition = count >= 3 ? .hold(Hold(.rejected("send"))) : .retry |
| + dispositions = Dictionary(uniqueKeysWithValues: ids.map { ($0, disposition) }) |
| + if count >= 3 { consecutiveThrows[first.lane] = nil } |
| + } |
| var mustRetry = false; var progressed = false |
| for entry in batch { |
| switch dispositions[entry.id] { |
diff --git a/Sources/RastrilloNative/Outbox.swift b/Sources/RastrilloNative/Outbox.swift| index 0ddc905..bf36eed 100644 |
| --- a/Sources/RastrilloNative/Outbox.swift |
| +++ b/Sources/RastrilloNative/Outbox.swift |
| @@ -142,10 +142,15 @@ public actor Outbox { |
| try persist(entry); values.append(entry); next += 1; return entry |
| } |
| } |
| - public func entries(lane: String) -> [OutboxEntry] { |
| + /// A last-known snapshot, including held entries. On an unavailable store |
| + /// this may be stale, just like summary (whose unavailable flag is set). |
| + /// Use it for display/deduplication hints only; replay must first call load() |
| + /// successfully. Every mutation and beginSend independently require load(). |
| + public func entries(lane: String, excludingInFlight: Bool = false) -> [OutboxEntry] { |
| try? load() |
| - return values.filter { $0.lane == lane } |
| + return values.filter { $0.lane == lane && (!excludingInFlight || !flying.contains($0.id)) } |
| } |
| + /// Fails closed to an empty batch when loading is unavailable. |
| public func sendable(lane: String, maxBytes: Int? = nil) -> [OutboxEntry] { |
| try? load() |
| guard !unavailable else { return [] } |
| @@ -241,6 +246,7 @@ public actor Outbox { |
| } |
| return result |
| } |
| + /// Last-known counts; unavailable marks retained counts as non-authoritative. |
| public var summary: OutboxSummary { try? load(); return currentSummary } |
| private func notify() { for subscriber in subscribers.values { subscriber.yield(currentSummary) } } |
| private func unsubscribe(_ id: UUID) { subscribers[id] = nil } |
diff --git a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift| index b27b4f9..cc1d7f4 100644 |
| --- a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift |
| +++ b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift |
| @@ -38,6 +38,28 @@ import XCTest |
| transport.committed.map { String(decoding: $0.body ?? Data(), as: UTF8.self) } |
| } |
| + func testThrowingLaneDoesNotStarveOtherLanesAndEventuallyHolds() async throws { |
| + struct Broken: Error {} |
| + let (connectivity, _) = await connectivity() |
| + let outbox = try await outbox([("a", "broken"), ("b", "good"), ("c", "good")]) |
| + let transport = ScriptedTransport([]) |
| + var calls: [String] = [] |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { lane, entries, _ in |
| + calls.append(lane) |
| + if lane == "a" { throw Broken() } |
| + return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, .sent) }) |
| + } |
| + await drainer.drain() |
| + XCTAssertEqual(calls, ["a", "b", "c"]) |
| + let remaining = await outbox.summary |
| + XCTAssertEqual(remaining.lanes.count, 1) |
| + await drainer.drain(); await drainer.drain() |
| + let held = await outbox.entries(lane: "a") |
| + XCTAssertEqual(held.first?.hold, Hold(.rejected("send"))) |
| + await drainer.drain() |
| + XCTAssertEqual(calls.filter { $0 == "a" }.count, 3, "Three consecutive throws hold the lane instead of spinning") |
| + } |
| + |
| func testConcurrentDrainWaitsForTheReservedBatch() async throws { |
| let (connectivity, _) = await connectivity() |
| let outbox = try await outbox([("doc:a", "1")]) |
diff --git a/docs/testing/k3-evidence.md b/docs/testing/k3-evidence.md| index 5de288c..a55964f 100644 |
| --- a/docs/testing/k3-evidence.md |
| +++ b/docs/testing/k3-evidence.md |
| @@ -80,3 +80,12 @@ No update or retarget API was added. |
| The only deliberate ordering difference is app-side discard removing the replica |
| before its lane; exhaustive crash tests prove this prevents stranded unsent text. |
| The prior empty-session status exception is removed. No merge or deployment. |
| + |
| +## Fable review follow-up (2026-09-24) |
| + |
| +- `testSealBlocksCacheStateAndSkipUnusedQuotaAfterCrash`: failed against the inherited per-seal implementation (38 high-water assertions and two cache assertions); passes with 1024-slot reservations. Two process lifetimes consume 19 seals each, observe durable marks 1024/2048, and produce 38 distinct 96-bit nonces. A restart abandons 1005 unused slots; key rotation still counts reseals and keeps its previous key until completion. Complete file protection remains in every atomic write. |
| +- `testThrowingLaneDoesNotStarveOtherLanesAndEventuallyHolds`: failed against the inherited drainer (only lane a called, three lanes remained, no hold, fourth throw); passes with b/c draining on the first attempt and a held after its third consecutive untyped throw. Returning a disposition or a typed transport error resets the throw streak. |
| +- Full Swift suite: 119 tests passed. Red/green logs: `/tmp/k3-native-review-{red,green}.log` (ephemeral); observed assertions above are the durable record. |
| +- Reservation API addition: `entries(lane:excludingInFlight:)` adds a filter to the existing snapshot, needed to deduplicate held metadata markers without suppressing a successor to a marker already being sent. No new mutation or reservation operation. |
| +- Snapshot invariant is now documented: entries/summary can retain stale display/deduplication hints on unavailable storage; summary flags this, sendable fails closed, and load/beginSend/all mutations enforce availability before replay or changes. |
| +- Corrected unavailable-send and Retry-After comments. The latter remains a deadline that neither connectivity nor Try again bypasses. |