|
| 1 | +import Foundation |
| 2 | + |
| 3 | +/// Capped exponential backoff with jitter for failed publish jobs. |
| 4 | +struct PublishBackoff: Sendable { |
| 5 | + let base: TimeInterval |
| 6 | + let cap: TimeInterval |
| 7 | + let jitterFraction: Double |
| 8 | + |
| 9 | + static let `default`: PublishBackoff = .init(base: 1, cap: 300, jitterFraction: 0.25) |
| 10 | + |
| 11 | + func delay(forAttempt attempt: Int64) -> TimeInterval { |
| 12 | + let shift = Double(max(0, attempt - 1)) |
| 13 | + let exponential = min(cap, base * pow(2, shift)) |
| 14 | + let jitter = exponential * jitterFraction * Double.random(in: -1...1) |
| 15 | + return max(0, exponential + jitter) |
| 16 | + } |
| 17 | +} |
| 18 | + |
| 19 | +/// Drains the durable profile publish queue: for each conversation, encrypts the |
| 20 | +/// current source avatar once (caching the ciphertext so a restart re-uploads |
| 21 | +/// identical bytes), uploads it, sends a ProfileUpdate, and writes the local |
| 22 | +/// avatar slot. Failures reschedule only that job with capped backoff so the |
| 23 | +/// loop is never head-of-line blocked. XMTP and upload specifics live behind the |
| 24 | +/// injected `ProfilePublishSession`. |
| 25 | +/// |
| 26 | +/// Not wired into startup yet; the lifecycle PR attaches a session and triggers |
| 27 | +/// drains on a wake timer. Within a process, drains are serialized. |
| 28 | +actor ProfilePublisher { |
| 29 | + private let publishStore: any ProfilePublishStoreProtocol |
| 30 | + private let profileStore: any ProfileStoreProtocol |
| 31 | + private let selfProfileStore: any SelfProfileStoreProtocol |
| 32 | + private let selfInboxId: String |
| 33 | + private let now: @Sendable () -> Date |
| 34 | + private let backoff: PublishBackoff |
| 35 | + |
| 36 | + private var session: (any ProfilePublishSession)? |
| 37 | + private var draining: Bool = false |
| 38 | + |
| 39 | + init( |
| 40 | + publishStore: any ProfilePublishStoreProtocol, |
| 41 | + profileStore: any ProfileStoreProtocol, |
| 42 | + selfProfileStore: any SelfProfileStoreProtocol, |
| 43 | + selfInboxId: String, |
| 44 | + now: @escaping @Sendable () -> Date = { Date() }, |
| 45 | + backoff: PublishBackoff = .default |
| 46 | + ) { |
| 47 | + self.publishStore = publishStore |
| 48 | + self.profileStore = profileStore |
| 49 | + self.selfProfileStore = selfProfileStore |
| 50 | + self.selfInboxId = selfInboxId |
| 51 | + self.now = now |
| 52 | + self.backoff = backoff |
| 53 | + } |
| 54 | + |
| 55 | + func attach(session: any ProfilePublishSession) async { |
| 56 | + self.session = session |
| 57 | + await drainReadyJobs() |
| 58 | + } |
| 59 | + |
| 60 | + func detach() { |
| 61 | + session = nil |
| 62 | + } |
| 63 | + |
| 64 | + /// Records a new source avatar (when `avatarBytes` is provided) and enqueues |
| 65 | + /// a publish job per conversation, priority conversation first, then drains. |
| 66 | + /// A nil `avatarBytes` is a name/metadata-only publish that re-sends the |
| 67 | + /// existing avatar for each conversation rather than clearing it. |
| 68 | + func publish(avatarBytes: Data?, priorityConversationId: String?) async throws { |
| 69 | + let conversationIds = try await session?.conversationIds() ?? [] |
| 70 | + var sourceVersion: Int64? |
| 71 | + var hasAvatar = false |
| 72 | + if let avatarBytes { |
| 73 | + let existing = try await publishStore.source(inboxId: selfInboxId) |
| 74 | + let version = (existing?.version ?? 0) + 1 |
| 75 | + try await publishStore.setSource( |
| 76 | + DBProfileAvatarSource(inboxId: selfInboxId, plaintext: avatarBytes, version: version, updatedAt: now()) |
| 77 | + ) |
| 78 | + sourceVersion = version |
| 79 | + hasAvatar = true |
| 80 | + } |
| 81 | + for conversationId in ordered(conversationIds, priority: priorityConversationId) { |
| 82 | + try await enqueueJob(conversationId: conversationId, sourceVersion: sourceVersion, hasAvatar: hasAvatar) |
| 83 | + } |
| 84 | + await drainReadyJobs() |
| 85 | + } |
| 86 | + |
| 87 | + /// Seeds a single conversation with the current profile (e.g. a freshly |
| 88 | + /// created or joined group), then drains. |
| 89 | + func publishConversation(_ conversationId: String) async throws { |
| 90 | + let source = try await publishStore.source(inboxId: selfInboxId) |
| 91 | + try await enqueueJob(conversationId: conversationId, sourceVersion: source?.version, hasAvatar: source != nil) |
| 92 | + await drainReadyJobs() |
| 93 | + } |
| 94 | + |
| 95 | + func drainReadyJobs() async { |
| 96 | + guard !draining, let session else { return } |
| 97 | + draining = true |
| 98 | + defer { draining = false } |
| 99 | + while let job = try? await publishStore.nextReadyJob(now: now()) { |
| 100 | + await process(job, session: session) |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + // MARK: - Enqueue |
| 105 | + |
| 106 | + private func enqueueJob(conversationId: String, sourceVersion: Int64?, hasAvatar: Bool) async throws { |
| 107 | + let seq = try await publishStore.nextSeq() |
| 108 | + let timestamp = now() |
| 109 | + let job = DBProfilePublishJob( |
| 110 | + id: UUID().uuidString, |
| 111 | + seq: seq, |
| 112 | + conversationId: conversationId, |
| 113 | + sourceVersion: sourceVersion, |
| 114 | + hasAvatar: hasAvatar, |
| 115 | + nextAttemptAt: timestamp, |
| 116 | + createdAt: timestamp, |
| 117 | + updatedAt: timestamp |
| 118 | + ) |
| 119 | + try await publishStore.enqueue(job) |
| 120 | + // A newer publish for this conversation supersedes its older pending jobs. |
| 121 | + try await publishStore.supersedeOlderThan(conversationId: conversationId, seq: seq) |
| 122 | + } |
| 123 | + |
| 124 | + private func ordered(_ ids: [String], priority: String?) -> [String] { |
| 125 | + guard let priority, ids.contains(priority) else { return ids } |
| 126 | + return [priority] + ids.filter { $0 != priority } |
| 127 | + } |
| 128 | + |
| 129 | + // MARK: - Process |
| 130 | + |
| 131 | + private func process(_ job: DBProfilePublishJob, session: any ProfilePublishSession) async { |
| 132 | + do { |
| 133 | + if job.hasAvatar { |
| 134 | + try await processAvatarJob(job, session: session) |
| 135 | + } else { |
| 136 | + try await processNameOnlyJob(job, session: session) |
| 137 | + } |
| 138 | + try? await publishStore.deleteJob(id: job.id) |
| 139 | + } catch is PublishDropError { |
| 140 | + try? await publishStore.deleteJob(id: job.id) |
| 141 | + } catch { |
| 142 | + await reschedule(job, error: error) |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + private func processAvatarJob(_ job: DBProfilePublishJob, session: any ProfilePublishSession) async throws { |
| 147 | + guard let version = job.sourceVersion, |
| 148 | + let source = try await publishStore.source(inboxId: selfInboxId), |
| 149 | + source.version == version else { |
| 150 | + throw PublishDropError.staleSource |
| 151 | + } |
| 152 | + guard let groupKey = try await session.imageKey(conversationId: job.conversationId) else { |
| 153 | + throw PublishDropError.conversationGone |
| 154 | + } |
| 155 | + |
| 156 | + var current = job |
| 157 | + if current.ciphertext == nil || current.groupKey != groupKey { |
| 158 | + let payload = try session.encrypt(source.plaintext, groupKey: groupKey) |
| 159 | + current.ciphertext = payload.ciphertext |
| 160 | + current.salt = payload.salt |
| 161 | + current.nonce = payload.nonce |
| 162 | + current.groupKey = groupKey |
| 163 | + current.filename = "ep-\(UUID().uuidString).enc" |
| 164 | + current.uploadedURL = nil |
| 165 | + current.updatedAt = now() |
| 166 | + try await publishStore.update(current) |
| 167 | + } |
| 168 | + |
| 169 | + let url: String |
| 170 | + if let uploaded = current.uploadedURL { |
| 171 | + url = uploaded |
| 172 | + } else { |
| 173 | + guard let ciphertext = current.ciphertext, let filename = current.filename else { |
| 174 | + throw PublishDropError.staleSource |
| 175 | + } |
| 176 | + url = try await session.upload(ciphertext, filename: filename) |
| 177 | + current.uploadedURL = url |
| 178 | + current.updatedAt = now() |
| 179 | + try await publishStore.update(current) |
| 180 | + } |
| 181 | + |
| 182 | + guard let salt = current.salt, let nonce = current.nonce, let key = current.groupKey else { |
| 183 | + throw PublishDropError.staleSource |
| 184 | + } |
| 185 | + let published = PublishedAvatar(url: url, salt: salt, nonce: nonce, key: key) |
| 186 | + let selfName = try await selfProfileStore.load()?.name |
| 187 | + try await session.sendProfileUpdate(name: selfName, avatar: published, conversationId: job.conversationId) |
| 188 | + let slot = DBProfileAvatar( |
| 189 | + inboxId: selfInboxId, |
| 190 | + conversationId: job.conversationId, |
| 191 | + url: url, |
| 192 | + salt: salt, |
| 193 | + nonce: nonce, |
| 194 | + encryptionKey: key, |
| 195 | + profileSource: .profileUpdate, |
| 196 | + updatedAt: now() |
| 197 | + ) |
| 198 | + try await profileStore.saveAvatar(slot) |
| 199 | + } |
| 200 | + |
| 201 | + private func processNameOnlyJob(_ job: DBProfilePublishJob, session: any ProfilePublishSession) async throws { |
| 202 | + let existing = try await profileStore.avatar(inboxId: selfInboxId, conversationId: job.conversationId) |
| 203 | + let published = publishedAvatar(from: existing) |
| 204 | + let selfName = try await selfProfileStore.load()?.name |
| 205 | + try await session.sendProfileUpdate(name: selfName, avatar: published, conversationId: job.conversationId) |
| 206 | + } |
| 207 | + |
| 208 | + private func publishedAvatar(from slot: DBProfileAvatar?) -> PublishedAvatar? { |
| 209 | + guard let slot, let url = slot.url, let salt = slot.salt, let nonce = slot.nonce, let key = slot.encryptionKey else { |
| 210 | + return nil |
| 211 | + } |
| 212 | + return PublishedAvatar(url: url, salt: salt, nonce: nonce, key: key) |
| 213 | + } |
| 214 | + |
| 215 | + private func reschedule(_ job: DBProfilePublishJob, error: any Error) async { |
| 216 | + // Re-fetch so the reschedule preserves any ciphertext/uploaded URL cached |
| 217 | + // during the attempt, rather than overwriting it with the stale `job`. |
| 218 | + var updated = await latestJob(id: job.id) ?? job |
| 219 | + updated.attemptCount += 1 |
| 220 | + updated.lastError = "\(error)" |
| 221 | + updated.nextAttemptAt = now().addingTimeInterval(backoff.delay(forAttempt: updated.attemptCount)) |
| 222 | + updated.state = .pending |
| 223 | + updated.updatedAt = now() |
| 224 | + try? await publishStore.update(updated) |
| 225 | + Log.error("ProfilePublisher job \(job.id) failed (attempt \(updated.attemptCount)): \(error)") |
| 226 | + } |
| 227 | + |
| 228 | + private func latestJob(id: String) async -> DBProfilePublishJob? { |
| 229 | + try? await publishStore.job(id: id) |
| 230 | + } |
| 231 | + |
| 232 | + private enum PublishDropError: Error { |
| 233 | + case staleSource |
| 234 | + case conversationGone |
| 235 | + } |
| 236 | +} |
0 commit comments