Merge 51798b761b3faf49c64e5438a8eb6223e45cd849 into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
Taksh Kothari 2026-08-08 02:35:29 +00:00 committed by GitHub
commit 3750b9bc8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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)
}
}