Route delivery status through the store; delete the location index

ConversationStore maintains an exact messageID -> Set<ConversationID>
map at every mutation point (append/upsert/remove/migrate/trim/clear),
so delivery updates are ID-only lookups that fan out to mirrored
ephemeral/stable copies. ChatDeliveryCoordinator shrinks 327 -> 119
lines: the positional location index, its growth-detection/rebuild
machinery, and the duplicate no-downgrade check are deleted - the rule
now lives in exactly one place. The middle-insertion regression tests
are rewritten against the store since stale positional locations are
structurally impossible now.

delivery updates: 38k -> 262k/s (~6.9x); ingest pipelines unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 13:26:00 +02:00
co-authored by Claude Fable 5
parent 99d1d1dccd
commit 7fb1f4a219
6 changed files with 546 additions and 404 deletions
+144 -27
View File
@@ -64,10 +64,25 @@ final class Conversation: ObservableObject, Identifiable {
return messages[index]
}
/// All message IDs currently in this conversation (unordered).
var messageIDs: Dictionary<String, Int>.Keys {
indexByMessageID.keys
}
// MARK: Store-internal mutations
/// Result of an ordered insert. `trimmedMessageIDs` reports messages
/// evicted by the cap so the store can keep its message-ID
/// conversation map exact.
fileprivate struct InsertResult {
let inserted: Bool
let trimmedMessageIDs: [String]
static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: [])
}
fileprivate enum UpsertOutcome {
case appended
case appended(trimmedMessageIDs: [String])
case updated
}
@@ -75,9 +90,9 @@ final class Conversation: ObservableObject, Identifiable {
/// Fast path appends when the timestamp is >= the current tail;
/// otherwise a binary search finds the upper-bound insertion point so
/// arrival order is preserved among equal timestamps.
/// Returns `false` if a message with the same ID already exists.
fileprivate func insert(_ message: BitchatMessage) -> Bool {
guard indexByMessageID[message.id] == nil else { return false }
/// Reports `inserted: false` if a message with the same ID already exists.
fileprivate func insert(_ message: BitchatMessage) -> InsertResult {
guard indexByMessageID[message.id] == nil else { return .duplicate }
if let last = messages.last, message.timestamp < last.timestamp {
let index = insertionIndex(for: message.timestamp)
@@ -88,8 +103,7 @@ final class Conversation: ObservableObject, Identifiable {
indexByMessageID[message.id] = messages.count - 1
}
trimIfNeeded()
return true
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
}
/// Replace-or-append by message ID. An existing message keeps its
@@ -100,14 +114,14 @@ final class Conversation: ObservableObject, Identifiable {
messages[index] = message
return .updated
}
_ = insert(message)
return .appended
let result = insert(message)
return .appended(trimmedMessageIDs: result.trimmedMessageIDs)
}
/// Applies a delivery status keyed by message ID, honoring the
/// no-downgrade rule (mirrors `ChatDeliveryCoordinator.shouldSkipUpdate`):
/// equal statuses are skipped, and `.read` is never downgraded to
/// `.delivered` or `.sent`.
/// no-downgrade rule (the SOLE enforcement point every delivery
/// update flows through the store): equal statuses are skipped, and
/// `.read` is never downgraded to `.delivered` or `.sent`.
/// Returns `true` when the status was applied.
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let index = indexByMessageID[messageID] else { return false }
@@ -197,14 +211,17 @@ final class Conversation: ObservableObject, Identifiable {
}
}
private func trimIfNeeded() {
guard messages.count > cap else { return }
/// Trims oldest messages over the cap; returns the trimmed message IDs.
private func trimIfNeeded() -> [String] {
guard messages.count > cap else { return [] }
let overflow = messages.count - cap
for message in messages.prefix(overflow) {
indexByMessageID.removeValue(forKey: message.id)
let trimmedIDs = messages.prefix(overflow).map(\.id)
for id in trimmedIDs {
indexByMessageID.removeValue(forKey: id)
}
messages.removeFirst(overflow)
reindex(from: 0)
return trimmedIDs
}
}
@@ -242,6 +259,19 @@ final class ConversationStore: ObservableObject {
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
/// Store-level message-ID conversation-membership map for ID-only
/// lookups (delivery receipts arrive with a message ID, not a
/// conversation). Maintained incrementally at every mutation point
/// all mutation is centralized in the intent API below, so the map is
/// exact, never scanned or rebuilt.
///
/// The value is a `Set` because a private message can legitimately live
/// in TWO direct conversations: step 2's raw per-peer keying mirrors a
/// message into both the stable-key and ephemeral-peer chats
/// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both
/// copies.
private var conversationIDsByMessageID: [String: Set<ConversationID>] = [:]
let changes = PassthroughSubject<ConversationChange, Never>()
// MARK: Intent API
@@ -264,7 +294,10 @@ final class ConversationStore: ObservableObject {
@discardableResult
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
let conversation = conversation(for: id)
guard conversation.insert(message) else { return false }
let result = conversation.insert(message)
guard result.inserted else { return false }
registerMessageID(message.id, in: id)
unregisterMessageIDs(result.trimmedMessageIDs, from: id)
changes.send(.appended(id, message))
return true
}
@@ -273,7 +306,9 @@ final class ConversationStore: ObservableObject {
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
let conversation = conversation(for: id)
switch conversation.upsert(message) {
case .appended:
case .appended(let trimmedMessageIDs):
registerMessageID(message.id, in: id)
unregisterMessageIDs(trimmedMessageIDs, from: id)
changes.send(.appended(id, message))
case .updated:
changes.send(.updated(id, messageID: message.id))
@@ -293,6 +328,44 @@ final class ConversationStore: ObservableObject {
return true
}
/// Applies a delivery status to EVERY conversation containing
/// `messageID` (ID-only delivery receipts don't know conversations;
/// mirrored private copies live in two direct chats). Returns `false`
/// when the message is unknown or no copy changed (equal status or
/// downgrade read beats delivered beats sent).
///
/// `BitchatMessage` is a reference type, so mirrored copies sharing one
/// instance are updated by the first apply; subsequent conversations
/// skip as already-equal (state stays correct everywhere, the
/// `.statusChanged` event fires for the conversation that applied).
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let ids = conversationIDsByMessageID[messageID] else { return false }
var applied = false
for id in ids where setDeliveryStatus(status, forMessageID: messageID, in: id) {
applied = true
}
return applied
}
/// Current delivery status of `messageID` in whichever conversation
/// holds it (mirrored copies share status see `setDeliveryStatus`).
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
guard let ids = conversationIDsByMessageID[messageID] else { return nil }
for id in ids {
if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus {
return status
}
}
return nil
}
/// Every conversation currently containing `messageID` (empty when the
/// message is unknown).
func conversationIDs(forMessageID messageID: String) -> Set<ConversationID> {
conversationIDsByMessageID[messageID] ?? []
}
func markRead(_ id: ConversationID) {
guard unreadConversations.contains(id) else { return }
unreadConversations.remove(id)
@@ -329,7 +402,13 @@ final class ConversationStore: ObservableObject {
let destinationConversation = conversation(for: destination)
for message in sourceConversation.messages {
_ = destinationConversation.insert(message)
let result = destinationConversation.insert(message)
guard result.inserted else { continue }
registerMessageID(message.id, in: destination)
unregisterMessageIDs(result.trimmedMessageIDs, from: destination)
}
for messageID in sourceConversation.messageIDs {
unregisterMessageID(messageID, from: source)
}
let wasUnread = unreadConversations.contains(source)
@@ -359,6 +438,7 @@ final class ConversationStore: ObservableObject {
let removed = conversation.remove(messageID: messageID) else {
return nil
}
unregisterMessageID(messageID, from: id)
changes.send(.messageRemoved(id, messageID: messageID))
return removed
}
@@ -368,7 +448,9 @@ final class ConversationStore: ObservableObject {
/// conversation is consistent. No-op for unknown conversations.
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
guard let conversation = conversationsByID[id] else { return }
for messageID in conversation.removeAll(where: predicate) {
let removedIDs = conversation.removeAll(where: predicate)
unregisterMessageIDs(removedIDs, from: id)
for messageID in removedIDs {
changes.send(.messageRemoved(id, messageID: messageID))
}
}
@@ -377,6 +459,9 @@ final class ConversationStore: ObservableObject {
/// its unread/selection state) alive.
func clear(_ id: ConversationID) {
guard let conversation = conversationsByID[id] else { return }
for messageID in conversation.messageIDs {
unregisterMessageID(messageID, from: id)
}
conversation.clearMessages()
changes.send(.cleared(id))
}
@@ -384,7 +469,10 @@ final class ConversationStore: ObservableObject {
/// Removes a conversation entirely, including unread state; clears the
/// selection if it pointed at the removed conversation.
func removeConversation(_ id: ConversationID) {
guard conversationsByID.removeValue(forKey: id) != nil else { return }
guard let conversation = conversationsByID.removeValue(forKey: id) else { return }
for messageID in conversation.messageIDs {
unregisterMessageID(messageID, from: id)
}
conversationIDs.removeAll { $0 == id }
unreadConversations.remove(id)
if selectedConversationID == id {
@@ -400,6 +488,7 @@ final class ConversationStore: ObservableObject {
conversationsByID.removeAll()
conversationIDs.removeAll()
unreadConversations.removeAll()
conversationIDsByMessageID.removeAll()
if selectedConversationID != nil {
selectedConversationID = nil
}
@@ -411,6 +500,26 @@ final class ConversationStore: ObservableObject {
// MARK: Internals
private func registerMessageID(_ messageID: String, in id: ConversationID) {
conversationIDsByMessageID[messageID, default: []].insert(id)
}
private func unregisterMessageID(_ messageID: String, from id: ConversationID) {
guard var ids = conversationIDsByMessageID[messageID] else { return }
ids.remove(id)
if ids.isEmpty {
conversationIDsByMessageID.removeValue(forKey: messageID)
} else {
conversationIDsByMessageID[messageID] = ids
}
}
private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) {
for messageID in messageIDs {
unregisterMessageID(messageID, from: id)
}
}
private static func cap(for id: ConversationID) -> Int {
switch id {
case .mesh:
@@ -471,13 +580,23 @@ extension ConversationStore {
}
/// `true` when any direct conversation contains a message with `messageID`
/// (O(1) per conversation via the incremental ID index).
/// (O(1) via the store-level message-ID conversation map).
func directConversationsContainMessage(withID messageID: String) -> Bool {
conversationIDs(forMessageID: messageID).contains { id in
if case .direct = id { return true }
return false
}
}
/// Message IDs across all direct conversations (read-receipt pruning
/// keeps only receipts whose messages still exist).
func directMessageIDs() -> Set<String> {
var messageIDs = Set<String>()
for (id, conversation) in conversationsByID {
guard case .direct = id else { continue }
if conversation.containsMessage(withID: messageID) { return true }
messageIDs.formUnion(conversation.messageIDs)
}
return false
return messageIDs
}
/// Removes every direct conversation (panic clear).
@@ -501,12 +620,10 @@ extension ConversationStore {
/// message, if any.
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
for (id, conversation) in conversationsByID {
for id in conversationIDs(forMessageID: messageID) {
switch id {
case .mesh, .geohash:
if conversation.containsMessage(withID: messageID) {
return removeMessage(withID: messageID, from: id)
}
return removeMessage(withID: messageID, from: id)
case .direct:
continue
}
+35 -243
View File
@@ -12,18 +12,19 @@ import Foundation
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
@MainActor
protocol ChatDeliveryContext: AnyObject {
/// Get-only derived views of the `ConversationStore`. `BitchatMessage`
/// is a reference type, so the public-timeline status patch below writes
/// through the shared message objects; step 4 replaces this with
/// `setDeliveryStatus(for:in:)` store intents.
var messages: [BitchatMessage] { get }
var privateChats: [PeerID: [BitchatMessage]] { get }
var isStartupPhase: Bool { get }
/// Applies a delivery status to a private message by ID (single-writer
/// store intent; full delivery migration is step 4). Returns `false`
/// when the message is unknown or the update would downgrade the status.
/// Applies a delivery status to every copy of the message across
/// conversations (`ConversationStore` intent, ID-only: the store's
/// message-ID conversation map resolves which conversations hold the
/// message, including mirrored ephemeral/stable private copies). The
/// no-downgrade rule is enforced in the store. Returns `false` when the
/// message is unknown or no copy changed.
@discardableResult
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
/// Current delivery status of the message in whichever conversation holds it.
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
/// Message IDs across all direct conversations (read-receipt pruning).
func privateMessageIDs() -> Set<String>
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
/// Returns the number of receipts removed. (Single mutation path for the
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
@@ -35,6 +36,19 @@ protocol ChatDeliveryContext: AnyObject {
}
extension ChatViewModel: ChatDeliveryContext {
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
conversations.setDeliveryStatus(status, forMessageID: messageID)
}
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
conversations.deliveryStatus(forMessageID: messageID)
}
func privateMessageIDs() -> Set<String> {
conversations.directMessageIDs()
}
func notifyUIChanged() {
objectWillChange.send()
}
@@ -44,14 +58,12 @@ extension ChatViewModel: ChatDeliveryContext {
}
}
/// Thin mapper from delivery events (read receipts, transport delivery
/// callbacks) onto `ConversationStore` delivery intents, plus read-receipt
/// retention cleanup. The store's message-ID conversation map replaces the
/// positional `messageLocationIndex` this coordinator used to maintain.
final class ChatDeliveryCoordinator {
private unowned let context: any ChatDeliveryContext
private var messageLocationIndex: [String: Set<MessageLocation>] = [:]
private var indexedPublicMessageCount = 0
private var indexedPublicTailMessageID: String?
private var indexedPrivateMessageCounts: [PeerID: Int] = [:]
private var indexedPrivateTailMessageIDs: [PeerID: String] = [:]
private var hasBuiltMessageLocationIndex = false
init(context: any ChatDeliveryContext) {
self.context = context
@@ -59,15 +71,9 @@ final class ChatDeliveryCoordinator {
@MainActor
func cleanupOldReadReceipts() {
guard !context.isStartupPhase, !context.privateChats.isEmpty else {
return
}
let validMessageIDs = Set(
context.privateChats.values.flatMap { messages in
messages.map(\.id)
}
)
guard !context.isStartupPhase else { return }
let validMessageIDs = context.privateMessageIDs()
guard !validMessageIDs.isEmpty else { return }
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
if removedCount > 0 {
@@ -90,9 +96,7 @@ final class ChatDeliveryCoordinator {
@MainActor
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
withValidLocations(for: messageID) { locations in
locations.lazy.compactMap { self.deliveryStatus(at: $0) }.first
}
context.deliveryStatus(forMessageID: messageID)
}
@MainActor
@@ -106,222 +110,10 @@ final class ChatDeliveryCoordinator {
break
}
var didUpdateStatus = false
let locations = withValidLocations(for: messageID) { $0 }
guard !locations.isEmpty else { return false }
for location in locations {
guard case .publicTimeline(let index) = location,
index < context.messages.count,
context.messages[index].id == messageID else {
continue
}
let currentStatus = context.messages[index].deliveryStatus
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
context.messages[index].deliveryStatus = status
didUpdateStatus = true
}
}
for location in locations {
guard case .privateChat(let peerID, let index) = location,
let chatMessages = context.privateChats[peerID],
index < chatMessages.count,
chatMessages[index].id == messageID else {
continue
}
let currentStatus = chatMessages[index].deliveryStatus
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
if context.setPrivateDeliveryStatus(status, forMessageID: messageID, peerID: peerID) {
didUpdateStatus = true
}
}
if didUpdateStatus {
context.notifyUIChanged()
}
return didUpdateStatus
}
}
private extension ChatDeliveryCoordinator {
enum MessageLocation: Hashable {
case publicTimeline(index: Int)
case privateChat(peerID: PeerID, index: Int)
}
func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
guard let currentStatus else { return false }
if currentStatus == newStatus { return true }
switch (currentStatus, newStatus) {
case (.read, .delivered), (.read, .sent):
return true
default:
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
return false
}
}
@MainActor
func withValidLocations<T>(
for messageID: String,
_ body: (Set<MessageLocation>) -> T
) -> T {
let didRebuildIndex = refreshMessageLocationIndexForGrowth()
if let locations = messageLocationIndex[messageID],
locations.allSatisfy({ isLocation($0, validFor: messageID) }) {
return body(locations)
}
guard !didRebuildIndex else {
return body(messageLocationIndex[messageID] ?? [])
}
if messageLocationIndex[messageID] == nil {
return body([])
}
rebuildMessageLocationIndex()
return body(messageLocationIndex[messageID] ?? [])
}
@MainActor
func deliveryStatus(at location: MessageLocation) -> DeliveryStatus? {
switch location {
case .publicTimeline(let index):
guard index < context.messages.count else { return nil }
return context.messages[index].deliveryStatus
case .privateChat(let peerID, let index):
guard let messages = context.privateChats[peerID],
index < messages.count else {
return nil
}
return messages[index].deliveryStatus
}
}
@MainActor
func isLocation(_ location: MessageLocation, validFor messageID: String) -> Bool {
switch location {
case .publicTimeline(let index):
return index < context.messages.count
&& context.messages[index].id == messageID
case .privateChat(let peerID, let index):
guard let messages = context.privateChats[peerID],
index < messages.count else {
return false
}
return messages[index].id == messageID
}
}
@MainActor
@discardableResult
func refreshMessageLocationIndexForGrowth() -> Bool {
guard hasBuiltMessageLocationIndex else {
rebuildMessageLocationIndex()
return true
}
if context.messages.count < indexedPublicMessageCount {
rebuildMessageLocationIndex()
return true
}
if context.messages.count == indexedPublicMessageCount,
context.messages.last?.id != indexedPublicTailMessageID {
rebuildMessageLocationIndex()
return true
}
if context.messages.count > indexedPublicMessageCount {
// Growth is only a pure append if the previously indexed tail kept
// its position; a middle insertion (out-of-order timestamp arrival)
// shifts it and invalidates every indexed location after the
// insertion point.
if indexedPublicMessageCount > 0,
context.messages[indexedPublicMessageCount - 1].id != indexedPublicTailMessageID {
rebuildMessageLocationIndex()
return true
}
for index in indexedPublicMessageCount..<context.messages.count {
add(.publicTimeline(index: index), for: context.messages[index].id)
}
indexedPublicMessageCount = context.messages.count
indexedPublicTailMessageID = context.messages.last?.id
}
let currentPeerIDs = Set(context.privateChats.keys)
if !Set(indexedPrivateMessageCounts.keys).isSubset(of: currentPeerIDs) {
rebuildMessageLocationIndex()
return true
}
for (peerID, messages) in context.privateChats {
let indexedCount = indexedPrivateMessageCounts[peerID] ?? 0
if messages.count < indexedCount {
rebuildMessageLocationIndex()
return true
}
if messages.count == indexedCount,
messages.last?.id != indexedPrivateTailMessageIDs[peerID] {
rebuildMessageLocationIndex()
return true
}
guard messages.count > indexedCount else { continue }
// Same append-only check as the public timeline above.
if indexedCount > 0,
messages[indexedCount - 1].id != indexedPrivateTailMessageIDs[peerID] {
rebuildMessageLocationIndex()
return true
}
for index in indexedCount..<messages.count {
add(.privateChat(peerID: peerID, index: index), for: messages[index].id)
}
indexedPrivateMessageCounts[peerID] = messages.count
if let tailID = messages.last?.id {
indexedPrivateTailMessageIDs[peerID] = tailID
} else {
indexedPrivateTailMessageIDs.removeValue(forKey: peerID)
}
}
return false
}
@MainActor
func rebuildMessageLocationIndex() {
messageLocationIndex.removeAll(keepingCapacity: true)
for (index, message) in context.messages.enumerated() {
add(.publicTimeline(index: index), for: message.id)
}
indexedPublicMessageCount = context.messages.count
indexedPublicTailMessageID = context.messages.last?.id
indexedPrivateMessageCounts.removeAll(keepingCapacity: true)
indexedPrivateTailMessageIDs.removeAll(keepingCapacity: true)
for (peerID, messages) in context.privateChats {
for (index, message) in messages.enumerated() {
add(.privateChat(peerID: peerID, index: index), for: message.id)
}
indexedPrivateMessageCounts[peerID] = messages.count
if let tailID = messages.last?.id {
indexedPrivateTailMessageIDs[peerID] = tailID
}
}
hasBuiltMessageLocationIndex = true
}
func add(_ location: MessageLocation, for messageID: String) {
messageLocationIndex[messageID, default: []].insert(location)
context.notifyUIChanged()
return true
}
}