| 1 | import Foundation |
| 2 | import RastrilloNative |
| 3 | import RastrilloTestSupport |
| 4 | import XCTest |
| 5 | |
| 6 | private func bytes(_ text: String) -> Data { Data(text.utf8) } |
| 7 | |
| 8 | /// Answers `.corrupt` for chosen paths, removing them, as the sealed store does. |
| 9 | private final class CorruptingStore: BlobStore, @unchecked Sendable { |
| 10 | let base: MemoryBlobStore |
| 11 | let corrupt: Set<String> |
| 12 | init(_ base: MemoryBlobStore, corrupt: Set<String>) { self.base = base; self.corrupt = corrupt } |
| 13 | func write(_ data: Data, to path: String) throws { try base.write(data, to: path) } |
| 14 | func read(_ path: String) throws -> Data? { |
| 15 | if corrupt.contains(path) { try base.remove(path); throw BlobStoreError.corrupt(path) } |
| 16 | return try base.read(path) |
| 17 | } |
| 18 | func list(_ directory: String) throws -> [String] { try base.list(directory) } |
| 19 | func remove(_ path: String) throws { try base.remove(path) } |
| 20 | } |
| 21 | |
| 22 | /// Answers `.keyLost` on the first read and removes everything, as the |
| 23 | /// sealed store does when the Keychain has lost the key. |
| 24 | private final class KeyLosingStore: BlobStore, @unchecked Sendable { |
| 25 | let base: MemoryBlobStore |
| 26 | init(_ base: MemoryBlobStore) { self.base = base } |
| 27 | func write(_ data: Data, to path: String) throws { try base.write(data, to: path) } |
| 28 | func read(_ path: String) throws -> Data? { |
| 29 | guard try base.read(path) != nil else { return nil } |
| 30 | try base.remove("") |
| 31 | throw BlobStoreError.keyLost |
| 32 | } |
| 33 | func list(_ directory: String) throws -> [String] { try base.list(directory) } |
| 34 | func remove(_ path: String) throws { try base.remove(path) } |
| 35 | } |
| 36 | |
| 37 | final class OutboxTests: XCTestCase { |
| 38 | func testEnqueueIsDurableOnReturnAndSeqResumesAfterTheHighest() async throws { |
| 39 | let disk = MemoryBlobStore() |
| 40 | let outbox = Outbox(store: disk, account: "a") |
| 41 | let first = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("one")) |
| 42 | let second = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("two")) |
| 43 | XCTAssertEqual(disk.contents.keys.sorted(), ["outbox/0000000000000001.entry", "outbox/0000000000000002.entry"]) |
| 44 | |
| 45 | let relaunched = Outbox(store: MemoryBlobStore(disk.contents), account: "a") |
| 46 | let entries = await relaunched.entries(lane: "doc:1") |
| 47 | XCTAssertEqual(entries.map(\.id), [first.id, second.id]) |
| 48 | XCTAssertEqual(entries.map(\.payload), [bytes("one"), bytes("two")]) |
| 49 | let third = try await relaunched.enqueue(lane: "doc:1", kind: "update", payload: bytes("three")) |
| 50 | XCTAssertEqual(third.seq, 3, "seq is recovered as max + 1") |
| 51 | } |
| 52 | |
| 53 | func testLanesAreFIFOAndIndependentAndBatchesAreCutByBytes() async throws { |
| 54 | let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| 55 | for index in 0..<4 { try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data(repeating: UInt8(index), count: 100)) } |
| 56 | try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("b")) |
| 57 | let batch = await outbox.sendable(lane: "doc:a", maxBytes: 250) |
| 58 | XCTAssertEqual(batch.map(\.seq), [1, 2], "Cut by bytes, in seq order") |
| 59 | let oversized = await outbox.sendable(lane: "doc:a", maxBytes: 10) |
| 60 | XCTAssertEqual(oversized.map(\.seq), [1], "A batch always carries at least one entry") |
| 61 | let other = await outbox.sendable(lane: "doc:b", maxBytes: nil) |
| 62 | XCTAssertEqual(other.map(\.payload), [bytes("b")]) |
| 63 | } |
| 64 | |
| 65 | func testAHoldStopsItsOwnLaneOnly() async throws { |
| 66 | let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| 67 | let first = try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("1")) |
| 68 | try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("2")) |
| 69 | try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("3")) |
| 70 | try await outbox.hold([first.id], Hold(.accessLost)) |
| 71 | let held = await outbox.sendable(lane: "doc:a", maxBytes: nil) |
| 72 | XCTAssertEqual(held, [], "Nothing passes a hold: Docs updates depend on the ones before them") |
| 73 | let other = await outbox.sendable(lane: "doc:b", maxBytes: nil) |
| 74 | XCTAssertEqual(other.count, 1) |
| 75 | let summary = await outbox.summary |
| 76 | XCTAssertEqual(summary.lanes["doc:a"]?.held, 1) |
| 77 | XCTAssertEqual(summary.lanes["doc:a"]?.pending, 1) |
| 78 | XCTAssertEqual(summary.lanes["doc:a"]?.firstHold, .accessLost) |
| 79 | try await outbox.release(first.id) |
| 80 | let released = await outbox.sendable(lane: "doc:a", maxBytes: nil) |
| 81 | XCTAssertEqual(released.count, 2) |
| 82 | } |
| 83 | |
| 84 | func testReplaceKeepsTheOldestSeqAndTakesANewID() async throws { |
| 85 | let disk = MemoryBlobStore() |
| 86 | let outbox = Outbox(store: disk, account: "a") |
| 87 | var ids: [UUID] = [] |
| 88 | for text in ["a", "b", "c"] { ids.append(try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes(text)).id) } |
| 89 | let after = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("d")) |
| 90 | let merged = try await outbox.replace(Array(ids[0...2]), with: bytes("abc")) |
| 91 | XCTAssertEqual(merged.seq, 1) |
| 92 | XCTAssertFalse(ids.contains(merged.id), "The merged entry gets a new id") |
| 93 | XCTAssertEqual(merged.lane, "doc:1") |
| 94 | XCTAssertEqual(merged.kind, "update") |
| 95 | let lane = await outbox.entries(lane: "doc:1") |
| 96 | XCTAssertEqual(lane.map(\.payload), [bytes("abc"), bytes("d")], "Lane order is unchanged") |
| 97 | XCTAssertEqual(lane.last?.id, after.id) |
| 98 | XCTAssertEqual(disk.contents.keys.sorted(), ["outbox/0000000000000001.entry", "outbox/0000000000000004.entry"]) |
| 99 | } |
| 100 | |
| 101 | func testReplaceRefusesInFlightHeldGappedAndCrossLaneRuns() async throws { |
| 102 | let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| 103 | var a: [UUID] = [] |
| 104 | for index in 0..<4 { |
| 105 | a.append(try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data(repeating: 1, count: 10)).id) |
| 106 | // Another lane interleaves in seq; that does not break a run of doc:a. |
| 107 | try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("b\(index)")) |
| 108 | } |
| 109 | let b = await outbox.entries(lane: "doc:b").map(\.id) |
| 110 | guard b.count == 4 else { return XCTFail("Expected four durable entries in lane b") } |
| 111 | let inFlight = try await outbox.beginSend(lane: "doc:a", maxBytes: 15) |
| 112 | XCTAssertEqual(inFlight.map(\.id), [a[0]]) |
| 113 | |
| 114 | await assertThrows(OutboxError.inFlight) { try await outbox.replace([a[0], a[1]], with: Data()) } |
| 115 | await assertThrows(OutboxError.notContiguous) { try await outbox.replace([a[1], a[3]], with: Data()) } |
| 116 | await assertThrows(OutboxError.notContiguous) { try await outbox.replace([a[1], b[1]], with: Data()) } |
| 117 | try await outbox.hold([a[3]], Hold(.tooLarge)) |
| 118 | await assertThrows(OutboxError.held) { try await outbox.replace([a[2], a[3]], with: Data()) } |
| 119 | |
| 120 | let merged = try await outbox.replace([a[1], a[2]], with: bytes("merged")) |
| 121 | XCTAssertEqual(merged.seq, 3, "A run of doc:a skips over doc:b's seqs") |
| 122 | try await outbox.ack([a[0]]) |
| 123 | let lane = await outbox.entries(lane: "doc:a") |
| 124 | XCTAssertEqual(lane.map(\.id), [merged.id, a[3]], "Acking the in-flight head never touches the merged entry") |
| 125 | } |
| 126 | |
| 127 | func testACrashBetweenReplaceStepsLeavesDuplicatesNeverAGap() async throws { |
| 128 | let base = MemoryBlobStore() |
| 129 | let setup = Outbox(store: base, account: "a") |
| 130 | var ids: [UUID] = [] |
| 131 | for text in ["a", "b", "c", "d"] { ids.append(try await setup.enqueue(lane: "doc:1", kind: "u", payload: bytes(text)).id) } |
| 132 | |
| 133 | let faulting = FaultingBlobStore(MemoryBlobStore(base.contents)) |
| 134 | let outbox = Outbox(store: faulting, account: "a") |
| 135 | try await outbox.load() |
| 136 | faulting.crash(after: 1) // step 1 (the write) happens; the first remove does not |
| 137 | do { |
| 138 | try await outbox.replace(Array(ids[0...2]), with: bytes("abc")) |
| 139 | XCTFail("Expected the simulated crash") |
| 140 | } catch is SimulatedCrash {} |
| 141 | |
| 142 | let relaunched = Outbox(store: faulting.committed, account: "a") |
| 143 | let lane = await relaunched.entries(lane: "doc:1") |
| 144 | XCTAssertEqual(lane.map(\.payload), [bytes("abc"), bytes("b"), bytes("c"), bytes("d")], |
| 145 | "Duplicates only: the merged head, then what it merged, then the rest in order") |
| 146 | } |
| 147 | |
| 148 | func testAckDiscardAndPurgeRemoveFiles() async throws { |
| 149 | let disk = MemoryBlobStore() |
| 150 | let outbox = Outbox(store: disk, account: "a") |
| 151 | let one = try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("1")) |
| 152 | try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("2")) |
| 153 | try await outbox.enqueue(lane: "doc:c", kind: "u", payload: bytes("3")) |
| 154 | try await outbox.ack([one.id, UUID()]) |
| 155 | try await outbox.discard(lane: "doc:b") |
| 156 | XCTAssertEqual(disk.contents.count, 1) |
| 157 | try await outbox.purge() |
| 158 | XCTAssertEqual(disk.contents.count, 0) |
| 159 | let summary = await outbox.summary |
| 160 | XCTAssertTrue(summary.isEmpty) |
| 161 | } |
| 162 | |
| 163 | func testAnUnavailableStoreIsNeverAnEmptyStore() async throws { |
| 164 | let base = MemoryBlobStore() |
| 165 | try await Outbox(store: base, account: "a").enqueue(lane: "doc:1", kind: "u", payload: bytes("kept")) |
| 166 | let faulting = FaultingBlobStore(base) |
| 167 | faulting.unavailable = true |
| 168 | let outbox = Outbox(store: faulting, account: "a") |
| 169 | await assertThrows(BlobStoreError.unavailable) { try await outbox.load() } |
| 170 | await assertThrows(BlobStoreError.unavailable) { try await outbox.enqueue(lane: "doc:1", kind: "u", payload: Data()) } |
| 171 | let summary = await outbox.summary |
| 172 | XCTAssertTrue(summary.unavailable) |
| 173 | XCTAssertEqual(base.contents.count, 1, "Nothing was deleted while locked") |
| 174 | XCTAssertFalse(faulting.operations.contains { if case .remove = $0 { return true } else { return false } }) |
| 175 | |
| 176 | faulting.unavailable = false |
| 177 | try await outbox.load() |
| 178 | let lane = await outbox.entries(lane: "doc:1") |
| 179 | XCTAssertEqual(lane.map(\.payload), [bytes("kept")]) |
| 180 | let after = await outbox.summary |
| 181 | XCTAssertFalse(after.unavailable) |
| 182 | } |
| 183 | |
| 184 | func testOneCorruptEntryIsRemovedAloneAndReported() async throws { |
| 185 | let base = MemoryBlobStore() |
| 186 | let setup = Outbox(store: base, account: "a") |
| 187 | for text in ["1", "2", "3"] { try await setup.enqueue(lane: "doc:1", kind: "u", payload: bytes(text)) } |
| 188 | let outbox = Outbox(store: CorruptingStore(base, corrupt: ["outbox/0000000000000002.entry"]), account: "a") |
| 189 | let lane = await outbox.entries(lane: "doc:1") |
| 190 | XCTAssertEqual(lane.map(\.payload), [bytes("1"), bytes("3")]) |
| 191 | XCTAssertEqual(base.contents.count, 2, "Only the corrupt file went") |
| 192 | var summary = await outbox.summary |
| 193 | XCTAssertTrue(summary.lostUnsent) |
| 194 | await outbox.acknowledgeLoss() |
| 195 | summary = await outbox.summary |
| 196 | XCTAssertFalse(summary.lostUnsent, "Reported once") |
| 197 | } |
| 198 | |
| 199 | func testALostKeyEmptiesTheOutboxAndSaysSoOnce() async throws { |
| 200 | let base = MemoryBlobStore() |
| 201 | try await Outbox(store: base, account: "a").enqueue(lane: "doc:1", kind: "u", payload: bytes("gone")) |
| 202 | let outbox = Outbox(store: KeyLosingStore(base), account: "a") |
| 203 | try await outbox.load() |
| 204 | let summary = await outbox.summary |
| 205 | XCTAssertTrue(summary.isEmpty) |
| 206 | XCTAssertTrue(summary.lostUnsent) |
| 207 | try await outbox.enqueue(lane: "doc:1", kind: "u", payload: bytes("new")) |
| 208 | let lane = await outbox.entries(lane: "doc:1") |
| 209 | XCTAssertEqual(lane.map(\.payload), [bytes("new")]) |
| 210 | } |
| 211 | |
| 212 | func testChangesDeliverTheCurrentSummaryThenEachChange() async throws { |
| 213 | let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| 214 | let changes = await outbox.changes |
| 215 | var iterator = changes.makeAsyncIterator() |
| 216 | let first = await iterator.next() |
| 217 | XCTAssertEqual(first?.pending, 0) |
| 218 | try await outbox.enqueue(lane: "doc:1", kind: "u", payload: bytes("x")) |
| 219 | let second = await iterator.next() |
| 220 | XCTAssertEqual(second?.pending, 1) |
| 221 | } |
| 222 | |
| 223 | /// Random enqueues, sends, acks, compactions and crashes. After every |
| 224 | /// relaunch: every entry whose enqueue returned and was never acked is |
| 225 | /// still there, in order, and each lane's content is a |
| 226 | /// duplicates-allowed replay of what was enqueued. |
| 227 | func testRandomCrashesNeverLoseOrReorderAnEntry() async throws { |
| 228 | var generator = SeededGenerator(seed: 0x6b33) |
| 229 | for _ in 0..<150 { |
| 230 | var disk = MemoryBlobStore() |
| 231 | var faulting = FaultingBlobStore(disk) |
| 232 | var outbox = Outbox(store: faulting, account: "a") |
| 233 | var expected: [String: [String]] = [:] // lane -> symbols not yet acked, in order |
| 234 | var acked: Set<String> = [] |
| 235 | var symbol = 0 |
| 236 | for _ in 0..<40 { |
| 237 | let lane = ["doc:a", "doc:b"][Int.random(in: 0...1, using: &generator)] |
| 238 | if Int.random(in: 0..<12, using: &generator) == 0 { faulting.crash(after: Int.random(in: 0...2, using: &generator)) } |
| 239 | do { |
| 240 | switch Int.random(in: 0..<10, using: &generator) { |
| 241 | case 0..<5: |
| 242 | symbol += 1 |
| 243 | let text = "s\(symbol)" |
| 244 | try await outbox.enqueue(lane: lane, kind: "u", payload: bytes(text)) |
| 245 | expected[lane, default: []].append(text) |
| 246 | case 5..<7: |
| 247 | let batch = try await outbox.beginSend(lane: lane, maxBytes: 8) |
| 248 | guard !batch.isEmpty else { continue } |
| 249 | // The server has the batch before the ack starts: a crash part |
| 250 | // way through the ack loses nothing the server lacks. |
| 251 | for entry in batch { |
| 252 | let text = String(decoding: entry.payload, as: UTF8.self) |
| 253 | for part in text.split(separator: "+") { acked.insert(String(part)) } |
| 254 | } |
| 255 | expected[lane] = expected[lane, default: []].filter { !acked.contains($0) } |
| 256 | try await outbox.ack(batch.map(\.id)) |
| 257 | case 7..<9: |
| 258 | let run = await outbox.sendable(lane: lane, maxBytes: nil) |
| 259 | guard run.count >= 2 else { continue } |
| 260 | let take = Array(run.prefix(Int.random(in: 2...run.count, using: &generator))) |
| 261 | let joined = take.map { String(decoding: $0.payload, as: UTF8.self) }.joined(separator: "+") |
| 262 | try await outbox.replace(take.map(\.id), with: bytes(joined)) |
| 263 | default: |
| 264 | let batch = try await outbox.beginSend(lane: lane, maxBytes: nil) |
| 265 | await outbox.endSend(batch.map(\.id)) |
| 266 | } |
| 267 | } catch is SimulatedCrash { |
| 268 | disk = faulting.committed |
| 269 | faulting = FaultingBlobStore(disk) |
| 270 | outbox = Outbox(store: faulting, account: "a") |
| 271 | } |
| 272 | // The invariant, checked against a fresh reading of the disk. |
| 273 | let reread = Outbox(store: MemoryBlobStore(faulting.committed.contents), account: "a") |
| 274 | for (lane, symbols) in expected { |
| 275 | let present = await reread.entries(lane: lane).flatMap { |
| 276 | String(decoding: $0.payload, as: UTF8.self).split(separator: "+").map(String.init) |
| 277 | } |
| 278 | let unacked = present.filter { !acked.contains($0) } |
| 279 | var seen: [String] = [] |
| 280 | for item in unacked where seen.last != item && !seen.contains(item) { seen.append(item) } |
| 281 | XCTAssertEqual(seen, symbols, "lane \(lane): lost or reordered an entry") |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | struct SeededGenerator: RandomNumberGenerator { |
| 289 | private var state: UInt64 |
| 290 | init(seed: UInt64) { state = seed } |
| 291 | mutating func next() -> UInt64 { |
| 292 | state &+= 0x9E3779B97F4A7C15 |
| 293 | var z = state |
| 294 | z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 |
| 295 | z = (z ^ (z >> 27)) &* 0x94D049BB133111EB |
| 296 | return z ^ (z >> 31) |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | func assertThrows<E: Error & Equatable>(_ expected: E, file: StaticString = #filePath, line: UInt = #line, |
| 301 | _ body: () async throws -> some Any) async { |
| 302 | do { |
| 303 | _ = try await body() |
| 304 | XCTFail("Expected \(expected)", file: file, line: line) |
| 305 | } catch let error as E { |
| 306 | XCTAssertEqual(error, expected, file: file, line: line) |
| 307 | } catch { |
| 308 | XCTFail("Expected \(expected), got \(error)", file: file, line: line) |
| 309 | } |
| 310 | } |
| 311 | |