perf(chat): single-pass watermark and move-append on chat load

Loading chat history did more work than it needed to. Seeding the unread
watermark walked every conversation and copied each one's full message
list out of the store just to read the last timestamp, and the database
reload copied every message into the store one at a time with no
reservation.

- The watermark now comes from one pass over the stored messages that
  returns the latest timestamp per conversation, with no per-
  conversation copy or sort.
- The store gained a move-append and a reserve, so the reload pre-sizes
  its vector and dedup set once and moves each row in instead of copying
  it. The dedup key is computed before the move.

Behaviour is unchanged: the watermark values and the blocked-conversation
filter on reload are the same as before.

Tests cover the per-conversation latest-timestamp pass and move-append.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 19:24:12 -05:00
co-authored by Claude Opus 4.8
parent 2a535c844d
commit edbb98d9ec
5 changed files with 68 additions and 7 deletions
+2 -4
View File
@@ -4091,10 +4091,8 @@ void App::provisionChatIdentityFromSecret(std::string secret)
// Baseline unread: treat everything already in the store at load as read, so only messages
// that arrive while the app is open badge as unread (Q1).
const auto& store = chat_service_.store();
for (const auto& cid : store.conversationIds()) {
const auto msgs = store.conversation(cid);
if (!msgs.empty()) chat_seen_watermark_[cid] = msgs.back().timestamp;
}
for (const auto& [cid, ts] : store.latestTimestampByConversation())
chat_seen_watermark_[cid] = ts;
}
} else {
chat_identity_unavailable_ = true;
+5 -3
View File
@@ -119,11 +119,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
void ChatService::loadFromDatabase() {
if (!db_) return;
for (const auto& message : db_->load()) {
auto rows = db_->load();
store_.reserve(store_.size() + rows.size());
for (auto& message : rows) {
// Never surface a blocked conversation, even if a prior "delete & block" failed to remove its
// rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path.
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
store_.append(message);
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue; // evaluate before the move
store_.append(std::move(message));
}
}
+22
View File
@@ -21,6 +21,28 @@ bool ChatStore::append(const ChatMessage& message) {
return true;
}
bool ChatStore::append(ChatMessage&& message) {
if (!seen_.insert(dedupKey(message)).second) return false; // reads txid/payload_position before the move
messages_.push_back(std::move(message));
++revision_;
return true;
}
void ChatStore::reserve(std::size_t n) {
messages_.reserve(n);
seen_.reserve(n);
}
std::unordered_map<std::string, std::int64_t> ChatStore::latestTimestampByConversation() const {
std::unordered_map<std::string, std::int64_t> out;
for (const auto& message : messages_) {
auto it = out.find(message.conversation_id);
if (it == out.end()) out.emplace(message.conversation_id, static_cast<std::int64_t>(message.timestamp));
else if (static_cast<std::int64_t>(message.timestamp) > it->second) it->second = static_cast<std::int64_t>(message.timestamp);
}
return out;
}
std::vector<ChatMessage> ChatStore::conversation(const std::string& conversationId) const {
std::vector<ChatMessage> out;
for (const auto& message : messages_) {
+8
View File
@@ -8,6 +8,7 @@
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
@@ -19,6 +20,13 @@ class ChatStore {
public:
// Returns true if newly inserted, false if a duplicate was ignored.
bool append(const ChatMessage& message);
// Same, but moves the message into the store (dedup key is computed before the move).
bool append(ChatMessage&& message);
// Pre-size the message vector + dedup set for a bulk load of n additional messages.
void reserve(std::size_t n);
// Max timestamp per conversation in ONE pass over messages_. Semantically equal to
// conversation(cid).back().timestamp (conversation() sorts ascending) without the per-cid copy+sort.
std::unordered_map<std::string, std::int64_t> latestTimestampByConversation() const;
// The dedup key of a message: "<txid>:<payload_position>".
static std::string messageKey(const std::string& txid, std::size_t payloadPosition);
+31
View File
@@ -10899,6 +10899,36 @@ void testTransactionRefreshRevision()
// ChatStore::hasChatTxid (History "Message" badge / Chat filter): a lazy txid index keyed on the monotonic
// revision(), so every mutation (append / updateDelivery / eraseConversation / clear) is seen on the next lookup;
// clear() also drops it. store.empty() must equal conversationIds().empty() (shouldSplitChatBuffer relies on it).
void testChatStoreLatestAndMove()
{
using namespace dragonx::chat;
auto mk = [](const std::string& txid, std::size_t pos, const std::string& cid, int64_t ts) {
ChatMessage m;
m.txid = txid; m.payload_position = pos; m.conversation_id = cid; m.timestamp = ts; m.body = "body-" + txid;
return m;
};
ChatStore a, b;
a.reserve(8);
std::vector<ChatMessage> in{ mk("t1", 0, "x", 50), mk("t2", 0, "y", 10), mk("t3", 0, "x", 90),
mk("t4", 0, "y", 70), mk("t5", 0, "x", 20) };
for (const auto& m : in) EXPECT_TRUE(a.append(m));
for (auto m : in) EXPECT_TRUE(b.append(std::move(m)));
// dedup on txid:position via the move overload; moved-from message not stored twice
EXPECT_FALSE(b.append(mk("t1", 0, "x", 999)));
EXPECT_TRUE(b.append(mk("t1", 1, "x", 60)));
EXPECT_EQ(a.size(), static_cast<size_t>(5));
EXPECT_EQ(b.size(), static_cast<size_t>(6));
EXPECT_EQ(b.conversation("x")[0].body, std::string("body-t5"));
auto latest = a.latestTimestampByConversation();
EXPECT_EQ(latest.size(), static_cast<size_t>(2));
for (const auto& cid : a.conversationIds())
EXPECT_EQ(latest[cid], static_cast<int64_t>(a.conversation(cid).back().timestamp));
EXPECT_EQ(latest["x"], static_cast<int64_t>(90));
EXPECT_EQ(latest["y"], static_cast<int64_t>(70));
EXPECT_TRUE(ChatStore().latestTimestampByConversation().empty());
}
void testChatStoreTxidIndex()
{
using namespace dragonx::chat;
@@ -18535,6 +18565,7 @@ int main()
testHushChatDatabase();
testHushChatDeleteConversation();
testChatStoreTxidIndex();
testChatStoreLatestAndMove();
testHushChatOutgoing();
testHushChatTransport();
testHushChatShuffledReceive();