Skip to content

perf(chat): look up chats by id in O(1) in ConversationStore - #1234

Open
bmc08gt wants to merge 1 commit into
mainfrom
perf/chat-store-id-index
Open

bmc08gt wants to merge 1 commit into
mainfrom
perf/chat-store-id-index

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

Every notification-extension write triggers reloadFromDatabase, which calls setFeedPreview once per chat. Each call looked up its row with conversations.firstIndex(where:), so one reload scanned the chat list once per chat on the main actor.

ConversationStore now keeps an id-to-position map and rebuilds it after every insert, remove and reorder (setFeed, setFeed(_:type:), setGroupFeed, remove, sort). It is not a didSet, because element writes like conversations[i].lastMessage = … fire that too and would make every write O(n). These now go through the map:

  • every per-id firstIndex(where:) in the store
  • the keeping* merge helpers, which setFeed runs once per incoming row, so a feed load was also quadratic
  • the new conversation(withID:) and contains(_:), which the controller's conversation(withID:), displayName(forConversationID:) and membership checks now call. ActivityRow calls conversation(withID:) once per boost row, so the activity list paid one chat-list scan per row.

If the map is ever out of date, a lookup asserts in debug and falls back to a scan.

Numbers

These come from the chat-load benchmark harness (ChatLoadBenchmarkTests) on an iPhone 17 simulator (iOS 27.0), warm, with origin/main 641184f and this branch run back to back:

1000 chats 2000 chats
reloadSplit(): setFeedPreview via c.store 280.9 → 1.5 ms 1157.9 → 3.1 ms
reloadSlope(): reloadFromDatabase total 525.5 → 295.3 ms 1769.5 → 471.2 ms
reloadSlope(): refresh per chat 0.554 → 0.250 ms 0.960 → 0.232 ms

The machine was heavily loaded (load average about 100), and absolute times moved between runs. An earlier origin/main run measured 1444.8 ms and 4923.1 ms for reloadFromDatabase at 1000 and 2000 chats. The comparison that holds is the back-to-back pair above. The per-chat cost is now flat, so the reload is linear in chat count.

What's left is the per-chat DB reads, latestMessage and newestMessageID: 216.7 ms at 1000 chats and 467.6 ms at 2000, in the same run.

Not done: refresh only the chats the extension touched

That needs to know which chats the extension wrote, and that isn't cheap to know today:

  • The Darwin notification the extension posts carries no payload, and repeated posts coalesce.
  • The extension writes message rows plus the catch-up cursor, and it only advances the cursor when a delta completes. Diffing cursors finds some of the touched chats, not all of them.
  • A per-chat "written by extension" marker would work, but it's a schema change: a schemaVersion bump, and the store gets rebuilt.

With the lookup fixed, that marker is the next step for this path.

Tests: ConversationStoreLookupTests covers lookups after an insert, a remove, an advanceLastActivity reorder, a setFeed replace, and a setGroupFeed that drops a group the user left. Each test writes through the store after rows have moved and reads back by id, so an out-of-date map would put the preview on the wrong row.

reloadFromDatabase runs after every notification-extension write and
calls setFeedPreview once per chat. Each call found its row with
conversations.firstIndex(where:), so one reload scanned the chat list
once per chat on the main actor.

ConversationStore now keeps an id-to-position map, rebuilt after every
insert, remove and reorder. A didSet would also fire on element writes
and make each of those O(n). The store's per-id scans, the keeping*
merge helpers that setFeed runs per row, and the controller's
conversation(withID:) and membership checks use it. A lookup that finds
the map out of date asserts in debug and falls back to a scan.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant