Skip to content

Commit fcdb55e

Browse files
thatSFguyclaude
andcommitted
fix(store): exempt contacts with message history from MED-2 eviction
On a busy TCP/transport attachment the destinations table churns past the 1000-row cap fast. Eviction keyed purely on lastSeen, so an un-favorited contact you were actively conversing with could be deleted mid-conversation — taking its public key with it — leaving the chat stuck on "(unknown sender)" and unable to send until the peer re-announced. Add `hash NOT IN (SELECT contactHash FROM messages)` to the eviction subquery on both platforms (Room DAO + SQLDelight .sq), alongside the existing favorite / userLabel / hidden exemptions. Message-history contacts are bounded by real conversations, so the table stays well under the CursorWindow limit the cap protects. Regression test added in StorageRoundTripTest (real SQLite via Robolectric). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 74388e1 commit fcdb55e

4 files changed

Lines changed: 61 additions & 3 deletions

File tree

androidApp/src/main/kotlin/io/github/thatsfguy/reticulum/android/storage/Daos.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ internal interface DestinationDao {
7171
* deleted) rows already don't count toward the visible list
7272
* but are excluded here too so the eviction doesn't churn
7373
* them. Audit reference: 2026-05-13 MED-2.
74+
*
75+
* Contacts with message history are also exempt
76+
* (`hash NOT IN (SELECT contactHash FROM messages)`): on a busy
77+
* TCP/transport attachment the table churns fast, and a contact
78+
* you're actively conversing with — but haven't favorited — was
79+
* being evicted out from under an open conversation, dropping its
80+
* public key so the chat reverted to "(unknown sender)" and
81+
* couldn't send until the peer re-announced. Like favorites, these
82+
* are real user state. They don't count toward [keepCount], so the
83+
* effective table size is (favorites + labeled + message-history) +
84+
* up to [keepCount] announce-only rows; message-history contacts
85+
* are bounded by actual conversations, so this stays well under the
86+
* CursorWindow limit the cap was lowered to protect. Set 2026-06-24.
7487
*/
7588
@Query("""
7689
DELETE FROM destinations
@@ -79,6 +92,7 @@ internal interface DestinationDao {
7992
WHERE favorite = 0
8093
AND hidden = 0
8194
AND (userLabel IS NULL OR userLabel = '')
95+
AND hash NOT IN (SELECT contactHash FROM messages)
8296
ORDER BY lastSeen DESC
8397
LIMIT -1 OFFSET :keepCount
8498
)

androidApp/src/test/kotlin/io/github/thatsfguy/reticulum/android/storage/StorageRoundTripTest.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,39 @@ class StorageRoundTripTest {
7474
assertTrue(ordered.any { it.hash == "starred" })
7575
}
7676

77+
/**
78+
* Regression (2026-06-24): MED-2 announce-flood eviction must NOT
79+
* delete a contact you have message history with, even when it's
80+
* the oldest-by-lastSeen and un-favorited. Before the
81+
* `hash NOT IN (SELECT contactHash FROM messages)` guard, a busy
82+
* TCP mesh could evict an active conversation's destination row,
83+
* dropping its public key so the chat reverted to "(unknown
84+
* sender)" and couldn't send until the peer re-announced.
85+
*/
86+
@Test fun evictionSparesContactsWithMessageHistory() = runTest {
87+
val dest = db.destinationDao()
88+
val msg = db.messageDao()
89+
// Oldest + un-favorited, but we've exchanged a message with it.
90+
dest.upsert(makeDestination(hash = "withmsg", lastSeen = 100, favorite = false))
91+
msg.insert(MessageEntity(
92+
contactHash = "withmsg", direction = "incoming", content = "hi",
93+
title = "", timestamp = 1L, state = "verified",
94+
attempts = 0, lastAttempt = 0, lastError = null,
95+
rawPacket = null, packetHash = null, rssi = null,
96+
))
97+
// Two newer announce-only rows with no message history.
98+
dest.upsert(makeDestination(hash = "noMsgOld", lastSeen = 101, favorite = false))
99+
dest.upsert(makeDestination(hash = "noMsgNew", lastSeen = 300, favorite = false))
100+
101+
// Keep only the newest 1 of the *evictable* (no-history) rows.
102+
dest.evictUnfavoritedOldest(keepCount = 1)
103+
104+
assertNotNull(dest.get("withmsg"),
105+
"contact with message history must survive eviction despite being oldest + unfavorited")
106+
assertNotNull(dest.get("noMsgNew"), "newest evictable row is kept")
107+
assertEquals(null, dest.get("noMsgOld"), "oldest evictable row with no history is evicted")
108+
}
109+
77110
@Test fun messageInsertAndPartialUpdate() = runTest {
78111
val dao = db.messageDao()
79112
val id = dao.insert(MessageEntity(

shared/src/commonMain/kotlin/io/github/thatsfguy/reticulum/engine/ReticulumEngine.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -750,9 +750,14 @@ class ReticulumEngine(
750750
* MED-2 announce-flood eviction. We let `destinationRepo` grow
751751
* up to [MAX_DESTINATIONS] non-favorited rows; past that
752752
* threshold each subsequent announce evicts the oldest excess
753-
* by `lastSeen` ASC. Favorited contacts and user-renamed entries
754-
* are exempt — those are deliberate state the user shouldn't
755-
* lose to flood pressure. Eviction is throttled to every
753+
* by `lastSeen` ASC. Favorited contacts, user-renamed entries,
754+
* and **any contact with message history** are exempt — those are
755+
* deliberate state the user shouldn't lose to flood pressure. (The
756+
* message-history exemption was added 2026-06-24: an un-favorited
757+
* contact you were actively chatting with on a busy TCP mesh could
758+
* be evicted mid-conversation, dropping its public key so the chat
759+
* reverted to "(unknown sender)" and couldn't send until the peer
760+
* re-announced.) Eviction is throttled to every
756761
* [EVICTION_INTERVAL_ANNOUNCES] new announces so a busy mesh
757762
* doesn't run the DELETE on every packet (Room/SQLDelight handle
758763
* the DELETE cheaply, but skipping it most of the time is

shared/src/commonMain/sqldelight/io/github/thatsfguy/reticulum/storage/ReticulumIosDatabase.sq

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,19 @@ DELETE FROM destinations;
116116
-- non-favorited, non-user-labeled, non-hidden rows past
117117
-- the keep count. Favorites and renamed entries are preserved
118118
-- regardless. Audit reference: 2026-05-13 MED-2.
119+
-- Contacts with message history are also exempt
120+
-- (hash NOT IN messages.contactHash): an un-favorited contact you're
121+
-- actively conversing with was being evicted on a busy mesh, dropping
122+
-- its public key so the chat reverted to "(unknown sender)" and
123+
-- couldn't send. Like favorites, that's real user state. Set 2026-06-24.
119124
evictUnfavoritedOldest:
120125
DELETE FROM destinations
121126
WHERE hash IN (
122127
SELECT hash FROM destinations
123128
WHERE favorite = 0
124129
AND hidden = 0
125130
AND (userLabel IS NULL OR userLabel = '')
131+
AND hash NOT IN (SELECT contactHash FROM messages)
126132
ORDER BY lastSeen DESC
127133
LIMIT -1 OFFSET ?
128134
);

0 commit comments

Comments
 (0)