Skip to content

Commit 7969469

Browse files
committed
profile publisher and backfill
1 parent 5230dc5 commit 7969469

6 files changed

Lines changed: 773 additions & 2 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import Foundation
2+
import GRDB
3+
4+
/// One-time migration that seeds the canonical profile stores from the legacy
5+
/// per-conversation `DBMemberProfile` rows. Runs at startup before
6+
/// `ProfilesRepository.warmUp`, so the new tables are populated before any
7+
/// reader fetches.
8+
///
9+
/// Everything is written at the lowest source (`.contact`) and an epoch-floor
10+
/// timestamp, so any real inbound event supersedes a backfilled value. Idempotent
11+
/// and non-clobbering: re-running fills only blanks of whatever is already there,
12+
/// and a value already set by a real `profileUpdate` is never downgraded.
13+
///
14+
/// Not wired into startup yet; the lifecycle PR calls `run()`.
15+
struct ProfileBackfill {
16+
private let databaseReader: any DatabaseReader
17+
private let profileStore: any ProfileStoreProtocol
18+
private let selfProfileStore: any SelfProfileStoreProtocol
19+
private let selfInboxId: String
20+
21+
/// Floor timestamp for backfilled rows; any real event (later `sentAt`)
22+
/// supersedes them.
23+
private let floor: Date = .init(timeIntervalSince1970: 0)
24+
25+
init(
26+
databaseReader: any DatabaseReader,
27+
profileStore: any ProfileStoreProtocol,
28+
selfProfileStore: any SelfProfileStoreProtocol,
29+
selfInboxId: String
30+
) {
31+
self.databaseReader = databaseReader
32+
self.profileStore = profileStore
33+
self.selfProfileStore = selfProfileStore
34+
self.selfInboxId = selfInboxId
35+
}
36+
37+
func run() async throws {
38+
let rows = try await databaseReader.read { db in
39+
try DBMemberProfile.fetchAll(db)
40+
}
41+
guard !rows.isEmpty else { return }
42+
43+
// Collapse a person's multiple conversation rows into one identity, and
44+
// gather the current user's own name/metadata for the self seed.
45+
var identityByInbox: [String: DBProfile] = [:]
46+
var selfName: String?
47+
var selfMetadata: ProfileMetadata?
48+
var sawSelf = false
49+
50+
for row in rows {
51+
if row.inboxId == selfInboxId {
52+
sawSelf = true
53+
if selfName == nil {
54+
selfName = ProfileMerge.nonBlank(row.name)
55+
}
56+
if selfMetadata == nil {
57+
selfMetadata = row.metadata
58+
}
59+
} else {
60+
identityByInbox[row.inboxId] = ProfileMerge.mergeIdentity(
61+
existing: identityByInbox[row.inboxId],
62+
inboxId: row.inboxId,
63+
incoming: IncomingIdentity(name: row.name, memberKind: row.memberKind, metadata: row.metadata),
64+
source: .contact,
65+
sentAt: floor
66+
)
67+
}
68+
69+
try await backfillAvatar(row)
70+
}
71+
72+
try await backfillIdentities(identityByInbox)
73+
74+
if sawSelf, try await selfProfileStore.load() == nil {
75+
try await selfProfileStore.save(
76+
DBSelfProfile(inboxId: selfInboxId, name: selfName, metadata: selfMetadata, updatedAt: floor)
77+
)
78+
}
79+
}
80+
81+
private func backfillAvatar(_ row: DBMemberProfile) async throws {
82+
guard row.hasValidEncryptedAvatar, let url = row.avatar else { return }
83+
let existing = try await profileStore.avatar(inboxId: row.inboxId, conversationId: row.conversationId)
84+
let merged = ProfileMerge.mergeAvatar(
85+
existing: existing,
86+
inboxId: row.inboxId,
87+
conversationId: row.conversationId,
88+
incoming: .set(url: url, salt: row.avatarSalt, nonce: row.avatarNonce, key: row.avatarKey),
89+
source: .contact,
90+
sentAt: floor
91+
)
92+
if let merged {
93+
try await profileStore.saveAvatar(merged)
94+
}
95+
}
96+
97+
private func backfillIdentities(_ identityByInbox: [String: DBProfile]) async throws {
98+
for (inboxId, accumulated) in identityByInbox {
99+
// Merge against whatever is already stored at `.contact`/floor, so a
100+
// value already set by a real event is preserved, not overwritten.
101+
let existing = try await profileStore.identity(inboxId: inboxId)
102+
let merged = ProfileMerge.mergeIdentity(
103+
existing: existing,
104+
inboxId: inboxId,
105+
incoming: IncomingIdentity(name: accumulated.name, memberKind: accumulated.memberKind, metadata: accumulated.metadata),
106+
source: .contact,
107+
sentAt: floor
108+
)
109+
try await profileStore.saveIdentity(merged)
110+
}
111+
}
112+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Foundation
2+
3+
/// Result of encrypting plaintext avatar bytes; cached on a publish job so a
4+
/// restart re-uploads identical ciphertext without re-encrypting.
5+
struct EncryptedAvatarPayload: Sendable {
6+
let ciphertext: Data
7+
let salt: Data
8+
let nonce: Data
9+
}
10+
11+
/// A fully-resolved avatar reference, ready to advertise in a ProfileUpdate and
12+
/// store in the local avatar slot.
13+
struct PublishedAvatar: Sendable {
14+
let url: String
15+
let salt: Data
16+
let nonce: Data
17+
let key: Data
18+
}
19+
20+
/// The XMTP- and upload-facing seam the publisher delegates to, keeping
21+
/// `ProfilePublisher` (and ConvosCore) free of XMTPiOS. The messaging layer
22+
/// provides the concrete implementation when the publisher is wired up.
23+
protocol ProfilePublishSession: Sendable {
24+
var inboxId: String { get }
25+
26+
/// All conversations the current user can publish their profile to.
27+
func conversationIds() async throws -> [String]
28+
29+
/// The conversation's image-encryption (group) key, or nil if the
30+
/// conversation no longer exists - in which case its publish job is dropped.
31+
func imageKey(conversationId: String) async throws -> Data?
32+
33+
/// Encrypts plaintext avatar bytes under the conversation's group key.
34+
func encrypt(_ plaintext: Data, groupKey: Data) throws -> EncryptedAvatarPayload
35+
36+
/// Uploads ciphertext, returning the URL it can be fetched from.
37+
func upload(_ ciphertext: Data, filename: String) async throws -> String
38+
39+
/// Sends a ProfileUpdate carrying the name and (optional) avatar to one
40+
/// conversation.
41+
func sendProfileUpdate(name: String?, avatar: PublishedAvatar?, conversationId: String) async throws
42+
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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

Comments
 (0)