@@ -36,20 +36,20 @@ final class LiveTailViewModel {
3636 timer?.invalidate()
3737 }
3838
39− // Cached formatters to avoid per-poll allocation
40− private static let displayFormatter: DateFormatter = {
39+ // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
40+ nonisolated(unsafe) private static let displayFormatter: DateFormatter = {
4141 let f = DateFormatter()
4242 f.dateFormat = "HH:mm:ss.SSS"
4343 return f
4444 }()
4545
46− private static let isoFractional: ISO8601DateFormatter = {
46+ nonisolated(unsafe) private static let isoFractional: ISO8601DateFormatter = {
4747 let f = ISO8601DateFormatter()
4848 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
4949 return f
5050 }()
5151
52− private static let isoBasic: ISO8601DateFormatter = {
52+ nonisolated(unsafe) private static let isoBasic: ISO8601DateFormatter = {
5353 let f = ISO8601DateFormatter()
5454 f.formatOptions = [.withInternetDateTime]
5555 return f
@@ -78,7 +78,9 @@ final class LiveTailViewModel {
7878 }
7979
8080 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
81− private(set) var cachedFilteredRecords: [LogRecord] = []
81+ var cachedFilteredRecords: [LogRecord] {
82+ cachedFilteredEntries.map { $0.record }
83+ }
8284 private(set) var filteredEntriesGeneration: Int = 0
8385
8486 private func rebuildFilteredEntries() {
@@ -117,7 +119,6 @@ final class LiveTailViewModel {
117119 }
118120
119121 cachedFilteredEntries = result
120− cachedFilteredRecords = result.map { $0.record }
121122 filteredEntriesGeneration += 1
122123 }
123124
@@ -262,26 +263,21 @@ final class LiveTailViewModel {
262263
263264 // MARK: - Column Extraction
264265
265− private func extractColumns(from records: [LogRecord]) -> [String] {
266+ private func extractColumnsFromKeys(_ keys: Set<String>) -> [String] {
266267 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
267− var allKeys = Set<String>()
268− for record in records {
269− allKeys.formUnion(record.keys)
270− }
271−
268+ var remaining = keys
272269 var ordered: [String] = []
273270 for field in priorityFields {
274− if allKeys.remove(field) != nil {
271+ if remaining.remove(field) != nil {
275272 ordered.append(field)
276273 }
277274 }
278− ordered.append(contentsOf: allKeys.sorted())
275+ ordered.append(contentsOf: remaining.sorted())
279276 return ordered
280277 }
281278
282279 private func updateColumns(stream: String) {
283− let allRecords = entries.map { $0.record }
284− let extracted = extractColumns(from: allRecords)
280+ let extracted = extractColumnsFromKeys(allKnownKeys)
285281
286282 if columns.isEmpty {
287283 // First time — apply saved config
@@ -330,23 +326,33 @@ final class LiveTailViewModel {
330326 do {
331327 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
332328
333− var newEntries: [LiveTailEntry] = []
329+ // Process records off the main actor using a snapshot of seen fingerprints
330+ let seenSnapshot = seenFingerprints
331+ let pollTime = now
332+ let candidateEntries = await Task.detached(priority: .userInitiated) {
333+ var results: [LiveTailEntry] = []
334+ for record in records {
335+ let fp = Self.fingerprint(for: record)
336+ guard !seenSnapshot.contains(fp) else { continue }
337+ let timestamp = Self.parseTimestamp(from: record) ?? pollTime
338+ let summary = Self.buildSummary(from: record)
339+ results.append(LiveTailEntry(
340+ timestamp: timestamp,
341+ record: record,
342+ displayTimestamp: Self.displayFormatter.string(from: timestamp),
343+ summary: summary,
344+ fingerprint: fp
345+ ))
346+ }
347+ return results
348+ }.value
334349
335− for record in records {
336− let fp = Self.fingerprint(for: record)
337− guard !seenFingerprints.contains(fp) else { continue }
338− seenFingerprints.insert(fp)
339−
340− let timestamp = parseTimestamp(from: record) ?? now
341− let summary = buildSummary(from: record)
342−
343− newEntries.append(LiveTailEntry(
344− timestamp: timestamp,
345− record: record,
346− displayTimestamp: Self.displayFormatter.string(from: timestamp),
347− summary: summary,
348− fingerprint: fp
349− ))
350+ // Authoritative dedup on main actor
351+ var newEntries: [LiveTailEntry] = []
352+ for entry in candidateEntries {
353+ if seenFingerprints.insert(entry.fingerprint).inserted {
354+ newEntries.append(entry)
355+ }
350356 }
351357
352358 if !newEntries.isEmpty {
@@ -355,7 +361,7 @@ final class LiveTailViewModel {
355361 if entries.count > maxEntries {
356362 let excess = entries.count - maxEntries
357363 droppedCount += excess
358− entries.removeFirst(excess)
364+ entries = Array(entries.suffix(maxEntries))
359365
360366 // Rebuild fingerprint set from stored values (no re-hashing)
361367 seenFingerprints = Set(entries.map { $0.fingerprint })
@@ -393,27 +399,32 @@ final class LiveTailViewModel {
393399 }
394400 }
395401
396− /// Deterministic content-based fingerprint using sorted key-value pairs and FNV-1a.
397− static func fingerprint(for record: LogRecord) -> String {
398− var h0: UInt64 = 14695981039346656037
399− var h1: UInt64 = 14695981039346656037 &* 31
400− for key in record.keys.sorted() {
402+ /// Deterministic content-based fingerprint using XOR with golden-ratio mixing.
403+ /// Order-independent: O(k) instead of O(k log k) per record.
404+ nonisolated static func fingerprint(for record: LogRecord) -> String {
405+ var xorAccum0: UInt64 = 0
406+ var xorAccum1: UInt64 = 0
407+ for (key, value) in record {
408+ var kh0: UInt64 = 14695981039346656037
409+ var kh1: UInt64 = 14695981039346656037 &* 31
401410 for byte in key.utf8 {
402− h0 = (h0 ^ UInt64(byte)) &* 1099511628211
403− h1 = (h1 ^ UInt64(byte)) &* 6700417
411+ kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
412+ kh1 = (kh1 ^ UInt64(byte)) &* 6700417
404413 }
405− h0 = (h0 ^ 0) &* 1099511628211 // separator
406− if let val = record[key] {
407− hashJSONValue(val, h0: &h0, h1: &h1)
408− }
409− h1 = (h1 ^ 0xFF) &* 6700417 // field separator
414+ kh0 = (kh0 ^ 0) &* 1099511628211 // separator
415+ hashJSONValue(value, h0: &kh0, h1: &kh1)
416+ kh1 = (kh1 ^ 0xFF) &* 6700417 // field separator
417+ xorAccum0 ^= kh0 &* 0x9E3779B97F4A7C15
418+ xorAccum1 ^= kh1 &* 0x9E3779B97F4A7C15
410419 }
420+ let h0 = 14695981039346656037 &+ xorAccum0
421+ let h1 = (14695981039346656037 &* 31) &+ xorAccum1
411422 return String(h0, radix: 36) + String(h1, radix: 36)
412423 }
413424
414425 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
415426 /// Uses type-discriminator bytes to prevent cross-type collisions.
416− private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
427+ nonisolated private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
417428 switch value {
418429 case .null:
419430 h0 = (h0 ^ 0x00) &* 1099511628211
@@ -459,33 +470,39 @@ final class LiveTailViewModel {
459470 case .object(let dict):
460471 h0 = (h0 ^ 0x06) &* 1099511628211
461472 h1 = (h1 ^ 0x06) &* 6700417
462− for key in dict.keys.sorted() {
473+ var xor0: UInt64 = 0
474+ var xor1: UInt64 = 0
475+ for (key, value) in dict {
476+ var kh0: UInt64 = 14695981039346656037
477+ var kh1: UInt64 = 14695981039346656037 &* 31
463478 for byte in key.utf8 {
464− h0 = (h0 ^ UInt64(byte)) &* 1099511628211
465− h1 = (h1 ^ UInt64(byte)) &* 6700417
466− }
467− h0 = (h0 ^ 0xFD) &* 1099511628211 // key-value separator
468− if let val = dict[key] {
469− hashJSONValue(val, h0: &h0, h1: &h1)
479+ kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
480+ kh1 = (kh1 ^ UInt64(byte)) &* 6700417
470481 }
482+ kh0 = (kh0 ^ 0xFD) &* 1099511628211 // key-value separator
483+ hashJSONValue(value, h0: &kh0, h1: &kh1)
484+ xor0 ^= kh0 &* 0x9E3779B97F4A7C15
485+ xor1 ^= kh1 &* 0x9E3779B97F4A7C15
471486 }
487+ h0 = h0 &+ xor0
488+ h1 = h1 &+ xor1
472489 }
473490 }
474491
475− func parseTimestamp(from record: LogRecord) -> Date? {
492+ nonisolated static func parseTimestamp(from record: LogRecord) -> Date? {
476493 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
477494 return nil
478495 }
479496 if case .string(let str) = value {
480− if let date = Self.isoFractional.date(from: str) {
497+ if let date = isoFractional.date(from: str) {
481498 return date
482499 }
483− return Self.isoBasic.date(from: str)
500+ return isoBasic.date(from: str)
484501 }
485502 return nil
486503 }
487504
488− func buildSummary(from record: LogRecord) -> String {
505+ nonisolated static func buildSummary(from record: LogRecord) -> String {
489506 var parts: [String] = []
490507
491508 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {