mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +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:
@@ -304,7 +304,7 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_indexRefreshesAfterPrivateChatReorder() async {
|
||||
func deliveryStatus_survivesPrivateChatReorder() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "reordered-msg-1"
|
||||
@@ -335,8 +335,8 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
#expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID)))
|
||||
|
||||
// A late arrival with an older timestamp is inserted before the
|
||||
// target by the store, shifting its position and invalidating the
|
||||
// delivery coordinator's location index.
|
||||
// target by the store, shifting its position; the store's ID-keyed
|
||||
// indexes must keep the target updatable.
|
||||
viewModel.seedPrivateChat([olderMessage], for: peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.map(\.id) == ["older-msg", messageID])
|
||||
|
||||
@@ -383,16 +383,31 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
// MARK: - Mock Delivery Context
|
||||
|
||||
/// 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
|
||||
private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||
var messages: [BitchatMessage] = []
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
let store = ConversationStore()
|
||||
var sentReadReceipts: Set<String> = []
|
||||
var isStartupPhase = false
|
||||
private(set) var notifyUIChangedCount = 0
|
||||
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 {
|
||||
let oldCount = sentReadReceipts.count
|
||||
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||
@@ -406,29 +421,19 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||
func markMessageDelivered(_ messageID: String) {
|
||||
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
|
||||
private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMessage {
|
||||
private func makePrivateMessage(
|
||||
id: String,
|
||||
status: DeliveryStatus,
|
||||
timestamp: Date = Date()
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: "me",
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
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
|
||||
|
||||
/// 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 {
|
||||
|
||||
@Test @MainActor
|
||||
@@ -449,7 +472,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
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(
|
||||
messageID,
|
||||
@@ -457,7 +480,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
)
|
||||
|
||||
#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.markedDeliveredMessageIDs == [messageID])
|
||||
}
|
||||
@@ -468,13 +491,16 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
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(
|
||||
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.markedDeliveredMessageIDs == [messageID])
|
||||
}
|
||||
@@ -483,22 +509,12 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
context.messages = [
|
||||
BitchatMessage(
|
||||
id: "public-mock-1",
|
||||
sender: "me",
|
||||
content: "Public message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
]
|
||||
context.store.append(makePublicMessage(id: "public-mock-1", status: .sending), to: .mesh)
|
||||
|
||||
// .sent is not a confirmed receipt — must not reach markMessageDelivered.
|
||||
let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent)
|
||||
#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.notifyUIChangedCount == 1)
|
||||
|
||||
@@ -508,57 +524,98 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
#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
|
||||
func middleInsertedMessage_isFoundAfterIndexWasBuilt() async {
|
||||
func middleInsertedMessage_isStillUpdatableByID() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let makePublic = { (id: String) in
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: "me",
|
||||
content: "Public message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
}
|
||||
context.messages = [makePublic("public-a"), makePublic("public-b")]
|
||||
context.store.append(
|
||||
makePublicMessage(id: "public-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)),
|
||||
to: .mesh
|
||||
)
|
||||
context.store.append(
|
||||
makePublicMessage(id: "public-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)),
|
||||
to: .mesh
|
||||
)
|
||||
|
||||
// Build the incremental index.
|
||||
#expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent))
|
||||
|
||||
// Out-of-order arrival: PublicMessagePipeline inserts by timestamp,
|
||||
// so the count grows while the tail ID stays the same.
|
||||
context.messages.insert(makePublic("public-mid"), at: 1)
|
||||
// Out-of-order arrival: the store inserts by timestamp, shifting the
|
||||
// tail's position.
|
||||
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
|
||||
// not be updated through a stale index entry.
|
||||
// Both the inserted message and the shifted tail stay updatable.
|
||||
#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(isSent(context.messages[2].deliveryStatus))
|
||||
#expect(isSent(mesh.message(withID: "public-b")?.deliveryStatus))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func middleInsertedPrivateMessage_isFoundAfterIndexWasBuilt() async {
|
||||
func middleInsertedPrivateMessage_isStillUpdatableByID() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
context.privateChats[peerID] = [
|
||||
makePrivateMessage(id: "pm-a", status: .sending),
|
||||
makePrivateMessage(id: "pm-b", status: .sending)
|
||||
]
|
||||
let conversationID = ConversationID.directPeer(peerID)
|
||||
context.store.append(
|
||||
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))
|
||||
|
||||
// Timestamp re-sort (sanitizeChat) can place a late arrival mid-array.
|
||||
context.privateChats[peerID]?.insert(makePrivateMessage(id: "pm-mid", status: .sending), at: 1)
|
||||
// A late arrival with an older timestamp lands mid-array.
|
||||
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(isSent(context.privateChats[peerID]?[1].deliveryStatus))
|
||||
#expect(isSent(chat.message(withID: "pm-mid")?.deliveryStatus))
|
||||
#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
|
||||
@@ -566,7 +623,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
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"]
|
||||
|
||||
// Startup phase: cleanup must be a no-op.
|
||||
|
||||
@@ -562,4 +562,159 @@ struct ConversationStoreTests {
|
||||
#expect(!conversation.containsMessage(withID: "one"))
|
||||
#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")
|
||||
}
|
||||
|
||||
// MARK: - 4a. Delivery location index (incremental path)
|
||||
// MARK: - 4a. Delivery status updates through the coordinator (store path)
|
||||
|
||||
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` against a warm
|
||||
/// location index: 2000 public + 50x40 private messages, 500 status
|
||||
/// updates per pass. Statuses alternate sent <-> delivered so every call
|
||||
/// performs a real update (never the skip path).
|
||||
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` over the
|
||||
/// `ConversationStore`'s message-ID → conversation map: 2000 public
|
||||
/// (split mesh + geohash to stay under the per-conversation cap) + 50x40
|
||||
/// private messages, 500 status updates per pass. Statuses alternate
|
||||
/// sent <-> delivered so every call performs a real update (never the
|
||||
/// skip path).
|
||||
func testDeliveryStatusIncrementalUpdates() {
|
||||
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
// Warm the index so the measure block exercises the incremental path.
|
||||
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) }
|
||||
}
|
||||
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
||||
XCTAssertEqual(targetIDs.count, 500)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// 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
|
||||
/// the head of the timeline) invalidates the index, so the next status
|
||||
/// update triggers a full rebuild over ~4000 message locations.
|
||||
func testDeliveryStatusIndexRebuild() {
|
||||
/// `ConversationStore.setDeliveryStatus(_:forMessageID:)` at the same
|
||||
/// scale as 4a, without the coordinator/context wrapping — the store-side
|
||||
/// cost of an ID-only delivery update (map lookup + per-conversation
|
||||
/// 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 coordinator = ChatDeliveryCoordinator(context: context)
|
||||
// Warm the index before the first insertion invalidates it.
|
||||
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
|
||||
let store = context.store
|
||||
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
||||
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)
|
||||
var pass = 0
|
||||
var toggle = false
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
precondition(pass < insertions.count, "add more pre-built insertions")
|
||||
context.messages.insert(insertions[pass], at: 0)
|
||||
pass += 1
|
||||
toggle.toggle()
|
||||
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
||||
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))
|
||||
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
|
||||
@@ -638,50 +622,63 @@ private final class PerfNostrContext: ChatNostrContext {
|
||||
|
||||
// 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
|
||||
private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||
var messages: [BitchatMessage] = []
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
let store = ConversationStore()
|
||||
var sentReadReceipts: Set<String> = []
|
||||
var isStartupPhase: Bool { false }
|
||||
private var publicIDs: [String] = []
|
||||
private var privateIDsByPeer: [(peerID: PeerID, messageIDs: [String])] = []
|
||||
|
||||
func notifyUIChanged() {}
|
||||
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 {
|
||||
let oldCount = sentReadReceipts.count
|
||||
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||
return oldCount - sentReadReceipts.count
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
chat[index].deliveryStatus = status
|
||||
privateChats[peerID] = chat
|
||||
return true
|
||||
}
|
||||
|
||||
/// 2000 public + `peerCount` x `messagesPerPeer` private messages with
|
||||
/// deterministic IDs and timestamps.
|
||||
/// `publicCount` public messages (split evenly between mesh and one
|
||||
/// geohash conversation so the corpus stays under the per-conversation
|
||||
/// cap) + `peerCount` x `messagesPerPeer` private messages, seeded into
|
||||
/// the store with deterministic IDs and timestamps.
|
||||
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
|
||||
let context = PerfDeliveryContext()
|
||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
context.messages = (0..<publicCount).map { i in
|
||||
BitchatMessage(
|
||||
let geohashID = ConversationID.geohash("u4pruyd")
|
||||
for i in 0..<publicCount {
|
||||
let message = BitchatMessage(
|
||||
id: "pub-\(i)",
|
||||
sender: "peer\(i % 20)",
|
||||
content: "public message number \(i)",
|
||||
timestamp: base.addingTimeInterval(Double(i)),
|
||||
isRelay: false
|
||||
)
|
||||
context.store.append(message, to: i % 2 == 0 ? .mesh : geohashID)
|
||||
context.publicIDs.append(message.id)
|
||||
}
|
||||
for p in 0..<peerCount {
|
||||
let peerID = PeerID(str: String(format: "%016x", 0xA000_0000 + p))
|
||||
context.privateChats[peerID] = (0..<messagesPerPeer).map { i in
|
||||
BitchatMessage(
|
||||
var messageIDs: [String] = []
|
||||
for i in 0..<messagesPerPeer {
|
||||
let message = BitchatMessage(
|
||||
id: "dm-\(p)-\(i)",
|
||||
sender: "peer\(p)",
|
||||
content: "private message \(i) for peer \(p)",
|
||||
@@ -691,10 +688,32 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||
recipientNickname: "me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
context.store.append(message, to: .directPeer(peerID))
|
||||
messageIDs.append(message.id)
|
||||
}
|
||||
context.privateIDsByPeer.append((peerID, messageIDs))
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user