perf(chat): look up chat txids through a revision-keyed index

The History tab copies every chat txid into a fresh set each frame to
badge and filter chat transactions: M string allocations for M chat
messages. ChatStore gains hasChatTxid(), an O(1) lookup in a txid index
that is rebuilt only after the store changed (append, delivery update,
erase, clear) and never built for an empty store. clear() also drops
the index, so no chat txids linger after a lock. The History tab moves
to it in the next commit.

The chat note-buffer pump, which runs every frame while chat is idle,
built the whole conversation-id list just to see whether the store was
empty; it now asks store().empty(), O(1) instead of O(M).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 09:50:03 -05:00
co-authored by Claude Opus 5.5
parent 860916d203
commit d3fa09128e
4 changed files with 109 additions and 2 deletions
+1 -1
View File
@@ -4854,7 +4854,7 @@ bool App::shouldSplitChatBuffer() {
// depleted, funded. NB: a wall-clock cooldown is deliberately NOT used — a split's outputs stay // depleted, funded. NB: a wall-clock cooldown is deliberately NOT used — a split's outputs stay
// invisible until mined (~1 block, far longer than any UI cooldown), so we gate on a single-split- // invisible until mined (~1 block, far longer than any UI cooldown), so we gate on a single-split-
// in-flight flag (cleared when the pipeline recovers or the watchdog fires) to prevent runaway splits. // in-flight flag (cleared when the pipeline recovers or the watchdog fires) to prevent runaway splits.
if (chat_service_.store().conversationIds().empty() && chat_send_queue_.empty()) return false; if (chat_service_.store().empty() && chat_send_queue_.empty()) return false; // == conversationIds().empty(), O(1)
if (!state_.connected || state_.isLocked()) return false; if (!state_.connected || state_.isLocked()) return false;
// Channel busy = our own op inflight, OR a user send holds the shared channel. Lite user sends set // Channel busy = our own op inflight, OR a user send holds the shared channel. Lite user sends set
// lite_send_callback_; full-node user sends don't touch inflight_op_, so they're detected via the // lite_send_callback_; full-node user sends don't touch inflight_op_, so they're detected via the
+1
View File
@@ -90,6 +90,7 @@ std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& convers
void ChatStore::clear() { void ChatStore::clear() {
messages_.clear(); messages_.clear();
seen_.clear(); seen_.clear();
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
++revision_; ++revision_;
} }
+22 -1
View File
@@ -38,7 +38,7 @@ public:
std::vector<std::string> conversationIds() const; std::vector<std::string> conversationIds() const;
// The set of on-chain txids that carried a chat message (sent or received, messages + contact // The set of on-chain txids that carried a chat message (sent or received, messages + contact
// requests) — used by the History tab to badge / filter chat transactions. O(messages), no copies. // requests). Builds a fresh COPY every call (O(messages) allocations) — prefer hasChatTxid() on hot paths.
std::unordered_set<std::string> chatTxids() const { std::unordered_set<std::string> chatTxids() const {
std::unordered_set<std::string> out; std::unordered_set<std::string> out;
out.reserve(messages_.size()); out.reserve(messages_.size());
@@ -47,6 +47,15 @@ public:
return out; return out;
} }
// True if an on-chain txid carried a chat message (History tab "Message" badge / Chat filter). Backed by a
// txid index rebuilt lazily when revision() moved (once per store mutation, O(messages)); O(1) on an empty
// store and per lookup. One txid may back several messages; a from-scratch rebuild handles erase correctly.
bool hasChatTxid(const std::string& txid) const {
if (messages_.empty() || txid.empty()) return false;
if (txid_index_rev_ != revision_) rebuildTxidIndex();
return txid_index_.count(txid) != 0;
}
// Remove every message in a conversation from the in-memory view and return the removed messages // Remove every message in a conversation from the in-memory view and return the removed messages
// (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest // (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest
// guard (blocked-cid predicate / DB tombstone), not here. // guard (blocked-cid predicate / DB tombstone), not here.
@@ -70,9 +79,21 @@ public:
private: private:
static std::string dedupKey(const ChatMessage& message); static std::string dedupKey(const ChatMessage& message);
void rebuildTxidIndex() const {
txid_index_.clear();
txid_index_.reserve(messages_.size());
for (const auto& m : messages_)
if (!m.txid.empty()) txid_index_.insert(m.txid);
txid_index_rev_ = revision_;
}
std::vector<ChatMessage> messages_; std::vector<ChatMessage> messages_;
std::unordered_set<std::string> seen_; std::unordered_set<std::string> seen_;
std::uint64_t revision_ = 0; std::uint64_t revision_ = 0;
// Lazy memo for hasChatTxid(): valid iff txid_index_rev_ == revision_. revision_ is monotonic and never reset,
// so append/updateDelivery/eraseConversation/clear all invalidate it (~0 = never built). Main thread only.
mutable std::unordered_set<std::string> txid_index_;
mutable std::uint64_t txid_index_rev_ = ~std::uint64_t{0};
}; };
} // namespace dragonx::chat } // namespace dragonx::chat
+85
View File
@@ -8473,6 +8473,90 @@ void testTransactionRefreshRevision()
EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(300)); EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(300));
} }
// 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 testChatStoreTxidIndex()
{
using namespace dragonx::chat;
ChatStore store;
auto mk = [](const std::string& txid, std::size_t pos, const std::string& cid) {
ChatMessage m;
m.txid = txid; m.payload_position = pos; m.conversation_id = cid; m.body = "b";
return m;
};
auto emptyMatches = [&store]() { return store.empty() == store.conversationIds().empty(); };
EXPECT_FALSE(store.hasChatTxid("t1"));
EXPECT_FALSE(store.hasChatTxid(""));
EXPECT_TRUE(emptyMatches());
EXPECT_TRUE(store.append(mk("t1", 0, "a")));
EXPECT_TRUE(emptyMatches());
EXPECT_TRUE(store.hasChatTxid("t1"));
EXPECT_FALSE(store.hasChatTxid("t2"));
// One txid backing two messages in two conversations: erasing one keeps the txid reported.
EXPECT_TRUE(store.append(mk("t1", 1, "b")));
EXPECT_TRUE(emptyMatches());
EXPECT_TRUE(store.hasChatTxid("t1"));
EXPECT_EQ(store.eraseConversation("a").size(), static_cast<size_t>(1));
EXPECT_TRUE(emptyMatches());
EXPECT_TRUE(store.hasChatTxid("t1"));
EXPECT_EQ(store.eraseConversation("b").size(), static_cast<size_t>(1));
EXPECT_TRUE(emptyMatches());
EXPECT_TRUE(store.empty());
EXPECT_FALSE(store.hasChatTxid("t1"));
// A lookup after the index was built still sees a later append (revision moved).
EXPECT_TRUE(store.append(mk("t3", 0, "c")));
EXPECT_TRUE(store.hasChatTxid("t3"));
EXPECT_TRUE(store.append(mk("t2", 0, "c")));
EXPECT_TRUE(emptyMatches());
EXPECT_TRUE(store.hasChatTxid("t2"));
const std::uint64_t revBeforeClear = store.revision();
store.clear();
EXPECT_TRUE(emptyMatches());
EXPECT_TRUE(store.revision() > revBeforeClear);
EXPECT_FALSE(store.hasChatTxid("t2"));
EXPECT_FALSE(store.hasChatTxid("t3"));
// An empty txid is never reported, even when a message carries one.
EXPECT_TRUE(store.append(mk("", 0, "d")));
EXPECT_TRUE(emptyMatches());
EXPECT_FALSE(store.hasChatTxid(""));
// An outgoing echo stays reported across a delivery change.
ChatMessage echo = mk("out:ab", 0, "e");
echo.direction = ChatDirection::Outgoing;
echo.delivery = ChatDelivery::Sending;
EXPECT_TRUE(store.append(echo));
EXPECT_TRUE(emptyMatches());
EXPECT_TRUE(store.hasChatTxid("out:ab"));
const std::uint64_t revBeforeUpdate = store.revision();
EXPECT_TRUE(store.updateDelivery("out:ab", ChatDelivery::Failed) != nullptr);
EXPECT_TRUE(store.revision() > revBeforeUpdate);
EXPECT_TRUE(store.hasChatTxid("out:ab"));
EXPECT_FALSE(store.hasChatTxid("out:zz"));
store.clear();
EXPECT_TRUE(store.empty());
EXPECT_TRUE(emptyMatches());
EXPECT_FALSE(store.hasChatTxid("out:ab"));
// Erasing a conversation while the store stays non-empty (so the empty-store early return can't hide a
// stale index): with the index already built, only the erased conversation's txid disappears.
EXPECT_TRUE(store.append(mk("t1", 0, "a")));
EXPECT_TRUE(store.append(mk("t2", 0, "c")));
EXPECT_TRUE(store.hasChatTxid("t1"));
EXPECT_TRUE(store.hasChatTxid("t2"));
EXPECT_EQ(store.eraseConversation("a").size(), static_cast<size_t>(1));
EXPECT_FALSE(store.empty());
EXPECT_TRUE(emptyMatches());
EXPECT_FALSE(store.hasChatTxid("t1"));
EXPECT_TRUE(store.hasChatTxid("t2"));
}
// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop // M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address // the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
// split, transaction typing, confirmations, and sync progress. // split, transaction typing, confirmations, and sync progress.
@@ -14545,6 +14629,7 @@ int main()
testHushChatService(); testHushChatService();
testHushChatDatabase(); testHushChatDatabase();
testHushChatDeleteConversation(); testHushChatDeleteConversation();
testChatStoreTxidIndex();
testHushChatOutgoing(); testHushChatOutgoing();
testHushChatTransport(); testHushChatTransport();
testHushChatShuffledReceive(); testHushChatShuffledReceive();