@@ -34,6 +34,9 @@ final class LiveTailViewModel {
34 nonisolated(unsafe) private var timer: Timer?
34 nonisolated(unsafe) private var timer: Timer?
35 private var lastTimestamp: Date?
35 private var lastTimestamp: Date?
36 private var seenFingerprints: Set<String> = []
36 private var seenFingerprints: Set<String> = []
37+ /// Insertion-ordered mirror of `seenFingerprints` (oldest first), used to
38+ /// evict the least-recently-seen fingerprints when the set is trimmed.
39+ private var seenFingerprintOrder: [String] = []
37 /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
40 /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
38 /// fingerprints of recently-evicted entries survive long enough to suppress
41 /// fingerprints of recently-evicted entries survive long enough to suppress
39 /// duplicates that reappear inside the next poll's look-back window, while
42 /// duplicates that reappear inside the next poll's look-back window, while
@@ -181,6 +184,7 @@ final class LiveTailViewModel {
181 errorMessage = nil
184 errorMessage = nil
182 entries = []
185 entries = []
183 seenFingerprints = []
186 seenFingerprints = []
187+ seenFingerprintOrder = []
184 allKnownKeys = []
188 allKnownKeys = []
185 droppedCount = 0
189 droppedCount = 0
186 consecutiveErrors = 0
190 consecutiveErrors = 0
@@ -218,6 +222,7 @@ final class LiveTailViewModel {
218 pollGeneration += 1
222 pollGeneration += 1
219 entries = []
223 entries = []
220 seenFingerprints = []
224 seenFingerprints = []
225+ seenFingerprintOrder = []
221 // Advance the look-back floor to now: without this, the next poll queries
226 // Advance the look-back floor to now: without this, the next poll queries
222 // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
227 // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
223 // every just-cleared record still inside the ~90 s window, repopulating the
228 // every just-cleared record still inside the ~90 s window, repopulating the
@@ -428,6 +433,7 @@ final class LiveTailViewModel {
428 var newEntries: [LiveTailEntry] = []
433 var newEntries: [LiveTailEntry] = []
429 for entry in candidateEntries {
434 for entry in candidateEntries {
430 if seenFingerprints.insert(entry.fingerprint).inserted {
435 if seenFingerprints.insert(entry.fingerprint).inserted {
436+ seenFingerprintOrder.append(entry.fingerprint)
431 newEntries.append(entry)
437 newEntries.append(entry)
@@ -452,10 +458,22 @@ final class LiveTailViewModel {
452 // duplicate. Keep the full seen-set so dedup stays correct;
458 // duplicate. Keep the full seen-set so dedup stays correct;
453 // it is bounded below to avoid unbounded growth.
459 // it is bounded below to avoid unbounded growth.
454 if seenFingerprints.count > maxSeenFingerprints {
460 if seenFingerprints.count > maxSeenFingerprints {
455− // Retain the fingerprints of everything still buffered
461+ // Evict the OLDEST surplus fingerprints by insertion order,
456− // (those are the ones that matter for dedup) and discard
462+ // not by what's currently buffered. Collapsing to only the
457− // the older surplus.
463+ // buffered set would drop recently-evicted fingerprints that
464+ // still fall inside the next poll's ~90 s look-back window,
465+ // re-admitting those records as duplicates. Keeping the
466+ // most-recently-seen tail preserves a full window of evicted
467+ // fingerprints. Buffered fingerprints are always retained so
468+ // a re-fetch of a buffered record stays deduplicated.
469+ let overflow = seenFingerprints.count - maxSeenFingerprints
458− seenFingerprints = Set(entries.map { $0.fingerprint })
470+ let buffered = Set(entries.map { $0.fingerprint })
471+ let toDrop = Set(seenFingerprintOrder.prefix(overflow))
472+ .subtracting(buffered)
473+ if !toDrop.isEmpty {
474+ seenFingerprints.subtract(toDrop)
475+ seenFingerprintOrder.removeAll { toDrop.contains($0) }