@@ -27,26 +27,14 @@ final class LiveTailViewModel {
2727 /// column hidden last session would reappear visible when it shows up in a
2828 /// later poll. This retains the intent so it stays hidden when it appears.
2929 private var savedHiddenColumns: Set<String> = []
30− /// The full persisted column order, including columns not yet seen this
31− /// session — the order counterpart of `savedHiddenColumns`. A column that
32− /// first shows up in a later poll is slotted back into its saved position
33− /// (instead of being appended), and saving keeps unseen columns' positions
34− /// rather than dropping them from the stored order.
35− private var savedColumnOrder: [String] = []
3630 private var currentStream: String?
3731
3832 // nonisolated(unsafe) so deinit can invalidate the timer.
3933 // Only mutated from @MainActor methods (start/stop).
4034 nonisolated(unsafe) private var timer: Timer?
4135 private var lastTimestamp: Date?
42− /// How many records with each content fingerprint have been delivered. A
43− /// count rather than a set: Parseable stamps every event of one ingest request
44− /// with the same millisecond `p_timestamp`, so genuinely repeated events (the
45− /// same log line emitted three times) fingerprint identically. A set collapsed
46− /// them into one row; counting keeps each occurrence while still suppressing
47− /// the re-fetch of the same rows by the next overlapping poll window.
48− private var seenFingerprints: [String: Int] = [:]
49− /// Insertion-ordered mirror of `seenFingerprints` keys (oldest first), used to
36+ private var seenFingerprints: Set<String> = []
37+ /// Insertion-ordered mirror of `seenFingerprints` (oldest first), used to
5038 /// evict the least-recently-seen fingerprints when the set is trimmed.
5139 private var seenFingerprintOrder: [String] = []
5240 /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
@@ -221,7 +209,7 @@ final class LiveTailViewModel {
221209 isPolling = false
222210 errorMessage = nil
223211 entries = []
224− seenFingerprints = [:]
212+ seenFingerprints = []
225213 seenFingerprintOrder = []
226214 allKnownKeys = []
227215 droppedCount = 0
@@ -232,7 +220,6 @@ final class LiveTailViewModel {
232220 columnOrder = []
233221 hiddenColumns = []
234222 savedHiddenColumns = []
235− savedColumnOrder = []
236223 columnFilters = []
237224 // Clear the text filter too. Leaving it set while clearing the column-filter
238225 // chips is inconsistent and would let a stale search from a previous stream
@@ -275,12 +262,12 @@ final class LiveTailViewModel {
275262 // next timer tick skip itself via `guard !isPolling`.
276263 isPolling = false
277264 entries = []
278− // Keep the dedup fingerprints (unlike start(), which begins a new session):
279− // every poll looks back at least 90 s regardless of `lastTimestamp` (see
280− // `safeStart` in poll()), so the records just cleared are re-fetched by the
281− // next poll. With an emptied fingerprint set they all passed dedup again and
282− // the tail refilled with exactly what the user cleared. The set stays
283− // bounded by the eviction in poll().
265+ seenFingerprints = []
266+ seenFingerprintOrder = []
267+ // Advance the look-back floor to now: without this, the next poll queries
268+ // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
269+ // every just-cleared record still inside the ~90 s window, repopulating the
270+ // view the user just emptied. Matches start()'s reset.
284271 lastTimestamp = Date()
285272 allKnownKeys = []
286273 droppedCount = 0
@@ -295,7 +282,6 @@ final class LiveTailViewModel {
295282 columnOrder = []
296283 hiddenColumns = []
297284 savedHiddenColumns = []
298− savedColumnOrder = []
299285 columnFilters = []
300286 // Match start(): clear the text filter alongside the column-filter chips so
301287 // a clear()ed tail isn't immediately re-hidden by a lingering search string.
@@ -351,7 +337,6 @@ final class LiveTailViewModel {
351337
352338 func resetColumnConfig() {
353339 columnOrder = columns
354− savedColumnOrder = []
355340 hiddenColumns.removeAll()
356341 savedHiddenColumns.removeAll()
357342 saveColumnConfig()
@@ -365,16 +350,9 @@ final class LiveTailViewModel {
365350
366351 private func saveColumnConfig() {
367352 guard let stream = currentStream else { return }
368− // Persist the full intended hidden set and order (including columns not
369− // yet seen this session) so they survive restarts and incremental discovery.
370− let seen = Set(columnOrder)
371− let order = QueryViewModel.mergedColumnOrder(
372− columnOrder,
373− inserting: savedColumnOrder.filter { !seen.contains($0) },
374− reference: savedColumnOrder
375− )
376− savedColumnOrder = order
377− let config = QueryViewModel.ColumnConfiguration(order: order, hidden: savedHiddenColumns)
353+ // Persist the full intended hidden set (including columns not yet seen
354+ // this session) so it survives across restarts and incremental discovery.
355+ let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: savedHiddenColumns)
378356 if let data = try? JSONEncoder().encode(config) {
379357 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
380358 }
@@ -416,12 +394,10 @@ final class LiveTailViewModel {
416394 merged.append(col)
417395 }
418396 columnOrder = merged
419− savedColumnOrder = config.order
420397 savedHiddenColumns = config.hidden
421398 hiddenColumns = config.hidden.intersection(extractedSet)
422399 } else {
423400 columnOrder = extracted
424− savedColumnOrder = []
425401 savedHiddenColumns = []
426402 hiddenColumns = []
427403 }
@@ -434,7 +410,7 @@ final class LiveTailViewModel {
434410 }
435411 if !newCols.isEmpty {
436412 columns.append(contentsOf: newCols)
437− columnOrder = QueryViewModel.mergedColumnOrder(columnOrder, inserting: newCols, reference: savedColumnOrder)
413+ columnOrder.append(contentsOf: newCols)
438414 // Re-hide any newly appearing column that was hidden in a prior
439415 // session but hadn't been seen yet this run.
440416 hiddenColumns.formUnion(newCols.filter { savedHiddenColumns.contains($0) })
@@ -492,27 +468,22 @@ final class LiveTailViewModel {
492468 // Process records off the main actor using a snapshot of seen fingerprints
493469 let seenSnapshot = seenFingerprints
494470 let pollTime = now
495− let (candidateEntries, batchCounts) = await Task.detached(priority: .userInitiated) {
496− var results: [(entry: LiveTailEntry, occurrence: Int)] = []
497− var counts: [String: Int] = [:]
471+ let candidateEntries = await Task.detached(priority: .userInitiated) {
472+ var results: [LiveTailEntry] = []
498473 for record in records {
499474 let fp = Self.fingerprint(for: record)
500− let occurrence = (counts[fp] ?? 0) + 1
501− counts[fp] = occurrence
502− // The first `seen` occurrences of this content were delivered
503− // by earlier polls; only later ones are new.
504− guard occurrence > (seenSnapshot[fp] ?? 0) else { continue }
475+ guard !seenSnapshot.contains(fp) else { continue }
505476 let timestamp = Self.parseTimestamp(from: record) ?? pollTime
506477 let summary = Self.buildSummary(from: record)
507− results.append((LiveTailEntry(
478+ results.append(LiveTailEntry(
508479 timestamp: timestamp,
509480 record: record,
510481 displayTimestamp: Self.displayFormatter.string(from: timestamp),
511482 summary: summary,
512483 fingerprint: fp
513− ), occurrence))
484+ ))
514485 }
515− return (results, counts)
486+ return results
516487 }.value
517488
518489 // Re-check after the detached processing hop: a stop/clear could have
@@ -521,13 +492,11 @@ final class LiveTailViewModel {
521492
522493 // Authoritative dedup on main actor
523494 var newEntries: [LiveTailEntry] = []
524− for candidate in candidateEntries
525− where candidate.occurrence > (seenFingerprints[candidate.entry.fingerprint] ?? 0) {
526− newEntries.append(candidate.entry)
527− }
528− for (fp, count) in batchCounts where count > (seenFingerprints[fp] ?? 0) {
529− if seenFingerprints[fp] == nil { seenFingerprintOrder.append(fp) }
530− seenFingerprints[fp] = count
495+ for entry in candidateEntries {
496+ if seenFingerprints.insert(entry.fingerprint).inserted {
497+ seenFingerprintOrder.append(entry.fingerprint)
498+ newEntries.append(entry)
499+ }
531500 }
532501
533502 if !newEntries.isEmpty {
@@ -549,27 +518,23 @@ final class LiveTailViewModel {
549518 // would let it pass the dedup check and be re-appended as a
550519 // duplicate. Keep the full seen-set so dedup stays correct;
551520 // it is bounded below to avoid unbounded growth.
552− }
553−
554− // Bound the seen-set on every poll, not only when the buffer
555− // overflows: clear() keeps fingerprints while emptying `entries`,
556− // so the set can outgrow the cap without the buffer ever filling.
557− if seenFingerprints.count > maxSeenFingerprints {
558− // Evict the OLDEST surplus fingerprints by insertion order,
559− // not by what's currently buffered. Collapsing to only the
560− // buffered set would drop recently-evicted fingerprints that
561− // still fall inside the next poll's ~90 s look-back window,
562− // re-admitting those records as duplicates. Keeping the
563− // most-recently-seen tail preserves a full window of evicted
564− // fingerprints. Buffered fingerprints are always retained so
565− // a re-fetch of a buffered record stays deduplicated.
566− let overflow = seenFingerprints.count - maxSeenFingerprints
567− let buffered = Set(entries.map { $0.fingerprint })
568− let toDrop = Set(seenFingerprintOrder.prefix(overflow))
569− .subtracting(buffered)
570− if !toDrop.isEmpty {
571− for fp in toDrop { seenFingerprints.removeValue(forKey: fp) }
572− seenFingerprintOrder.removeAll { toDrop.contains($0) }
521+ if seenFingerprints.count > maxSeenFingerprints {
522+ // Evict the OLDEST surplus fingerprints by insertion order,
523+ // not by what's currently buffered. Collapsing to only the
524+ // buffered set would drop recently-evicted fingerprints that
525+ // still fall inside the next poll's ~90 s look-back window,
526+ // re-admitting those records as duplicates. Keeping the
527+ // most-recently-seen tail preserves a full window of evicted
528+ // fingerprints. Buffered fingerprints are always retained so
529+ // a re-fetch of a buffered record stays deduplicated.
530+ let overflow = seenFingerprints.count - maxSeenFingerprints
531+ let buffered = Set(entries.map { $0.fingerprint })
532+ let toDrop = Set(seenFingerprintOrder.prefix(overflow))
533+ .subtracting(buffered)
534+ if !toDrop.isEmpty {
535+ seenFingerprints.subtract(toDrop)
536+ seenFingerprintOrder.removeAll { toDrop.contains($0) }
537+ }
573538 }
574539 }
575540
@@ -724,19 +689,10 @@ final class LiveTailViewModel {
724689 return nil
725690 }
726691 if case .string(let str) = value {
727− if let date = isoFractional.date(from: str) ?? isoBasic.date(from: str) {
692+ if let date = isoFractional.date(from: str) {
728693 return date
729694 }
730− // Parseable stores `p_timestamp` as an Arrow timestamp without a
731− // timezone, and the query API serializes it as a naive UTC string with
732− // no offset (e.g. "2024-01-11T09:08:34.290"). `.withInternetDateTime`
733− // requires a zone designator, so such values never parsed: every entry
734− // fell back to the poll time, which also scrambled the tail's order
735− // (each poll's DESC-ordered batch was appended newest-first). Retry as
736− // UTC; a string that already carries an offset won't parse with an
737− // extra "Z", so this can't reinterpret zoned timestamps.
738− let utc = str + "Z"
739− return isoFractional.date(from: utc) ?? isoBasic.date(from: utc)
695+ return isoBasic.date(from: str)
740696 }
741697 return nil
742698 }