Retain known sync status while protected storage is unavailable
3 files changed,
+57
−9
Sources/RastrilloApple/OutboxDrainer.swift+9 −2Tests/RastrilloAppleTests/OutboxDrainerTests.swift+21 −0docs/testing/k3-evidence.md+27 −7
CI failed — run details
Intent
diff --git a/Sources/RastrilloApple/OutboxDrainer.swift b/Sources/RastrilloApple/OutboxDrainer.swift| index aab2fc0..d3fc1cf 100644 |
| --- a/Sources/RastrilloApple/OutboxDrainer.swift |
| +++ b/Sources/RastrilloApple/OutboxDrainer.swift |
| @@ -66,6 +66,7 @@ import RastrilloNative |
| private let send: Send |
| private let maxBatchBytes: Int? |
| private var backoff: Backoff |
| + @ObservationIgnored private var knownStatuses: [String: SyncStatus] = [:] |
| private var draining = false |
| private var drainWaiters: [CheckedContinuation<Outcome, Never>] = [] |
| private var worker: Task<Void, Never>? |
| @@ -218,7 +219,13 @@ import RastrilloNative |
| count: (OutboxSummary) -> Int = { $0.pendingLanes }) -> SyncStatus { |
| let state = lane.map { summary.only($0) } ?? summary |
| let offline = transport().map { connectivity.isOffline($0.origin) } ?? !connectivity.isPathSatisfied |
| - return SyncStatus(summary: state, sending: state.inFlight > 0, sessionEnded: sessionEnded, |
| - offline: offline, backingOff: phase == .backingOff, localFailure: localFailure, count: count) |
| + let key = lane.map { "lane:" + $0 } ?? "account" |
| + if state.unavailable, localFailure == nil, let known = knownStatuses[key] { return known } |
| + let status = SyncStatus(summary: state, sending: state.inFlight > 0, sessionEnded: sessionEnded, |
| + offline: offline, backingOff: phase == .backingOff, localFailure: localFailure, count: count) |
| + // A locked store retains the last observable status. An app's explicit |
| + // enqueue failure still reports input that has not reached durable storage. |
| + if !state.unavailable, localFailure == nil { knownStatuses[key] = status } |
| + return status |
| } |
| } |
diff --git a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift| index 163de59..b27b4f9 100644 |
| --- a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift |
| +++ b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift |
| @@ -60,6 +60,27 @@ import XCTest |
| XCTAssertTrue(summary.isEmpty) |
| } |
| + func testUnavailableStoreKeepsTheLastKnownStatusUntilUnlock() async throws { |
| + let (connectivity, _) = await connectivity(.unsatisfied) |
| + let disk = FaultingBlobStore() |
| + let outbox = Outbox(store: disk, account: "a") |
| + try await outbox.enqueue(lane: "doc:a", kind: "update", payload: Data([1])) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { ScriptedTransport([]) }, connectivity: connectivity, send: poster()) |
| + await drainer.drain() |
| + XCTAssertEqual(drainer.status(), .offline(pending: 1)) |
| + disk.unavailable = true |
| + // Force an I/O operation even after a successful initial load. |
| + do { try await outbox.enqueue(lane: "doc:a", kind: "update", payload: Data([2])); XCTFail("Locked store must refuse enqueue") } |
| + catch BlobStoreError.unavailable { } |
| + await drainer.drain() |
| + XCTAssertEqual(drainer.status(), .offline(pending: 1), "Unavailable keeps the known status; it is not lost") |
| + XCTAssertEqual(drainer.status(localFailure: .cannotSaveOnDevice), .failed(pending: 1, reason: .cannotSaveOnDevice), "The app still reports undurable input explicitly") |
| + disk.unavailable = false |
| + await drainer.drain() |
| + XCTAssertEqual(drainer.status(), .offline(pending: 1)) |
| + XCTAssertEqual(disk.committed.contents.count, 1) |
| + } |
| + |
| func testDrainsEachLaneInOrderAndAcknowledges() async throws { |
| let (connectivity, _) = await connectivity() |
| let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)]) |
diff --git a/docs/testing/k3-evidence.md b/docs/testing/k3-evidence.md| index c463970..5de288c 100644 |
| --- a/docs/testing/k3-evidence.md |
| +++ b/docs/testing/k3-evidence.md |
| @@ -1,4 +1,4 @@ |
| -# K3 validation (in progress) |
| +# K3 validation |
| Continuing WIP 68a51d7 against offline-kit spec 6049830 (discussion #5). |
| The inherited implementations were explicitly no-op stubs, not working code. |
| @@ -16,8 +16,8 @@ drainer/catalog suite passed 18. `make ci` passed, including macOS and iOS |
| companion builds, Go race tests, and Python bundle tests; transcript: |
| `/tmp/k3-kit-ci-1.log`. This is intermediate evidence, not final K3 approval. |
| -Remaining: complete Docs adoption and crash/convergence evidence, seal-count |
| -rotation, final audit and full gates. Nothing has been merged or deployed. |
| +The continuation evidence below supersedes the intermediate status. Nothing |
| +has been merged or deployed. |
| ## Disk stop, 2026-09-24 |
| @@ -38,10 +38,8 @@ Additional focused evidence before the stop: |
| - Fixed an inherited test's unchecked empty-array indexing so stub failures |
| produce an assertion instead of terminating the test process. |
| -The latest changes have NOT had another full `make ci`. The previous full |
| -pass predates rotation and lane-retry fixes. Hub CI on 05bf466 failed while |
| -unpacking Sparkle because `unzip` is unavailable; no macOS runner exists. |
| -This branch is WIP and must not merge. |
| +At that disk stop, the latest changes had not had another full make ci. |
| +The resumed local gate and later focused checks are recorded below. |
| Docs adoption is saved on oficina/docs `feat/k3-outbox-lane`, discussion #69. |
| Its evidence file lists unresolved title/lifecycle issues and remaining gates. |
| @@ -60,3 +58,25 @@ empty queue; entries includes held entries for offline replay; beginSend makes |
| selection and reservation one actor operation so compaction cannot delete a |
| batch in flight; endSend releases a retry/cancelled reservation without ack. |
| No update or retarget API was added. |
| + |
| +## Final continuation evidence |
| + |
| +- Full local `make ci` passed at 3e93ef916f2bb5469fd09375e0f6253938da8650, |
| + including Swift, macOS/iOS companion builds, Go race and Python bundle checks |
| + (`/tmp/k3-kit-final-ci.log`). |
| +- A locked store must preserve its last known status. The new regression failed |
| + against the previous behavior (`/tmp/k3-unavailable-status-red.log`); all 21 |
| + drainer/status tests passed after the fix (`/tmp/k3-unavailable-status-green.log`). |
| + Explicit app enqueue failures still show cannotSaveOnDevice; unknown storage |
| + never masquerades as a known empty queue. |
| +- The final-head `make ci` rerun is logged at `/tmp/k3-kit-release-ci.log` and |
| + its result/head is recorded on [discussion #9](https://amadan.net/rastrillo/native/discuss/9). |
| +- Hub CI at 3e93ef9 failed before compilation: Sparkle archive extraction could |
| + not find unzip. There is no macOS runner; the local macOS gate is authoritative |
| + for this task, as directed by the user. |
| +- Docs now has real SIGKILL red/green proof and the parked-update port. Its |
| + replica/metadata/Home adoption and crash tests are on oficina/docs #69. |
| + |
| +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. |