rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3/// One queued change, durable on disk until the server has it.
4public struct OutboxEntry: Codable, Sendable, Identifiable, Equatable {
5 /// Stable for the entry's life; apps may send it as an idempotency key.
6 public let id: UUID
7 /// Per account and monotonic, recovered as max + 1 at load. It orders a lane.
8 public let seq: UInt64
9 /// The FIFO scope, such as "doc:<key>". It never changes.
10 public let lane: String
11 /// App-defined.
12 public let kind: String
13 /// Opaque app bytes.
14 public let payload: Data
15 public let createdAt: Date
16 /// Failed sends so far. Counted in memory; written with the next rewrite.
17 public var attempts: Int
18 /// nil means sendable.
19 public var hold: Hold?
20
21 public init(id: UUID, seq: UInt64, lane: String, kind: String, payload: Data, createdAt: Date, attempts: Int = 0, hold: Hold? = nil) {
22 self.id = id; self.seq = seq; self.lane = lane; self.kind = kind; self.payload = payload
23 self.createdAt = createdAt; self.attempts = attempts; self.hold = hold
24 }
25}
26
27/// Why an entry stopped: the person has to decide what happens to it.
28public struct Hold: Codable, Sendable, Equatable {
29 public enum Reason: Codable, Sendable, Equatable {
30 case conflict(Data)
31 case rejected(String)
32 case accessLost
33 case tooLarge
34 }
35 public let reason: Reason
36 /// Every v1 hold stops its lane. A hold that lets later entries pass
37 /// arrives with Sheets, whose writes to different cells do not depend on
38 /// each other; Docs' updates do, so it never needs one.
39 public var blocksLane: Bool { true }
40 public init(_ reason: Reason) { self.reason = reason }
41}
42
43/// Per-lane counts, for status and for the app's own caps.
44public struct OutboxSummary: Sendable, Equatable {
45 public struct Lane: Sendable, Equatable {
46 /// Entries not held, including any in flight.
47 public var pending = 0
48 public var held = 0
49 public var inFlight = 0
50 /// Payload bytes of every entry in the lane, held or not.
51 public var bytes = 0
52 public var firstHold: Hold.Reason?
53 public init() {}
54 }
55 public var lanes: [String: Lane] = [:]
56 /// The store could not be read. Counts are the last ones known, which is
57 /// never the same thing as "nothing pending".
58 public var unavailable = false
59 /// Unsent entries were lost to a lost key or a corrupt file. Report it
60 /// once, then call `Outbox.acknowledgeLoss()`.
61 public var lostUnsent = false
62
63 public init() {}
64
65 public var pending: Int { lanes.values.reduce(0) { $0 + $1.pending } }
66 public var held: Int { lanes.values.reduce(0) { $0 + $1.held } }
67 public var inFlight: Int { lanes.values.reduce(0) { $0 + $1.inFlight } }
68 /// Lanes with anything in them: the default "N" of "Offline · N pending".
69 public var pendingLanes: Int { lanes.values.filter { $0.pending + $0.held > 0 }.count }
70 public var isEmpty: Bool { lanes.values.allSatisfy { $0.pending + $0.held == 0 } }
71
72 /// The same summary, narrowed to one lane.
73 public func only(_ lane: String) -> OutboxSummary {
74 var copy = self
75 copy.lanes = lanes[lane].map { [lane: $0] } ?? [:]
76 return copy
77 }
78}
79
80public enum OutboxError: Error, Sendable, Equatable {
81 /// `replace` was asked to merge an entry that is being sent. The batch's
82 /// `ack` would otherwise delete the merged file, and with it content whose
83 /// own files the merge had already removed.
84 case inFlight
85 /// `replace` needs a run of entries that are next to each other in one lane.
86 case notContiguous
87 /// `replace` never merges a held entry: it is waiting for a person.
88 case held
89 case unknownEntry(UUID)
90 case duplicateEntry(UUID)
91}
92
93/// Serializes disk mutations and send reservations for one account. Memory is
94/// changed after each successful disk operation, including partial batch acks.
95public actor Outbox {
96 public nonisolated let account: String
97 public static let directory = "outbox"
98 private let store: BlobStore
99 private var values: [OutboxEntry] = []
100 private var flying: Set<UUID> = []
101 private var loaded = false
102 private var unavailable = false
103 private var lost = false
104 private var next: UInt64 = 1
105 private var subscribers: [UUID: AsyncStream<OutboxSummary>.Continuation] = [:]
106 public init(store: BlobStore, account: String) { self.store = store; self.account = account }
107 private func path(_ seq: UInt64) -> String { "outbox/" + String(format: "%016llx", seq) + ".entry" }
108 public func load() throws {
109 guard !loaded || unavailable else { return }
110 var recovered: [OutboxEntry] = []
111 do {
112 let names = try store.list(Self.directory)
113 for name in names where name.hasSuffix(".entry") {
114 let file = Self.directory + "/" + name
115 do {
116 guard let data = try store.read(file) else { continue }
117 guard let entry = try? JSONDecoder().decode(OutboxEntry.self, from: data), path(entry.seq) == file,
118 !recovered.contains(where: { $0.id == entry.id }) else {
119 try store.remove(file); lost = true; continue
120 }
121 recovered.append(entry)
122 } catch BlobStoreError.corrupt { lost = true }
123 }
124 values = recovered.sorted { $0.seq < $1.seq }
125 next = max(next, (values.last?.seq ?? 0) + 1)
126 loaded = true; unavailable = false; notify()
127 } catch BlobStoreError.keyLost {
128 values = []; flying = []; loaded = true; unavailable = false; lost = true; notify()
129 } catch { unavailable = true; notify(); throw error }
130 }
131 private func mutate<T>(_ body: () throws -> T) throws -> T {
132 do { let result = try body(); unavailable = false; notify(); return result }
133 catch { unavailable = true; notify(); throw error }
134 }
135 private func persist(_ entry: OutboxEntry) throws { try store.write(JSONEncoder().encode(entry), to: path(entry.seq)) }
136 @discardableResult
137 public func enqueue(lane: String, kind: String, payload: Data, id: UUID = UUID()) throws -> OutboxEntry {
138 try load()
139 guard !values.contains(where: { $0.id == id }) else { throw OutboxError.duplicateEntry(id) }
140 return try mutate {
141 let entry = OutboxEntry(id: id, seq: next, lane: lane, kind: kind, payload: payload, createdAt: Date())
142 try persist(entry); values.append(entry); next += 1; return entry
143 }
144 }
145 /// A last-known snapshot, including held entries. On an unavailable store
146 /// this may be stale, just like summary (whose unavailable flag is set).
147 /// Use it for display/deduplication hints only; replay must first call load()
148 /// successfully. Every mutation and beginSend independently require load().
149 public func entries(lane: String, excludingInFlight: Bool = false) -> [OutboxEntry] {
150 try? load()
151 return values.filter { $0.lane == lane && (!excludingInFlight || !flying.contains($0.id)) }
152 }
153 /// Fails closed to an empty batch when loading is unavailable.
154 public func sendable(lane: String, maxBytes: Int? = nil) -> [OutboxEntry] {
155 try? load()
156 guard !unavailable else { return [] }
157 var result: [OutboxEntry] = []; var bytes = 0
158 for entry in values where entry.lane == lane {
159 if entry.hold != nil { break }
160 // Compaction may use the suffix after a reserved batch, but beginSend
161 // below never sends it before the earlier batch finishes.
162 if flying.contains(entry.id) { continue }
163 if let maxBytes, !result.isEmpty, bytes + entry.payload.count > maxBytes { break }
164 result.append(entry); bytes += entry.payload.count
165 }
166 return result
167 }
168 public func beginSend(lane: String, maxBytes: Int? = nil) throws -> [OutboxEntry] {
169 try load()
170 guard flying.isEmpty else { return [] }
171 let batch = sendable(lane: lane, maxBytes: maxBytes)
172 if !batch.isEmpty { flying.formUnion(batch.map(\.id)); notify() }
173 return batch
174 }
175 public func endSend(_ ids: [UUID], failed: Bool = true) {
176 for id in ids {
177 flying.remove(id)
178 if failed, let index = values.firstIndex(where: { $0.id == id }) { values[index].attempts += 1 }
179 }
180 notify()
181 }
182 @discardableResult
183 public func replace(_ ids: [UUID], with payload: Data) throws -> OutboxEntry {
184 try load()
185 guard !ids.isEmpty, Set(ids).count == ids.count else { throw OutboxError.notContiguous }
186 if ids.contains(where: { flying.contains($0) }) { throw OutboxError.inFlight }
187 let run = try ids.map { id in
188 guard let entry = values.first(where: { $0.id == id }) else { throw OutboxError.unknownEntry(id) }
189 return entry
190 }.sorted { $0.seq < $1.seq }
191 let first = run[0]
192 let lane = values.filter { $0.lane == first.lane }
193 guard run.allSatisfy({ $0.lane == first.lane && $0.kind == first.kind }),
194 let start = lane.firstIndex(where: { $0.id == first.id }),
195 Array(lane.dropFirst(start).prefix(run.count)).map(\.id) == run.map(\.id) else { throw OutboxError.notContiguous }
196 let sendableIDs = Set(sendable(lane: first.lane).map(\.id))
197 guard run.allSatisfy({ $0.hold == nil && sendableIDs.contains($0.id) }) else { throw OutboxError.held }
198 return try mutate {
199 let merged = OutboxEntry(id: UUID(), seq: first.seq, lane: first.lane, kind: first.kind, payload: payload, createdAt: first.createdAt)
200 try persist(merged)
201 values[values.firstIndex(where: { $0.id == first.id })!] = merged
202 for entry in run.dropFirst() {
203 try store.remove(path(entry.seq)); values.removeAll { $0.id == entry.id }
204 }
205 return merged
206 }
207 }
208 public func ack(_ ids: [UUID]) throws {
209 try load()
210 try mutate {
211 for id in ids {
212 guard let entry = values.first(where: { $0.id == id }) else { continue }
213 try store.remove(path(entry.seq)); values.removeAll { $0.id == id }; flying.remove(id)
214 }
215 }
216 }
217 public func hold(_ ids: [UUID], _ hold: Hold) throws {
218 try load()
219 try mutate {
220 for id in ids {
221 guard let index = values.firstIndex(where: { $0.id == id }) else { continue }
222 var entry = values[index]; entry.hold = hold
223 try persist(entry); values[index] = entry; flying.remove(id)
224 }
225 }
226 }
227 public func release(_ id: UUID) throws {
228 try load()
229 guard let index = values.firstIndex(where: { $0.id == id }) else { throw OutboxError.unknownEntry(id) }
230 try mutate { var entry = values[index]; entry.hold = nil; try persist(entry); values[index] = entry }
231 }
232 public func discard(lane: String) throws { try load(); try ack(values.filter { $0.lane == lane }.map(\.id)) }
233 public func purge() throws {
234 try load()
235 try mutate { try store.remove(Self.directory); values = []; flying = [] }
236 }
237 public func acknowledgeLoss() { lost = false; notify() }
238 private var currentSummary: OutboxSummary {
239 var result = OutboxSummary(); result.unavailable = unavailable; result.lostUnsent = lost
240 for entry in values {
241 var lane = result.lanes[entry.lane] ?? OutboxSummary.Lane()
242 if let hold = entry.hold { lane.held += 1; if lane.firstHold == nil { lane.firstHold = hold.reason } }
243 else { lane.pending += 1 }
244 if flying.contains(entry.id) { lane.inFlight += 1 }
245 lane.bytes += entry.payload.count; result.lanes[entry.lane] = lane
246 }
247 return result
248 }
249 /// Last-known counts; unavailable marks retained counts as non-authoritative.
250 public var summary: OutboxSummary { try? load(); return currentSummary }
251 private func notify() { for subscriber in subscribers.values { subscriber.yield(currentSummary) } }
252 private func unsubscribe(_ id: UUID) { subscribers[id] = nil }
253 public var changes: AsyncStream<OutboxSummary> {
254 try? load()
255 let (stream, continuation) = AsyncStream<OutboxSummary>.makeStream(bufferingPolicy: .bufferingNewest(1))
256 let id = UUID(); subscribers[id] = continuation; continuation.yield(currentSummary)
257 continuation.onTermination = { [weak self] _ in Task { await self?.unsubscribe(id) } }
258 return stream
259 }
260}
261