mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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:
@@ -64,10 +64,25 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
return messages[index]
|
return messages[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All message IDs currently in this conversation (unordered).
|
||||||
|
var messageIDs: Dictionary<String, Int>.Keys {
|
||||||
|
indexByMessageID.keys
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Store-internal mutations
|
// 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 {
|
fileprivate enum UpsertOutcome {
|
||||||
case appended
|
case appended(trimmedMessageIDs: [String])
|
||||||
case updated
|
case updated
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +90,9 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// Fast path appends when the timestamp is >= the current tail;
|
/// Fast path appends when the timestamp is >= the current tail;
|
||||||
/// otherwise a binary search finds the upper-bound insertion point so
|
/// otherwise a binary search finds the upper-bound insertion point so
|
||||||
/// arrival order is preserved among equal timestamps.
|
/// arrival order is preserved among equal timestamps.
|
||||||
/// Returns `false` if a message with the same ID already exists.
|
/// Reports `inserted: false` if a message with the same ID already exists.
|
||||||
fileprivate func insert(_ message: BitchatMessage) -> Bool {
|
fileprivate func insert(_ message: BitchatMessage) -> InsertResult {
|
||||||
guard indexByMessageID[message.id] == nil else { return false }
|
guard indexByMessageID[message.id] == nil else { return .duplicate }
|
||||||
|
|
||||||
if let last = messages.last, message.timestamp < last.timestamp {
|
if let last = messages.last, message.timestamp < last.timestamp {
|
||||||
let index = insertionIndex(for: message.timestamp)
|
let index = insertionIndex(for: message.timestamp)
|
||||||
@@ -88,8 +103,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
indexByMessageID[message.id] = messages.count - 1
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
trimIfNeeded()
|
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace-or-append by message ID. An existing message keeps its
|
/// Replace-or-append by message ID. An existing message keeps its
|
||||||
@@ -100,14 +114,14 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
messages[index] = message
|
messages[index] = message
|
||||||
return .updated
|
return .updated
|
||||||
}
|
}
|
||||||
_ = insert(message)
|
let result = insert(message)
|
||||||
return .appended
|
return .appended(trimmedMessageIDs: result.trimmedMessageIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies a delivery status keyed by message ID, honoring the
|
/// Applies a delivery status keyed by message ID, honoring the
|
||||||
/// no-downgrade rule (mirrors `ChatDeliveryCoordinator.shouldSkipUpdate`):
|
/// no-downgrade rule (the SOLE enforcement point — every delivery
|
||||||
/// equal statuses are skipped, and `.read` is never downgraded to
|
/// update flows through the store): equal statuses are skipped, and
|
||||||
/// `.delivered` or `.sent`.
|
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||||
/// Returns `true` when the status was applied.
|
/// Returns `true` when the status was applied.
|
||||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
guard let index = indexByMessageID[messageID] else { return false }
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
@@ -197,14 +211,17 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func trimIfNeeded() {
|
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||||
guard messages.count > cap else { return }
|
private func trimIfNeeded() -> [String] {
|
||||||
|
guard messages.count > cap else { return [] }
|
||||||
let overflow = messages.count - cap
|
let overflow = messages.count - cap
|
||||||
for message in messages.prefix(overflow) {
|
let trimmedIDs = messages.prefix(overflow).map(\.id)
|
||||||
indexByMessageID.removeValue(forKey: message.id)
|
for id in trimmedIDs {
|
||||||
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
messages.removeFirst(overflow)
|
messages.removeFirst(overflow)
|
||||||
reindex(from: 0)
|
reindex(from: 0)
|
||||||
|
return trimmedIDs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +259,19 @@ final class ConversationStore: ObservableObject {
|
|||||||
|
|
||||||
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
|
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>()
|
let changes = PassthroughSubject<ConversationChange, Never>()
|
||||||
|
|
||||||
// MARK: Intent API
|
// MARK: Intent API
|
||||||
@@ -264,7 +294,10 @@ final class ConversationStore: ObservableObject {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
|
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
|
||||||
let conversation = conversation(for: id)
|
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))
|
changes.send(.appended(id, message))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -273,7 +306,9 @@ final class ConversationStore: ObservableObject {
|
|||||||
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
|
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
|
||||||
let conversation = conversation(for: id)
|
let conversation = conversation(for: id)
|
||||||
switch conversation.upsert(message) {
|
switch conversation.upsert(message) {
|
||||||
case .appended:
|
case .appended(let trimmedMessageIDs):
|
||||||
|
registerMessageID(message.id, in: id)
|
||||||
|
unregisterMessageIDs(trimmedMessageIDs, from: id)
|
||||||
changes.send(.appended(id, message))
|
changes.send(.appended(id, message))
|
||||||
case .updated:
|
case .updated:
|
||||||
changes.send(.updated(id, messageID: message.id))
|
changes.send(.updated(id, messageID: message.id))
|
||||||
@@ -293,6 +328,44 @@ final class ConversationStore: ObservableObject {
|
|||||||
return true
|
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) {
|
func markRead(_ id: ConversationID) {
|
||||||
guard unreadConversations.contains(id) else { return }
|
guard unreadConversations.contains(id) else { return }
|
||||||
unreadConversations.remove(id)
|
unreadConversations.remove(id)
|
||||||
@@ -329,7 +402,13 @@ final class ConversationStore: ObservableObject {
|
|||||||
|
|
||||||
let destinationConversation = conversation(for: destination)
|
let destinationConversation = conversation(for: destination)
|
||||||
for message in sourceConversation.messages {
|
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)
|
let wasUnread = unreadConversations.contains(source)
|
||||||
@@ -359,6 +438,7 @@ final class ConversationStore: ObservableObject {
|
|||||||
let removed = conversation.remove(messageID: messageID) else {
|
let removed = conversation.remove(messageID: messageID) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
unregisterMessageID(messageID, from: id)
|
||||||
changes.send(.messageRemoved(id, messageID: messageID))
|
changes.send(.messageRemoved(id, messageID: messageID))
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
@@ -368,7 +448,9 @@ final class ConversationStore: ObservableObject {
|
|||||||
/// conversation is consistent. No-op for unknown conversations.
|
/// conversation is consistent. No-op for unknown conversations.
|
||||||
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
|
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
|
||||||
guard let conversation = conversationsByID[id] else { return }
|
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))
|
changes.send(.messageRemoved(id, messageID: messageID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,6 +459,9 @@ final class ConversationStore: ObservableObject {
|
|||||||
/// its unread/selection state) alive.
|
/// its unread/selection state) alive.
|
||||||
func clear(_ id: ConversationID) {
|
func clear(_ id: ConversationID) {
|
||||||
guard let conversation = conversationsByID[id] else { return }
|
guard let conversation = conversationsByID[id] else { return }
|
||||||
|
for messageID in conversation.messageIDs {
|
||||||
|
unregisterMessageID(messageID, from: id)
|
||||||
|
}
|
||||||
conversation.clearMessages()
|
conversation.clearMessages()
|
||||||
changes.send(.cleared(id))
|
changes.send(.cleared(id))
|
||||||
}
|
}
|
||||||
@@ -384,7 +469,10 @@ final class ConversationStore: ObservableObject {
|
|||||||
/// Removes a conversation entirely, including unread state; clears the
|
/// Removes a conversation entirely, including unread state; clears the
|
||||||
/// selection if it pointed at the removed conversation.
|
/// selection if it pointed at the removed conversation.
|
||||||
func removeConversation(_ id: ConversationID) {
|
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 }
|
conversationIDs.removeAll { $0 == id }
|
||||||
unreadConversations.remove(id)
|
unreadConversations.remove(id)
|
||||||
if selectedConversationID == id {
|
if selectedConversationID == id {
|
||||||
@@ -400,6 +488,7 @@ final class ConversationStore: ObservableObject {
|
|||||||
conversationsByID.removeAll()
|
conversationsByID.removeAll()
|
||||||
conversationIDs.removeAll()
|
conversationIDs.removeAll()
|
||||||
unreadConversations.removeAll()
|
unreadConversations.removeAll()
|
||||||
|
conversationIDsByMessageID.removeAll()
|
||||||
if selectedConversationID != nil {
|
if selectedConversationID != nil {
|
||||||
selectedConversationID = nil
|
selectedConversationID = nil
|
||||||
}
|
}
|
||||||
@@ -411,6 +500,26 @@ final class ConversationStore: ObservableObject {
|
|||||||
|
|
||||||
// MARK: Internals
|
// 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 {
|
private static func cap(for id: ConversationID) -> Int {
|
||||||
switch id {
|
switch id {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
@@ -471,13 +580,23 @@ extension ConversationStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `true` when any direct conversation contains a message with `messageID`
|
/// `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 {
|
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 {
|
for (id, conversation) in conversationsByID {
|
||||||
guard case .direct = id else { continue }
|
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).
|
/// Removes every direct conversation (panic clear).
|
||||||
@@ -501,12 +620,10 @@ extension ConversationStore {
|
|||||||
/// message, if any.
|
/// message, if any.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
|
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
|
||||||
for (id, conversation) in conversationsByID {
|
for id in conversationIDs(forMessageID: messageID) {
|
||||||
switch id {
|
switch id {
|
||||||
case .mesh, .geohash:
|
case .mesh, .geohash:
|
||||||
if conversation.containsMessage(withID: messageID) {
|
return removeMessage(withID: messageID, from: id)
|
||||||
return removeMessage(withID: messageID, from: id)
|
|
||||||
}
|
|
||||||
case .direct:
|
case .direct:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,18 +12,19 @@ import Foundation
|
|||||||
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
|
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol ChatDeliveryContext: AnyObject {
|
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 }
|
var isStartupPhase: Bool { get }
|
||||||
/// Applies a delivery status to a private message by ID (single-writer
|
/// Applies a delivery status to every copy of the message across
|
||||||
/// store intent; full delivery migration is step 4). Returns `false`
|
/// conversations (`ConversationStore` intent, ID-only: the store's
|
||||||
/// when the message is unknown or the update would downgrade the status.
|
/// 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
|
@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`.
|
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
|
||||||
/// Returns the number of receipts removed. (Single mutation path for the
|
/// Returns the number of receipts removed. (Single mutation path for the
|
||||||
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
|
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
|
||||||
@@ -35,6 +36,19 @@ protocol ChatDeliveryContext: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatDeliveryContext {
|
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() {
|
func notifyUIChanged() {
|
||||||
objectWillChange.send()
|
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 {
|
final class ChatDeliveryCoordinator {
|
||||||
private unowned let context: any ChatDeliveryContext
|
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) {
|
init(context: any ChatDeliveryContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -59,15 +71,9 @@ final class ChatDeliveryCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cleanupOldReadReceipts() {
|
func cleanupOldReadReceipts() {
|
||||||
guard !context.isStartupPhase, !context.privateChats.isEmpty else {
|
guard !context.isStartupPhase else { return }
|
||||||
return
|
let validMessageIDs = context.privateMessageIDs()
|
||||||
}
|
guard !validMessageIDs.isEmpty else { return }
|
||||||
|
|
||||||
let validMessageIDs = Set(
|
|
||||||
context.privateChats.values.flatMap { messages in
|
|
||||||
messages.map(\.id)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
|
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
|
||||||
if removedCount > 0 {
|
if removedCount > 0 {
|
||||||
@@ -90,9 +96,7 @@ final class ChatDeliveryCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||||
withValidLocations(for: messageID) { locations in
|
context.deliveryStatus(forMessageID: messageID)
|
||||||
locations.lazy.compactMap { self.deliveryStatus(at: $0) }.first
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -106,222 +110,10 @@ final class ChatDeliveryCoordinator {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
var didUpdateStatus = false
|
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||||
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:
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
context.notifyUIChanged()
|
||||||
|
return true
|
||||||
@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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func deliveryStatus_indexRefreshesAfterPrivateChatReorder() async {
|
func deliveryStatus_survivesPrivateChatReorder() async {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
let messageID = "reordered-msg-1"
|
let messageID = "reordered-msg-1"
|
||||||
@@ -335,8 +335,8 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
#expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID)))
|
#expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID)))
|
||||||
|
|
||||||
// A late arrival with an older timestamp is inserted before the
|
// A late arrival with an older timestamp is inserted before the
|
||||||
// target by the store, shifting its position and invalidating the
|
// target by the store, shifting its position; the store's ID-keyed
|
||||||
// delivery coordinator's location index.
|
// indexes must keep the target updatable.
|
||||||
viewModel.seedPrivateChat([olderMessage], for: peerID)
|
viewModel.seedPrivateChat([olderMessage], for: peerID)
|
||||||
#expect(viewModel.privateChats[peerID]?.map(\.id) == ["older-msg", messageID])
|
#expect(viewModel.privateChats[peerID]?.map(\.id) == ["older-msg", messageID])
|
||||||
|
|
||||||
@@ -383,16 +383,31 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
// MARK: - Mock Delivery Context
|
// MARK: - Mock Delivery Context
|
||||||
|
|
||||||
/// Lightweight stand-in for `ChatDeliveryContext` proving that
|
/// Lightweight stand-in for `ChatDeliveryContext` proving that
|
||||||
/// `ChatDeliveryCoordinator` is testable without constructing a `ChatViewModel`.
|
/// `ChatDeliveryCoordinator` is testable without constructing a
|
||||||
|
/// `ChatViewModel`: the delivery surface forwards to a real
|
||||||
|
/// `ConversationStore` (the coordinator is a thin mapper over store
|
||||||
|
/// intents), and assertions read store state.
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class MockChatDeliveryContext: ChatDeliveryContext {
|
private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||||
var messages: [BitchatMessage] = []
|
let store = ConversationStore()
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
||||||
var sentReadReceipts: Set<String> = []
|
var sentReadReceipts: Set<String> = []
|
||||||
var isStartupPhase = false
|
var isStartupPhase = false
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
private(set) var markedDeliveredMessageIDs: [String] = []
|
private(set) var markedDeliveredMessageIDs: [String] = []
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
|
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||||
|
store.deliveryStatus(forMessageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateMessageIDs() -> Set<String> {
|
||||||
|
store.directMessageIDs()
|
||||||
|
}
|
||||||
|
|
||||||
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
||||||
let oldCount = sentReadReceipts.count
|
let oldCount = sentReadReceipts.count
|
||||||
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||||
@@ -406,29 +421,19 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
|||||||
func markMessageDelivered(_ messageID: String) {
|
func markMessageDelivered(_ messageID: String) {
|
||||||
markedDeliveredMessageIDs.append(messageID)
|
markedDeliveredMessageIDs.append(messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
|
|
||||||
guard var chat = privateChats[peerID],
|
|
||||||
let index = chat.firstIndex(where: { $0.id == messageID }) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
chat[index].deliveryStatus = status
|
|
||||||
privateChats[peerID] = chat
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMessage {
|
private func makePrivateMessage(
|
||||||
|
id: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
timestamp: Date = Date()
|
||||||
|
) -> BitchatMessage {
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: id,
|
id: id,
|
||||||
sender: "me",
|
sender: "me",
|
||||||
content: "Test message",
|
content: "Test message",
|
||||||
timestamp: Date(),
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: "Peer",
|
recipientNickname: "Peer",
|
||||||
@@ -437,10 +442,28 @@ private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMe
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makePublicMessage(
|
||||||
|
id: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
timestamp: Date = Date()
|
||||||
|
) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
id: id,
|
||||||
|
sender: "me",
|
||||||
|
content: "Public message",
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: false,
|
||||||
|
deliveryStatus: status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Coordinator Tests Against Mock Context
|
// MARK: - Coordinator Tests Against Mock Context
|
||||||
|
|
||||||
/// Exercises `ChatDeliveryCoordinator` against `MockChatDeliveryContext` —
|
/// Exercises `ChatDeliveryCoordinator` against `MockChatDeliveryContext` —
|
||||||
/// the exemplar for the narrow-dependency coordinator pattern.
|
/// the exemplar for the narrow-dependency coordinator pattern. State is
|
||||||
|
/// seeded into and asserted against the mock's `ConversationStore`.
|
||||||
struct ChatDeliveryCoordinatorContextTests {
|
struct ChatDeliveryCoordinatorContextTests {
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -449,7 +472,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
let messageID = "mock-msg-1"
|
let messageID = "mock-msg-1"
|
||||||
context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .sent)]
|
context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(peerID))
|
||||||
|
|
||||||
let didUpdate = coordinator.updateMessageDeliveryStatus(
|
let didUpdate = coordinator.updateMessageDeliveryStatus(
|
||||||
messageID,
|
messageID,
|
||||||
@@ -457,7 +480,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
#expect(didUpdate)
|
#expect(didUpdate)
|
||||||
#expect(isDelivered(context.privateChats[peerID]?.first?.deliveryStatus))
|
#expect(isDelivered(context.store.conversation(for: .directPeer(peerID)).message(withID: messageID)?.deliveryStatus))
|
||||||
#expect(context.notifyUIChangedCount == 1)
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
#expect(context.markedDeliveredMessageIDs == [messageID])
|
||||||
}
|
}
|
||||||
@@ -468,13 +491,16 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
let messageID = "mock-msg-2"
|
let messageID = "mock-msg-2"
|
||||||
context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date()))]
|
context.store.append(
|
||||||
|
makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date())),
|
||||||
|
to: .directPeer(peerID)
|
||||||
|
)
|
||||||
|
|
||||||
coordinator.didReceiveReadReceipt(
|
coordinator.didReceiveReadReceipt(
|
||||||
ReadReceipt(originalMessageID: messageID, readerID: peerID, readerNickname: "Peer")
|
ReadReceipt(originalMessageID: messageID, readerID: peerID, readerNickname: "Peer")
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(isRead(context.privateChats[peerID]?.first?.deliveryStatus))
|
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||||
#expect(context.notifyUIChangedCount == 1)
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
#expect(context.markedDeliveredMessageIDs == [messageID])
|
||||||
}
|
}
|
||||||
@@ -483,22 +509,12 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async {
|
func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async {
|
||||||
let context = MockChatDeliveryContext()
|
let context = MockChatDeliveryContext()
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
context.messages = [
|
context.store.append(makePublicMessage(id: "public-mock-1", status: .sending), to: .mesh)
|
||||||
BitchatMessage(
|
|
||||||
id: "public-mock-1",
|
|
||||||
sender: "me",
|
|
||||||
content: "Public message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: false,
|
|
||||||
deliveryStatus: .sending
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
// .sent is not a confirmed receipt — must not reach markMessageDelivered.
|
// .sent is not a confirmed receipt — must not reach markMessageDelivered.
|
||||||
let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent)
|
let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent)
|
||||||
#expect(didUpdate)
|
#expect(didUpdate)
|
||||||
#expect(isSent(context.messages.first?.deliveryStatus))
|
#expect(isSent(context.store.conversation(for: .mesh).message(withID: "public-mock-1")?.deliveryStatus))
|
||||||
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||||
#expect(context.notifyUIChangedCount == 1)
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
|
|
||||||
@@ -508,57 +524,98 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
#expect(context.notifyUIChangedCount == 1)
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The old positional `messageLocationIndex` could go stale when a late
|
||||||
|
// arrival was inserted mid-array (count grew but indexed locations
|
||||||
|
// shifted). The store's per-conversation ID index is reindexed inside the
|
||||||
|
// same mutation, so staleness is structurally impossible — these tests
|
||||||
|
// pin the equivalent behavior through the new path: after out-of-order
|
||||||
|
// insertion, updates keyed by ID still land on the right messages.
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func middleInsertedMessage_isFoundAfterIndexWasBuilt() async {
|
func middleInsertedMessage_isStillUpdatableByID() async {
|
||||||
let context = MockChatDeliveryContext()
|
let context = MockChatDeliveryContext()
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let makePublic = { (id: String) in
|
context.store.append(
|
||||||
BitchatMessage(
|
makePublicMessage(id: "public-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)),
|
||||||
id: id,
|
to: .mesh
|
||||||
sender: "me",
|
)
|
||||||
content: "Public message",
|
context.store.append(
|
||||||
timestamp: Date(),
|
makePublicMessage(id: "public-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)),
|
||||||
isRelay: false,
|
to: .mesh
|
||||||
isPrivate: false,
|
)
|
||||||
deliveryStatus: .sending
|
|
||||||
)
|
|
||||||
}
|
|
||||||
context.messages = [makePublic("public-a"), makePublic("public-b")]
|
|
||||||
|
|
||||||
// Build the incremental index.
|
|
||||||
#expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent))
|
#expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent))
|
||||||
|
|
||||||
// Out-of-order arrival: PublicMessagePipeline inserts by timestamp,
|
// Out-of-order arrival: the store inserts by timestamp, shifting the
|
||||||
// so the count grows while the tail ID stays the same.
|
// tail's position.
|
||||||
context.messages.insert(makePublic("public-mid"), at: 1)
|
context.store.append(
|
||||||
|
makePublicMessage(id: "public-mid", status: .sending, timestamp: Date(timeIntervalSince1970: 20)),
|
||||||
|
to: .mesh
|
||||||
|
)
|
||||||
|
let mesh = context.store.conversation(for: .mesh)
|
||||||
|
#expect(mesh.messages.map(\.id) == ["public-a", "public-mid", "public-b"])
|
||||||
|
|
||||||
// The inserted message must be locatable, and the shifted tail must
|
// Both the inserted message and the shifted tail stay updatable.
|
||||||
// not be updated through a stale index entry.
|
|
||||||
#expect(coordinator.updateMessageDeliveryStatus("public-mid", status: .sent))
|
#expect(coordinator.updateMessageDeliveryStatus("public-mid", status: .sent))
|
||||||
#expect(isSent(context.messages[1].deliveryStatus))
|
#expect(isSent(mesh.message(withID: "public-mid")?.deliveryStatus))
|
||||||
#expect(coordinator.updateMessageDeliveryStatus("public-b", status: .sent))
|
#expect(coordinator.updateMessageDeliveryStatus("public-b", status: .sent))
|
||||||
#expect(isSent(context.messages[2].deliveryStatus))
|
#expect(isSent(mesh.message(withID: "public-b")?.deliveryStatus))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func middleInsertedPrivateMessage_isFoundAfterIndexWasBuilt() async {
|
func middleInsertedPrivateMessage_isStillUpdatableByID() async {
|
||||||
let context = MockChatDeliveryContext()
|
let context = MockChatDeliveryContext()
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
context.privateChats[peerID] = [
|
let conversationID = ConversationID.directPeer(peerID)
|
||||||
makePrivateMessage(id: "pm-a", status: .sending),
|
context.store.append(
|
||||||
makePrivateMessage(id: "pm-b", status: .sending)
|
makePrivateMessage(id: "pm-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)),
|
||||||
]
|
to: conversationID
|
||||||
|
)
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(id: "pm-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)),
|
||||||
|
to: conversationID
|
||||||
|
)
|
||||||
|
|
||||||
#expect(coordinator.updateMessageDeliveryStatus("pm-a", status: .sent))
|
#expect(coordinator.updateMessageDeliveryStatus("pm-a", status: .sent))
|
||||||
|
|
||||||
// Timestamp re-sort (sanitizeChat) can place a late arrival mid-array.
|
// A late arrival with an older timestamp lands mid-array.
|
||||||
context.privateChats[peerID]?.insert(makePrivateMessage(id: "pm-mid", status: .sending), at: 1)
|
context.store.append(
|
||||||
|
makePrivateMessage(id: "pm-mid", status: .sending, timestamp: Date(timeIntervalSince1970: 20)),
|
||||||
|
to: conversationID
|
||||||
|
)
|
||||||
|
let chat = context.store.conversation(for: conversationID)
|
||||||
|
#expect(chat.messages.map(\.id) == ["pm-a", "pm-mid", "pm-b"])
|
||||||
|
|
||||||
#expect(coordinator.updateMessageDeliveryStatus("pm-mid", status: .sent))
|
#expect(coordinator.updateMessageDeliveryStatus("pm-mid", status: .sent))
|
||||||
#expect(isSent(context.privateChats[peerID]?[1].deliveryStatus))
|
#expect(isSent(chat.message(withID: "pm-mid")?.deliveryStatus))
|
||||||
#expect(coordinator.updateMessageDeliveryStatus("pm-b", status: .sent))
|
#expect(coordinator.updateMessageDeliveryStatus("pm-b", status: .sent))
|
||||||
#expect(isSent(context.privateChats[peerID]?[2].deliveryStatus))
|
#expect(isSent(chat.message(withID: "pm-b")?.deliveryStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func mirroredPrivateCopies_bothReceiveDeliveryUpdate() async {
|
||||||
|
let context = MockChatDeliveryContext()
|
||||||
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
|
let stablePeerID = PeerID(str: String(repeating: "ab", count: 32))
|
||||||
|
let ephemeralPeerID = PeerID(str: "0102030405060708")
|
||||||
|
let messageID = "mirrored-mock-1"
|
||||||
|
|
||||||
|
// Step 2's keying mirrors a private message into both the stable-key
|
||||||
|
// and ephemeral-peer conversations (distinct copies here to prove
|
||||||
|
// per-conversation application, not shared-reference aliasing).
|
||||||
|
context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(stablePeerID))
|
||||||
|
context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(ephemeralPeerID))
|
||||||
|
|
||||||
|
let didUpdate = coordinator.updateMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: "Peer", at: Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(didUpdate)
|
||||||
|
#expect(isDelivered(context.store.conversation(for: .directPeer(stablePeerID)).message(withID: messageID)?.deliveryStatus))
|
||||||
|
#expect(isDelivered(context.store.conversation(for: .directPeer(ephemeralPeerID)).message(withID: messageID)?.deliveryStatus))
|
||||||
|
#expect(context.markedDeliveredMessageIDs == [messageID])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -566,7 +623,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
let context = MockChatDeliveryContext()
|
let context = MockChatDeliveryContext()
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
context.privateChats[peerID] = [makePrivateMessage(id: "keep-receipt", status: .sent)]
|
context.store.append(makePrivateMessage(id: "keep-receipt", status: .sent), to: .directPeer(peerID))
|
||||||
context.sentReadReceipts = ["keep-receipt", "drop-receipt"]
|
context.sentReadReceipts = ["keep-receipt", "drop-receipt"]
|
||||||
|
|
||||||
// Startup phase: cleanup must be a no-op.
|
// Startup phase: cleanup must be a no-op.
|
||||||
|
|||||||
@@ -562,4 +562,159 @@ struct ConversationStoreTests {
|
|||||||
#expect(!conversation.containsMessage(withID: "one"))
|
#expect(!conversation.containsMessage(withID: "one"))
|
||||||
#expect(store.append(makeMessage(id: "one", timestamp: 2000), to: id))
|
#expect(store.append(makeMessage(id: "one", timestamp: 2000), to: id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Store-level message-ID → conversation map (ID-only delivery)
|
||||||
|
|
||||||
|
@Test("ID map tracks append, upsert, and removal")
|
||||||
|
@MainActor
|
||||||
|
func messageIDMapTracksAppendAndRemoval() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let direct = makeDirectConversationID("aa")
|
||||||
|
|
||||||
|
#expect(store.conversationIDs(forMessageID: "m1").isEmpty)
|
||||||
|
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)
|
||||||
|
#expect(store.conversationIDs(forMessageID: "m1") == [.mesh])
|
||||||
|
|
||||||
|
// Upsert-as-append registers; upsert-in-place does not duplicate.
|
||||||
|
store.upsertByID(makeMessage(id: "d1", timestamp: 1, isPrivate: true), in: direct)
|
||||||
|
store.upsertByID(makeMessage(id: "d1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: direct)
|
||||||
|
#expect(store.conversationIDs(forMessageID: "d1") == [direct])
|
||||||
|
|
||||||
|
store.removeMessage(withID: "m1", from: .mesh)
|
||||||
|
#expect(store.conversationIDs(forMessageID: "m1").isEmpty)
|
||||||
|
|
||||||
|
store.removeMessages(from: direct, where: { $0.id == "d1" })
|
||||||
|
#expect(store.conversationIDs(forMessageID: "d1").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ID map handles multi-conversation membership (mirrored private copies)")
|
||||||
|
@MainActor
|
||||||
|
func messageIDMapHandlesMirroredCopies() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let stable = makeDirectConversationID("stable")
|
||||||
|
let ephemeral = makeDirectConversationID("ephemeral")
|
||||||
|
|
||||||
|
// Step 2's keying mirrors one private message into the stable-key
|
||||||
|
// AND ephemeral-peer conversations (distinct copies here to prove
|
||||||
|
// per-conversation bookkeeping).
|
||||||
|
store.upsertByID(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: stable)
|
||||||
|
store.upsertByID(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: ephemeral)
|
||||||
|
#expect(store.conversationIDs(forMessageID: "dm-1") == [stable, ephemeral])
|
||||||
|
|
||||||
|
// An ID-only delivery update reaches BOTH copies.
|
||||||
|
#expect(store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1"))
|
||||||
|
for id in [stable, ephemeral] {
|
||||||
|
guard case .delivered = store.conversation(for: id).message(withID: "dm-1")?.deliveryStatus else {
|
||||||
|
Issue.record("expected .delivered in \(id)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing one copy keeps the other resolvable.
|
||||||
|
store.removeMessage(withID: "dm-1", from: ephemeral)
|
||||||
|
#expect(store.conversationIDs(forMessageID: "dm-1") == [stable])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ID-only setDeliveryStatus enforces no-downgrade and unknown IDs")
|
||||||
|
@MainActor
|
||||||
|
func idOnlyDeliveryStatusRules() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let direct = makeDirectConversationID("aa")
|
||||||
|
store.append(
|
||||||
|
makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .read(by: "bob", at: Date())),
|
||||||
|
to: direct
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unknown message.
|
||||||
|
#expect(!store.setDeliveryStatus(.sent, forMessageID: "missing"))
|
||||||
|
#expect(store.deliveryStatus(forMessageID: "missing") == nil)
|
||||||
|
|
||||||
|
// Downgrade from .read is refused; status is readable by ID alone.
|
||||||
|
#expect(!store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1"))
|
||||||
|
guard case .read = store.deliveryStatus(forMessageID: "dm-1") else {
|
||||||
|
Issue.record("expected .read to survive the downgrade attempt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ID map follows migration between conversations")
|
||||||
|
@MainActor
|
||||||
|
func messageIDMapFollowsMigration() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let source = makeDirectConversationID("ephemeral")
|
||||||
|
let destination = makeDirectConversationID("stable")
|
||||||
|
store.append(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true), to: source)
|
||||||
|
store.append(makeMessage(id: "dm-2", timestamp: 2, isPrivate: true), to: source)
|
||||||
|
// Already present in the destination: migration dedups, and the map
|
||||||
|
// must not retain a stale source membership.
|
||||||
|
store.append(makeMessage(id: "dm-2", timestamp: 2, isPrivate: true), to: destination)
|
||||||
|
|
||||||
|
store.migrateConversation(from: source, to: destination)
|
||||||
|
|
||||||
|
#expect(store.conversationIDs(forMessageID: "dm-1") == [destination])
|
||||||
|
#expect(store.conversationIDs(forMessageID: "dm-2") == [destination])
|
||||||
|
// Delivery updates keep flowing after the handoff.
|
||||||
|
#expect(store.setDeliveryStatus(.sent, forMessageID: "dm-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ID map drops trimmed messages")
|
||||||
|
@MainActor
|
||||||
|
func messageIDMapDropsTrimmedMessages() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let id = ConversationID.geohash("u4pruyd")
|
||||||
|
let conversation = store.conversation(for: id)
|
||||||
|
store.append(makeMessage(id: "first", timestamp: 1), to: id)
|
||||||
|
for index in 0..<conversation.cap {
|
||||||
|
store.append(makeMessage(id: "filler-\(index)", timestamp: 2 + TimeInterval(index)), to: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "first" fell off the cap: the map must forget it.
|
||||||
|
#expect(store.conversationIDs(forMessageID: "first").isEmpty)
|
||||||
|
#expect(!store.setDeliveryStatus(.sent, forMessageID: "first"))
|
||||||
|
// Survivors stay resolvable.
|
||||||
|
#expect(store.conversationIDs(forMessageID: "filler-0") == [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ID map is emptied by clear, removeConversation, and clearAll")
|
||||||
|
@MainActor
|
||||||
|
func messageIDMapClearedWithConversations() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let direct = makeDirectConversationID("aa")
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)
|
||||||
|
store.append(makeMessage(id: "d1", timestamp: 1, isPrivate: true), to: direct)
|
||||||
|
|
||||||
|
store.clear(.mesh)
|
||||||
|
#expect(store.conversationIDs(forMessageID: "m1").isEmpty)
|
||||||
|
#expect(store.conversationIDs(forMessageID: "d1") == [direct])
|
||||||
|
|
||||||
|
store.removeConversation(direct)
|
||||||
|
#expect(store.conversationIDs(forMessageID: "d1").isEmpty)
|
||||||
|
|
||||||
|
store.append(makeMessage(id: "m2", timestamp: 2), to: .mesh)
|
||||||
|
store.clearAll()
|
||||||
|
#expect(store.conversationIDs(forMessageID: "m2").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("shared message instance across conversations stays consistent")
|
||||||
|
@MainActor
|
||||||
|
func sharedInstanceMirroredCopiesStayConsistent() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let stable = makeDirectConversationID("stable")
|
||||||
|
let ephemeral = makeDirectConversationID("ephemeral")
|
||||||
|
// The production mirroring path upserts the SAME BitchatMessage
|
||||||
|
// instance (reference type) into both conversations.
|
||||||
|
let message = makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent)
|
||||||
|
store.upsertByID(message, in: stable)
|
||||||
|
store.upsertByID(message, in: ephemeral)
|
||||||
|
|
||||||
|
#expect(store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1"))
|
||||||
|
|
||||||
|
for id in [stable, ephemeral] {
|
||||||
|
guard case .delivered = store.conversation(for: id).message(withID: "dm-1")?.deliveryStatus else {
|
||||||
|
Issue.record("expected .delivered in \(id)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,26 +194,18 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("gcs.buildAndDecode", samples: samples, operations: repsPerPass, unit: "filters")
|
reportThroughput("gcs.buildAndDecode", samples: samples, operations: repsPerPass, unit: "filters")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 4a. Delivery location index (incremental path)
|
// MARK: - 4a. Delivery status updates through the coordinator (store path)
|
||||||
|
|
||||||
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` against a warm
|
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` over the
|
||||||
/// location index: 2000 public + 50x40 private messages, 500 status
|
/// `ConversationStore`'s message-ID → conversation map: 2000 public
|
||||||
/// updates per pass. Statuses alternate sent <-> delivered so every call
|
/// (split mesh + geohash to stay under the per-conversation cap) + 50x40
|
||||||
/// performs a real update (never the skip path).
|
/// private messages, 500 status updates per pass. Statuses alternate
|
||||||
|
/// sent <-> delivered so every call performs a real update (never the
|
||||||
|
/// skip path).
|
||||||
func testDeliveryStatusIncrementalUpdates() {
|
func testDeliveryStatusIncrementalUpdates() {
|
||||||
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
// Warm the index so the measure block exercises the incremental path.
|
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
||||||
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
|
|
||||||
|
|
||||||
// 250 public + 250 private IDs spread across the corpus.
|
|
||||||
var targetIDs: [String] = []
|
|
||||||
for i in stride(from: 0, to: 2000, by: 8) { targetIDs.append(context.messages[i].id) }
|
|
||||||
let peerIDs = context.privateChats.keys.sorted { $0.id < $1.id }
|
|
||||||
for (offset, peer) in peerIDs.enumerated() where offset < 25 {
|
|
||||||
guard let chat = context.privateChats[peer] else { continue }
|
|
||||||
for i in stride(from: 0, to: chat.count, by: 4) { targetIDs.append(chat[i].id) }
|
|
||||||
}
|
|
||||||
XCTAssertEqual(targetIDs.count, 500)
|
XCTAssertEqual(targetIDs.count, 500)
|
||||||
|
|
||||||
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
@@ -235,46 +227,38 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
|
reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 4b. Delivery location index (rebuild path)
|
// MARK: - 4b. Delivery status updates against the store directly
|
||||||
|
|
||||||
/// The expensive path: a non-append insertion (out-of-order arrival at
|
/// `ConversationStore.setDeliveryStatus(_:forMessageID:)` at the same
|
||||||
/// the head of the timeline) invalidates the index, so the next status
|
/// scale as 4a, without the coordinator/context wrapping — the store-side
|
||||||
/// update triggers a full rebuild over ~4000 message locations.
|
/// cost of an ID-only delivery update (map lookup + per-conversation
|
||||||
func testDeliveryStatusIndexRebuild() {
|
/// ID-index apply + change emission). Replaces the deleted
|
||||||
|
/// `delivery.indexRebuild` benchmark: the positional location index and
|
||||||
|
/// its rebuild path no longer exist; the store's ID indexes are
|
||||||
|
/// maintained inside each mutation, so there is no rebuild to measure.
|
||||||
|
func testDeliveryStatusStoreUpdates() {
|
||||||
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let store = context.store
|
||||||
// Warm the index before the first insertion invalidates it.
|
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
||||||
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
|
XCTAssertEqual(targetIDs.count, 500)
|
||||||
|
|
||||||
// Pre-built head insertions, one consumed per measure pass.
|
|
||||||
let insertions: [BitchatMessage] = (0..<32).map { i in
|
|
||||||
BitchatMessage(
|
|
||||||
id: "insert-\(i)",
|
|
||||||
sender: "alice",
|
|
||||||
content: "out-of-order arrival \(i)",
|
|
||||||
timestamp: Date(timeIntervalSince1970: 1_690_000_000),
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
let probeID = context.messages[1000].id
|
|
||||||
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
var pass = 0
|
|
||||||
var toggle = false
|
var toggle = false
|
||||||
var samples: [TimeInterval] = []
|
var samples: [TimeInterval] = []
|
||||||
|
|
||||||
measure {
|
measure {
|
||||||
precondition(pass < insertions.count, "add more pre-built insertions")
|
|
||||||
context.messages.insert(insertions[pass], at: 0)
|
|
||||||
pass += 1
|
|
||||||
toggle.toggle()
|
toggle.toggle()
|
||||||
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
||||||
let start = Date()
|
let start = Date()
|
||||||
let updated = coordinator.updateMessageDeliveryStatus(probeID, status: status)
|
var updated = 0
|
||||||
|
for id in targetIDs where store.setDeliveryStatus(status, forMessageID: id) {
|
||||||
|
updated += 1
|
||||||
|
}
|
||||||
samples.append(Date().timeIntervalSince(start))
|
samples.append(Date().timeIntervalSince(start))
|
||||||
XCTAssertTrue(updated)
|
XCTAssertEqual(updated, targetIDs.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
reportThroughput("delivery.indexRebuild", samples: samples, operations: 1, unit: "rebuilds")
|
reportThroughput("delivery.storeUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 5. Message formatting
|
// MARK: - 5. Message formatting
|
||||||
@@ -638,50 +622,63 @@ private final class PerfNostrContext: ChatNostrContext {
|
|||||||
|
|
||||||
// MARK: - Mock ChatDeliveryContext
|
// MARK: - Mock ChatDeliveryContext
|
||||||
|
|
||||||
|
/// Minimal `ChatDeliveryContext` over a real `ConversationStore` (the
|
||||||
|
/// coordinator is a thin mapper onto store intents, so the measured cost is
|
||||||
|
/// the store's ID-map delivery path).
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class PerfDeliveryContext: ChatDeliveryContext {
|
private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||||
var messages: [BitchatMessage] = []
|
let store = ConversationStore()
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
||||||
var sentReadReceipts: Set<String> = []
|
var sentReadReceipts: Set<String> = []
|
||||||
var isStartupPhase: Bool { false }
|
var isStartupPhase: Bool { false }
|
||||||
|
private var publicIDs: [String] = []
|
||||||
|
private var privateIDsByPeer: [(peerID: PeerID, messageIDs: [String])] = []
|
||||||
|
|
||||||
func notifyUIChanged() {}
|
func notifyUIChanged() {}
|
||||||
func markMessageDelivered(_ messageID: String) {}
|
func markMessageDelivered(_ messageID: String) {}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
|
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||||
|
store.deliveryStatus(forMessageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateMessageIDs() -> Set<String> {
|
||||||
|
store.directMessageIDs()
|
||||||
|
}
|
||||||
|
|
||||||
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
||||||
let oldCount = sentReadReceipts.count
|
let oldCount = sentReadReceipts.count
|
||||||
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||||
return oldCount - sentReadReceipts.count
|
return oldCount - sentReadReceipts.count
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
/// `publicCount` public messages (split evenly between mesh and one
|
||||||
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
|
/// geohash conversation so the corpus stays under the per-conversation
|
||||||
guard var chat = privateChats[peerID],
|
/// cap) + `peerCount` x `messagesPerPeer` private messages, seeded into
|
||||||
let index = chat.firstIndex(where: { $0.id == messageID }) else {
|
/// the store with deterministic IDs and timestamps.
|
||||||
return false
|
|
||||||
}
|
|
||||||
chat[index].deliveryStatus = status
|
|
||||||
privateChats[peerID] = chat
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 2000 public + `peerCount` x `messagesPerPeer` private messages with
|
|
||||||
/// deterministic IDs and timestamps.
|
|
||||||
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
|
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
|
||||||
let context = PerfDeliveryContext()
|
let context = PerfDeliveryContext()
|
||||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
context.messages = (0..<publicCount).map { i in
|
let geohashID = ConversationID.geohash("u4pruyd")
|
||||||
BitchatMessage(
|
for i in 0..<publicCount {
|
||||||
|
let message = BitchatMessage(
|
||||||
id: "pub-\(i)",
|
id: "pub-\(i)",
|
||||||
sender: "peer\(i % 20)",
|
sender: "peer\(i % 20)",
|
||||||
content: "public message number \(i)",
|
content: "public message number \(i)",
|
||||||
timestamp: base.addingTimeInterval(Double(i)),
|
timestamp: base.addingTimeInterval(Double(i)),
|
||||||
isRelay: false
|
isRelay: false
|
||||||
)
|
)
|
||||||
|
context.store.append(message, to: i % 2 == 0 ? .mesh : geohashID)
|
||||||
|
context.publicIDs.append(message.id)
|
||||||
}
|
}
|
||||||
for p in 0..<peerCount {
|
for p in 0..<peerCount {
|
||||||
let peerID = PeerID(str: String(format: "%016x", 0xA000_0000 + p))
|
let peerID = PeerID(str: String(format: "%016x", 0xA000_0000 + p))
|
||||||
context.privateChats[peerID] = (0..<messagesPerPeer).map { i in
|
var messageIDs: [String] = []
|
||||||
BitchatMessage(
|
for i in 0..<messagesPerPeer {
|
||||||
|
let message = BitchatMessage(
|
||||||
id: "dm-\(p)-\(i)",
|
id: "dm-\(p)-\(i)",
|
||||||
sender: "peer\(p)",
|
sender: "peer\(p)",
|
||||||
content: "private message \(i) for peer \(p)",
|
content: "private message \(i) for peer \(p)",
|
||||||
@@ -691,10 +688,32 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
|||||||
recipientNickname: "me",
|
recipientNickname: "me",
|
||||||
senderPeerID: peerID
|
senderPeerID: peerID
|
||||||
)
|
)
|
||||||
|
context.store.append(message, to: .directPeer(peerID))
|
||||||
|
messageIDs.append(message.id)
|
||||||
}
|
}
|
||||||
|
context.privateIDsByPeer.append((peerID, messageIDs))
|
||||||
}
|
}
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deterministic update targets spread across the corpus: `publicTargets`
|
||||||
|
/// public IDs plus `privateTargets` private IDs.
|
||||||
|
func makeTargetIDs(publicTargets: Int, privateTargets: Int) -> [String] {
|
||||||
|
var targetIDs: [String] = []
|
||||||
|
let publicStride = max(1, publicIDs.count / publicTargets)
|
||||||
|
for i in stride(from: 0, to: publicIDs.count, by: publicStride) where targetIDs.count < publicTargets {
|
||||||
|
targetIDs.append(publicIDs[i])
|
||||||
|
}
|
||||||
|
var privateCount = 0
|
||||||
|
outer: for (_, messageIDs) in privateIDsByPeer {
|
||||||
|
for i in stride(from: 0, to: messageIDs.count, by: 4) {
|
||||||
|
guard privateCount < privateTargets else { break outer }
|
||||||
|
targetIDs.append(messageIDs[i])
|
||||||
|
privateCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targetIDs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - End-to-end pipeline fixture
|
// MARK: - End-to-end pipeline fixture
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# Conversation Store: Single Source of Truth
|
# Conversation Store: Single Source of Truth
|
||||||
|
|
||||||
**Status:** Steps 1–3 implemented (additive store, private cutover, public
|
**Status:** Steps 1–4 implemented (additive store, private cutover, public
|
||||||
cutover; `PublicTimelineStore` deleted). Baselines recorded in
|
cutover, delivery via store; `PublicTimelineStore` and
|
||||||
|
`ChatDeliveryCoordinator.messageLocationIndex` deleted). Baselines recorded in
|
||||||
`bitchatTests/Performance/PerformanceBaselineTests.swift` (`pipeline.privateIngest`,
|
`bitchatTests/Performance/PerformanceBaselineTests.swift` (`pipeline.privateIngest`,
|
||||||
`pipeline.publicIngest`, `store.append`).
|
`pipeline.publicIngest`, `store.append`, `delivery.incrementalUpdate`,
|
||||||
|
`delivery.storeUpdate`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user