Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions Blackbook/Services/LocalServerSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -408,58 +408,81 @@ final class LocalServerSyncService {

guard let top = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }

// Apply in dependency order: leaf entities first
// Apply in dependency order: leaf entities first. Each layer is applied in chunks with
// intermediate saves (see `applyInChunks`) so a fresh full sync never commits one massive
// 0→N transaction — that made the UI's @Query re-render the entire new object graph at once
// and faulted SwiftData relationships, crashing under Release optimization (work_log 2026-06-01).

// Layer 0: Tags, Groups, Locations, RejectedCalendarEvents
if let tags = top["tags"] as? [[String: Any]] {
logger.info("Pulled \(tags.count) tag(s)")
for dict in tags { try ModelSyncApply.applyRemoteTag(dict, to: context) }
try applyInChunks(tags, context: context, apply: ModelSyncApply.applyRemoteTag)
}
if let groups = top["groups"] as? [[String: Any]] {
logger.info("Pulled \(groups.count) group(s)")
for dict in groups { try ModelSyncApply.applyRemoteGroup(dict, to: context) }
try applyInChunks(groups, context: context, apply: ModelSyncApply.applyRemoteGroup)
}
if let locations = top["locations"] as? [[String: Any]] {
logger.info("Pulled \(locations.count) location(s)")
for dict in locations { try ModelSyncApply.applyRemoteLocation(dict, to: context) }
try applyInChunks(locations, context: context, apply: ModelSyncApply.applyRemoteLocation)
}
if let events = top["rejectedCalendarEvents"] as? [[String: Any]] {
for dict in events { try ModelSyncApply.applyRemoteRejectedEvent(dict, to: context) }
try applyInChunks(events, context: context, apply: ModelSyncApply.applyRemoteRejectedEvent)
}

// Layer 1: Activities (references Groups)
if let activities = top["activities"] as? [[String: Any]] {
logger.info("Pulled \(activities.count) activity(ies)")
for dict in activities { try ModelSyncApply.applyRemoteActivity(dict, to: context) }
try applyInChunks(activities, context: context, apply: ModelSyncApply.applyRemoteActivity)
}

// Layer 2: Contacts (references Tags, Groups, Locations, Activities)
if let contacts = top["contacts"] as? [[String: Any]] {
logger.info("Pulled \(contacts.count) contact(s)")
for dict in contacts { try ContactSyncApply.applyRemoteContact(dict, to: context) }
try applyInChunks(contacts, context: context, apply: ContactSyncApply.applyRemoteContact)
}

// Layer 3: Child entities (reference Contacts)
if let interactions = top["interactions"] as? [[String: Any]] {
logger.info("Pulled \(interactions.count) interaction(s)")
for dict in interactions { try ModelSyncApply.applyRemoteInteraction(dict, to: context) }
try applyInChunks(interactions, context: context, apply: ModelSyncApply.applyRemoteInteraction)
}
if let notes = top["notes"] as? [[String: Any]] {
logger.info("Pulled \(notes.count) note(s)")
for dict in notes { try ModelSyncApply.applyRemoteNote(dict, to: context) }
try applyInChunks(notes, context: context, apply: ModelSyncApply.applyRemoteNote)
}
if let reminders = top["reminders"] as? [[String: Any]] {
logger.info("Pulled \(reminders.count) reminder(s)")
for dict in reminders { try ModelSyncApply.applyRemoteReminder(dict, to: context) }
try applyInChunks(reminders, context: context, apply: ModelSyncApply.applyRemoteReminder)
}
if let rels = top["contactRelationships"] as? [[String: Any]] {
logger.info("Pulled \(rels.count) relationship(s)")
for dict in rels { try ModelSyncApply.applyRemoteContactRelationship(dict, to: context) }
try applyInChunks(rels, context: context, apply: ModelSyncApply.applyRemoteContactRelationship)
}

try context.save()
}

/// Number of pulled records applied per intermediate save during a pull.
private static let applyChunkSize = 25

/// Applies pulled record dicts via `apply`, saving after every `applyChunkSize` records so no
/// single transaction commits a massive 0→N change (which can fault SwiftData relationships
/// during the subsequent @Query re-render and crash the UI under Release optimization — observed
/// 2026-06-01 after a 95-message iMessage backfill). The caller saves once more for the remainder.
private func applyInChunks(
_ items: [[String: Any]],
context: ModelContext,
apply: (_ dict: [String: Any], _ context: ModelContext) throws -> Void
) throws {
for (index, dict) in items.enumerated() {
try apply(dict, context)
if (index + 1) % Self.applyChunkSize == 0 {
try context.save()
}
}
}

private func fetchPendingContacts(context: ModelContext) throws -> [Contact] {
let syncedStatus = SyncStatus.synced.rawValue
let predicate = #Predicate<Contact> { $0.syncStatus != syncedStatus }
Expand Down
15 changes: 15 additions & 0 deletions docs/test-scenarios/TEST_SCENARIOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,3 +390,18 @@ Then re-confirm Full Disk Access for "Blackbook Server" and toggle "Log iMessage

### Note
The console only runs while the sync server is running (it starts/stops with the main listener). The main Blackbook app is unaffected — these are BlackbookServer-only changes, deployed locally (not TestFlight).

## 2026-06-01 — Harden bulk sync-apply against the SwiftData @Query faulting crash

**Context:** After the 95-message iMessage backfill, the macOS app crash-looped (4× `EXC_BAD_ACCESS` in 45s) the moment the 95 interactions bulk-synced in. Root cause: `LocalServerSyncService.pullRemoteChanges` applied all pulled records to the main context and saved once — a single 0→95 transaction made the dashboard's `@Query` re-render the entire new object graph at once, faulting the `Interaction↔Contact` relationship under Release optimization. (Debug + settled-data Release builds do NOT crash; it was a transient race during the one-time bulk insert.) Fix: apply each layer in chunks of 25 with intermediate saves (`applyInChunks`) so no single massive transition occurs.

### Scenario — fresh-device full sync of a large store
1. On a device with an empty/reset store, sign in and let the first full sync run (pulls all contacts + the 95+ interactions).
2. Keep the **Overview/Dashboard** in front during the sync.
3. Expect: no crash; interactions appear (possibly in visible chunks of ~25 as each intermediate save lands); relationship scores update.
4. Open a contact → Interactions tab → messages render.

### Notes / caveats
- The crash is timing/optimization-dependent and could not be reproduced on settled data, so this hardening is **defense-in-depth** — verified by build + test + reasoning, not by reproducing the original race.
- Chunked saves also improve failure semantics: a mid-pull error now preserves already-applied chunks (idempotent UUID-upsert heals the rest on the next sync) instead of losing the whole batch.
- Verified: macOS + iOS build clean; 13 Swift Testing tests pass.
Loading