fix: evict fully-aged-out geohashes from activity tracker

messageTimes/lastMessages kept an entry for every geohash ever seen —
prune() only emptied a geohash's timestamp array, never dropped the
dictionary entry itself. roaming across many regions over a long
session leaked one entry per distinct geohash for the life of the
process. sweep other geohashes on each recorded message and drop the
ones that have fully aged out.
This commit is contained in:
Taksh 2026-08-07 20:59:05 +05:30
parent 1f59e814f9
commit a5bb31e0fd
2 changed files with 54 additions and 0 deletions

View File

@ -41,6 +41,12 @@ final class GeohashChatActivityTracker: ObservableObject {
private var lastMessages: [String: GeohashChatPreview] = [:]
private let now: () -> Date
#if DEBUG
/// Number of distinct geohashes currently held in memory; exposed only
/// for regression tests verifying stale entries get evicted.
var _trackedGeohashCountForTesting: Int { messageTimes.count }
#endif
init(
window: TimeInterval = TransportConfig.uiGeohashChatActivityWindowSeconds,
now: @escaping () -> Date = { Date() }
@ -68,6 +74,15 @@ final class GeohashChatActivityTracker: ObservableObject {
} else {
lastMessages[gh] = GeohashChatPreview(senderName: senderName, content: content, timestamp: clamped)
}
// A geohash's timestamp array only ever gets pruned down to empty
// its dictionary entry (and the paired lastMessages entry) is never
// dropped, so roaming across many regions over a long session leaks
// one entry per distinct geohash ever observed. Sweep other geohashes
// here so activity that has fully aged out gets evicted instead of
// sitting in memory for the rest of the process's lifetime.
pruneStaleGeohashes(except: gh)
objectWillChange.send()
}
@ -113,4 +128,15 @@ final class GeohashChatActivityTracker: ObservableObject {
let cutoff = now().addingTimeInterval(-window)
return times.filter { $0 >= cutoff }
}
/// Drops any tracked geohash (other than `keep`) whose messages have all
/// aged out of the window, so the maps stay bounded to recently-active
/// geohashes instead of growing for the life of the process.
private func pruneStaleGeohashes(except keep: String) {
for gh in Array(messageTimes.keys) where gh != keep {
guard prune(messageTimes[gh] ?? []).isEmpty else { continue }
messageTimes.removeValue(forKey: gh)
lastMessages.removeValue(forKey: gh)
}
}
}

View File

@ -110,4 +110,32 @@ struct GeohashChatActivityTrackerTests {
#expect(tracker.messageCount(for: "9q8yy") == 0)
#expect(tracker.mostActiveConversation(among: [channel("9q8yy", .city)]) == nil)
}
@Test
func staleGeohashesAreEvictedNotJustEmptied() {
let (tracker, advance) = makeTracker(window: 900)
tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "old region", timestamp: baseDate)
#expect(tracker._trackedGeohashCountForTesting == 1)
// Move well past the window, then record activity in a different
// geohash this should sweep the now-stale "9q8yy" entry out of
// memory instead of leaving an empty-but-present dictionary entry.
advance(baseDate.addingTimeInterval(901))
tracker.recordChatMessage(geohash: "9r1zz", senderName: "b#2222", content: "new region", timestamp: baseDate.addingTimeInterval(901))
#expect(tracker._trackedGeohashCountForTesting == 1)
#expect(tracker.messageCount(for: "9q8yy") == 0)
#expect(tracker.messageCount(for: "9r1zz") == 1)
}
@Test
func activeGeohashSurvivesSweepEvenWhenMomentarilyEmpty() {
// The geohash currently being recorded to is exempt from the sweep,
// so recording its own first message never evicts itself.
let (tracker, _) = makeTracker(window: 900)
tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "hi", timestamp: baseDate)
#expect(tracker._trackedGeohashCountForTesting == 1)
#expect(tracker.messageCount(for: "9q8yy") == 1)
}
}