Consolidate message deduplication into shared MessageDeduplicator

- Extend MessageDeduplicator with configurable maxAge/maxCount params
- Add timestampFor() method for content key time-window checks
- Add record() method to store entries with specific timestamps
- Replace ChatViewModel's custom contentLRU implementation with MessageDeduplicator
- Remove ~35 lines of duplicate LRU code from ChatViewModel

This consolidates 2 separate deduplication implementations into one
reusable, thread-safe component.
This commit is contained in:
Claude
2025-11-25 01:19:26 +00:00
parent 86f46c8b90
commit 0492480598
2 changed files with 81 additions and 70 deletions
+70 -30
View File
@@ -2,38 +2,98 @@ import Foundation
// MARK: - Message Deduplicator (shared)
/// Thread-safe deduplicator with LRU eviction and time-based expiry.
/// Used for both message ID deduplication (network layer) and content key deduplication (UI layer).
final class MessageDeduplicator {
private struct Entry {
let messageID: String
let id: String
let timestamp: Date
}
private var entries: [Entry] = []
private var head: Int = 0
private var lookup = Set<String>()
private var lookup: [String: Date] = [:] // id -> timestamp for O(1) lookup
private let lock = NSLock()
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
private let maxCount = TransportConfig.messageDedupMaxCount
private let maxAge: TimeInterval
private let maxCount: Int
/// Initialize with default config from TransportConfig
convenience init() {
self.init(
maxAge: TransportConfig.messageDedupMaxAgeSeconds,
maxCount: TransportConfig.messageDedupMaxCount
)
}
/// Initialize with custom config for content deduplication
init(maxAge: TimeInterval, maxCount: Int) {
self.maxAge = maxAge
self.maxCount = maxCount
}
/// Check if message is duplicate and add if not
func isDuplicate(_ messageID: String) -> Bool {
func isDuplicate(_ id: String) -> Bool {
lock.lock()
defer { lock.unlock() }
cleanupOldEntries()
if lookup.contains(messageID) {
if lookup[id] != nil {
return true
}
entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID)
let now = Date()
entries.append(Entry(id: id, timestamp: now))
lookup[id] = now
trimIfNeeded()
return false
}
/// Record an ID with a specific timestamp (for content key tracking)
func record(_ id: String, timestamp: Date) {
lock.lock()
defer { lock.unlock() }
if lookup[id] == nil {
entries.append(Entry(id: id, timestamp: timestamp))
}
lookup[id] = timestamp
trimIfNeeded()
}
/// Add an ID without checking (for announce-back tracking)
func markProcessed(_ id: String) {
lock.lock()
defer { lock.unlock() }
if lookup[id] == nil {
let now = Date()
entries.append(Entry(id: id, timestamp: now))
lookup[id] = now
}
}
/// Check if ID exists without adding
func contains(_ id: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return lookup[id] != nil
}
/// Get timestamp for an ID (for content deduplication time-window checks)
func timestampFor(_ id: String) -> Date? {
lock.lock()
defer { lock.unlock() }
return lookup[id]
}
private func trimIfNeeded() {
// Soft-cap and advance head by a chunk to avoid O(n) shifting
if (entries.count - head) > maxCount {
let removeCount = min(100, entries.count - head)
for i in head..<(head + removeCount) {
lookup.remove(entries[i].messageID)
lookup.removeValue(forKey: entries[i].id)
}
head += removeCount
// Periodically compact to reclaim memory
@@ -42,26 +102,6 @@ final class MessageDeduplicator {
head = 0
}
}
return false
}
/// Add an ID without checking (for announce-back tracking)
func markProcessed(_ messageID: String) {
lock.lock()
defer { lock.unlock() }
if !lookup.contains(messageID) {
entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID)
}
}
/// Check if ID exists without adding
func contains(_ messageID: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return lookup.contains(messageID)
}
/// Clear all entries
@@ -89,7 +129,7 @@ final class MessageDeduplicator {
private func cleanupOldEntries() {
let cutoff = Date().addingTimeInterval(-maxAge)
while head < entries.count, entries[head].timestamp < cutoff {
lookup.remove(entries[head].messageID)
lookup.removeValue(forKey: entries[head].id)
head += 1
}
if head > 0 && head > entries.count / 2 {