@@ -36,20 +36,20 @@ final class LiveTailViewModel {
36 timer?.invalidate()
36 timer?.invalidate()
39− // Cached formatters to avoid per-poll allocation
39+ // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
40− private static let displayFormatter: DateFormatter = {
40+ nonisolated(unsafe) private static let displayFormatter: DateFormatter = {
41 let f = DateFormatter()
41 let f = DateFormatter()
42 f.dateFormat = "HH:mm:ss.SSS"
42 f.dateFormat = "HH:mm:ss.SSS"
46− private static let isoFractional: ISO8601DateFormatter = {
46+ nonisolated(unsafe) private static let isoFractional: ISO8601DateFormatter = {
47 let f = ISO8601DateFormatter()
47 let f = ISO8601DateFormatter()
48 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
48 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
52− private static let isoBasic: ISO8601DateFormatter = {
52+ nonisolated(unsafe) private static let isoBasic: ISO8601DateFormatter = {
53 let f = ISO8601DateFormatter()
53 let f = ISO8601DateFormatter()
54 f.formatOptions = [.withInternetDateTime]
54 f.formatOptions = [.withInternetDateTime]
@@ -78,7 +78,9 @@ final class LiveTailViewModel {
80 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
80 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
81− private(set) var cachedFilteredRecords: [LogRecord] = []
81+ var cachedFilteredRecords: [LogRecord] {
82+ cachedFilteredEntries.map { $0.record }
82 private(set) var filteredEntriesGeneration: Int = 0
84 private(set) var filteredEntriesGeneration: Int = 0
84 private func rebuildFilteredEntries() {
86 private func rebuildFilteredEntries() {
@@ -117,7 +119,6 @@ final class LiveTailViewModel {
119 cachedFilteredEntries = result
121 cachedFilteredEntries = result
120− cachedFilteredRecords = result.map { $0.record }
121 filteredEntriesGeneration += 1
122 filteredEntriesGeneration += 1
@@ -262,26 +263,21 @@ final class LiveTailViewModel {
263 // MARK: - Column Extraction
264 // MARK: - Column Extraction
265− private func extractColumns(from records: [LogRecord]) -> [String] {
266+ private func extractColumnsFromKeys(_ keys: Set<String>) -> [String] {
266 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
267 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
267− var allKeys = Set<String>()
268+ var remaining = keys
268− for record in records {
269− allKeys.formUnion(record.keys)
272 var ordered: [String] = []
269 var ordered: [String] = []
273 for field in priorityFields {
270 for field in priorityFields {
274− if allKeys.remove(field) != nil {
271+ if remaining.remove(field) != nil {
275 ordered.append(field)
272 ordered.append(field)
278− ordered.append(contentsOf: allKeys.sorted())
275+ ordered.append(contentsOf: remaining.sorted())
279 return ordered
276 return ordered
282 private func updateColumns(stream: String) {
279 private func updateColumns(stream: String) {
283− let allRecords = entries.map { $0.record }
280+ let extracted = extractColumnsFromKeys(allKnownKeys)
284− let extracted = extractColumns(from: allRecords)
286 if columns.isEmpty {
282 if columns.isEmpty {
287 // First time — apply saved config
283 // First time — apply saved config
@@ -330,23 +326,33 @@ final class LiveTailViewModel {
331 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
327 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
329+ // Process records off the main actor using a snapshot of seen fingerprints
330+ let seenSnapshot = seenFingerprints
332+ let candidateEntries = await Task.detached(priority: .userInitiated) {
333− var newEntries: [LiveTailEntry] = []
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,
342+ displayTimestamp: Self.displayFormatter.string(from: timestamp),
335− for record in records {
350+ // Authoritative dedup on main actor
336− let fp = Self.fingerprint(for: record)
351+ var newEntries: [LiveTailEntry] = []
337− guard !seenFingerprints.contains(fp) else { continue }
352+ for entry in candidateEntries {
338− seenFingerprints.insert(fp)
353+ if seenFingerprints.insert(entry.fingerprint).inserted {
340− let timestamp = parseTimestamp(from: record) ?? now
341− let summary = buildSummary(from: record)
343− newEntries.append(LiveTailEntry(
354+ newEntries.append(entry)
344− timestamp: timestamp,
355+ }
346− displayTimestamp: Self.displayFormatter.string(from: timestamp),
352 if !newEntries.isEmpty {
358 if !newEntries.isEmpty {
@@ -355,7 +361,7 @@ final class LiveTailViewModel {
355 if entries.count > maxEntries {
361 if entries.count > maxEntries {
356 let excess = entries.count - maxEntries
362 let excess = entries.count - maxEntries
357 droppedCount += excess
363 droppedCount += excess
358− entries.removeFirst(excess)
364+ entries = Array(entries.suffix(maxEntries))
360 // Rebuild fingerprint set from stored values (no re-hashing)
366 // Rebuild fingerprint set from stored values (no re-hashing)
361 seenFingerprints = Set(entries.map { $0.fingerprint })
367 seenFingerprints = Set(entries.map { $0.fingerprint })
@@ -393,27 +399,32 @@ final class LiveTailViewModel {
396− /// Deterministic content-based fingerprint using sorted key-value pairs and FNV-1a.
402+ /// Deterministic content-based fingerprint using XOR with golden-ratio mixing.
403+ /// Order-independent: O(k) instead of O(k log k) per record.
397− static func fingerprint(for record: LogRecord) -> String {
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 {
398− var h0: UInt64 = 14695981039346656037
408+ var kh0: UInt64 = 14695981039346656037
399− var h1: UInt64 = 14695981039346656037 &* 31
409+ var kh1: UInt64 = 14695981039346656037 &* 31
400− for key in record.keys.sorted() {
401 for byte in key.utf8 {
410 for byte in key.utf8 {
402− h0 = (h0 ^ UInt64(byte)) &* 1099511628211
411+ kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
403− h1 = (h1 ^ UInt64(byte)) &* 6700417
412+ kh1 = (kh1 ^ UInt64(byte)) &* 6700417
405− h0 = (h0 ^ 0) &* 1099511628211 // separator
414+ kh0 = (kh0 ^ 0) &* 1099511628211 // separator
406− if let val = record[key] {
407− hashJSONValue(val, h0: &h0, h1: &h1)
415+ hashJSONValue(value, h0: &kh0, h1: &kh1)
409− h1 = (h1 ^ 0xFF) &* 6700417 // field separator
416+ kh1 = (kh1 ^ 0xFF) &* 6700417 // field separator
417+ xorAccum0 ^= kh0 &* 0x9E3779B97F4A7C15
418+ xorAccum1 ^= kh1 &* 0x9E3779B97F4A7C15
420+ let h0 = 14695981039346656037 &+ xorAccum0
421+ let h1 = (14695981039346656037 &* 31) &+ xorAccum1
411 return String(h0, radix: 36) + String(h1, radix: 36)
422 return String(h0, radix: 36) + String(h1, radix: 36)
414 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
425 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
415 /// Uses type-discriminator bytes to prevent cross-type collisions.
426 /// 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) {
417 switch value {
428 switch value {
418 case .null:
429 case .null:
419 h0 = (h0 ^ 0x00) &* 1099511628211
430 h0 = (h0 ^ 0x00) &* 1099511628211
@@ -459,33 +470,39 @@ final class LiveTailViewModel {
459 case .object(let dict):
470 case .object(let dict):
460 h0 = (h0 ^ 0x06) &* 1099511628211
471 h0 = (h0 ^ 0x06) &* 1099511628211
461 h1 = (h1 ^ 0x06) &* 6700417
472 h1 = (h1 ^ 0x06) &* 6700417
473+ var xor0: UInt64 = 0
474+ var xor1: UInt64 = 0
462− for key in dict.keys.sorted() {
475+ for (key, value) in dict {
476+ var kh0: UInt64 = 14695981039346656037
477+ var kh1: UInt64 = 14695981039346656037 &* 31
463 for byte in key.utf8 {
478 for byte in key.utf8 {
464− h0 = (h0 ^ UInt64(byte)) &* 1099511628211
479+ kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
465− h1 = (h1 ^ UInt64(byte)) &* 6700417
480+ kh1 = (kh1 ^ UInt64(byte)) &* 6700417
467− h0 = (h0 ^ 0xFD) &* 1099511628211 // key-value separator
468− if let val = dict[key] {
469− hashJSONValue(val, h0: &h0, h1: &h1)
482+ kh0 = (kh0 ^ 0xFD) &* 1099511628211 // key-value separator
483+ hashJSONValue(value, h0: &kh0, h1: &kh1)
484+ xor0 ^= kh0 &* 0x9E3779B97F4A7C15
485+ xor1 ^= kh1 &* 0x9E3779B97F4A7C15
475− func parseTimestamp(from record: LogRecord) -> Date? {
492+ nonisolated static func parseTimestamp(from record: LogRecord) -> Date? {
476 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
493 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
477 return nil
494 return nil
479 if case .string(let str) = value {
496 if case .string(let str) = value {
480− if let date = Self.isoFractional.date(from: str) {
497+ if let date = isoFractional.date(from: str) {
481 return date
498 return date
483− return Self.isoBasic.date(from: str)
500+ return isoBasic.date(from: str)
485 return nil
502 return nil
488− func buildSummary(from record: LogRecord) -> String {
505+ nonisolated static func buildSummary(from record: LogRecord) -> String {
489 var parts: [String] = []
506 var parts: [String] = []
491 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {
508 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {