From 67de8ee6851d1acd58e86e703c99739376c5e1c8 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 9 Jul 2026 11:42:26 -0400 Subject: [PATCH 1/4] Add `ManagedObjectCache` --- Sources/CoreDataModel/NSManagedObject.swift | 35 ++++-- .../NSManagedObjectContext.swift | 107 +++++++++++++++++- 2 files changed, 133 insertions(+), 9 deletions(-) diff --git a/Sources/CoreDataModel/NSManagedObject.swift b/Sources/CoreDataModel/NSManagedObject.swift index 227603e..f498ddc 100644 --- a/Sources/CoreDataModel/NSManagedObject.swift +++ b/Sources/CoreDataModel/NSManagedObject.swift @@ -187,17 +187,27 @@ internal extension NSManagedObject { for key: PropertyKey, in context: NSManagedObjectContext ) throws { - + var cache = NSManagedObjectContext.ManagedObjectCache() + try setRelationship(newValue, for: key, in: context, cache: &cache) + } + + func setRelationship( + _ newValue: RelationshipValue, + for key: PropertyKey, + in context: NSManagedObjectContext, + cache: inout NSManagedObjectContext.ManagedObjectCache + ) throws { + guard let relationship = self.entity.relationshipsByName[key.rawValue], let destinationEntity = relationship.destinationEntity?.name.map({ EntityName(rawValue: $0) }) else { assertionFailure("Invalid relationship for \"\(key)\"") throw CocoaError(.coreData) } - + let model = self.entity.managedObjectModel - + let objectValue: AnyObject? - + switch newValue { case .null: objectValue = nil @@ -207,7 +217,7 @@ internal extension NSManagedObject { throw CocoaError(.coreData) } // find managed object - let managedObject = try context.find(destinationEntity, for: value) + let managedObject = try context.find(destinationEntity, for: value, cache: &cache) objectValue = managedObject case let .toMany(value): guard relationship.isToMany else { @@ -216,14 +226,14 @@ internal extension NSManagedObject { } // find or create let managedObjects = try value - .map { try context.find(destinationEntity, for: $0) ?? context.create(destinationEntity, for: $0, in: model) } + .map { try context.find(destinationEntity, for: $0, cache: &cache) ?? context.create(destinationEntity, for: $0, in: model, cache: &cache) } if relationship.isOrdered { objectValue = NSOrderedSet(array: managedObjects) } else { objectValue = NSSet(array: managedObjects) } } - + self.setValue(objectValue, forKey: key.rawValue) } } @@ -274,13 +284,22 @@ internal extension NSManagedObject { internal extension NSManagedObject { func setValues(for value: ModelData, in context: NSManagedObjectContext) throws { + var cache = NSManagedObjectContext.ManagedObjectCache() + try setValues(for: value, in: context, cache: &cache) + } + + func setValues( + for value: ModelData, + in context: NSManagedObjectContext, + cache: inout NSManagedObjectContext.ManagedObjectCache + ) throws { // apply attributes for (key, value) in value.attributes { setAttribute(value, for: key) } // apply relationships for (key, value) in value.relationships { - try setRelationship(value, for: key, in: context) + try setRelationship(value, for: key, in: context, cache: &cache) } } } diff --git a/Sources/CoreDataModel/NSManagedObjectContext.swift b/Sources/CoreDataModel/NSManagedObjectContext.swift index ed9f3e3..6d1d5cb 100644 --- a/Sources/CoreDataModel/NSManagedObjectContext.swift +++ b/Sources/CoreDataModel/NSManagedObjectContext.swift @@ -198,11 +198,116 @@ internal extension NSManagedObjectContext { _ values: [ModelData], model: NSManagedObjectModel ) throws { + guard values.isEmpty == false else { return } + // Prefetch every object referenced by the batch (inserted values and their + // relationship targets) with one fetch request per entity. Per-object + // find-or-create degrades quadratically as pending inserts accumulate, + // because each fetch request evaluates its predicate against all unsaved + // objects in the context. + var cache = try prefetch(for: values, model: model) + // find or create the inserted objects first so relationships between + // values in the same batch resolve regardless of their order + var managedObjects = [NSManagedObject]() + managedObjects.reserveCapacity(values.count) for value in values { - try insert(value, model: model, shouldSave: false) + let managedObject: NSManagedObject + if let cached = cache[value.entity]?[value.id] { + managedObject = cached + } else { + // the prefetch covered every value identifier, so a cache miss + // means the object does not exist yet + managedObject = try create(value.entity, for: value.id, in: model, cache: &cache) + } + managedObjects.append(managedObject) + } + // apply attributes and relationships + for (index, value) in values.enumerated() { + try managedObjects[index].setValues(for: value, in: self, cache: &cache) } try self.save() } } +internal extension NSManagedObjectContext { + + /// Realized managed objects by entity and identifier, used to avoid per-object + /// fetch requests during batch inserts. + typealias ManagedObjectCache = [EntityName: [ObjectID: NSManagedObject]] + + /// Fetch the existing managed objects referenced by the given values + /// (both inserted objects and their relationship targets) with a single + /// fetch request per entity. + func prefetch( + for values: [ModelData], + model: NSManagedObjectModel + ) throws -> ManagedObjectCache { + // collect identifiers by entity + var idsByEntity = [EntityName: Set]() + for value in values { + idsByEntity[value.entity, default: []].insert(value.id) + guard value.relationships.isEmpty == false else { continue } + let relationshipsByName = try model[value.entity].relationshipsByName + for (key, relationship) in value.relationships { + guard let destinationEntity = relationshipsByName[key.rawValue]?.destinationEntity?.name.map({ EntityName(rawValue: $0) }) else { + continue + } + switch relationship { + case .null: + continue + case let .toOne(id): + idsByEntity[destinationEntity, default: []].insert(id) + case let .toMany(ids): + idsByEntity[destinationEntity, default: []].formUnion(ids) + } + } + } + // fetch existing objects with a single request per entity + var cache = ManagedObjectCache(minimumCapacity: idsByEntity.count) + for (entityName, ids) in idsByEntity { + let fetchRequest = NSFetchRequest(entityName: entityName.rawValue) + fetchRequest.predicate = NSPredicate( + format: "%K IN %@", + NSManagedObject.BuiltInProperty.id.rawValue, + ids.map { $0.rawValue } + ) + fetchRequest.returnsObjectsAsFaults = false + var entityCache = [ObjectID: NSManagedObject](minimumCapacity: ids.count) + for managedObject in try self.fetch(fetchRequest) { + entityCache[try managedObject.modelObjectID] = managedObject + } + cache[entityName] = entityCache + } + return cache + } + + /// Find the managed object through the cache, falling back to a fetch request + /// and caching the result. + func find( + _ entityName: EntityName, + for id: ObjectID, + cache: inout ManagedObjectCache + ) throws -> NSManagedObject? { + if let managedObject = cache[entityName]?[id] { + return managedObject + } + guard let managedObject = try find(entityName, for: id, includesPropertyValues: false) else { + return nil + } + cache[entityName, default: [:]][id] = managedObject + return managedObject + } + + /// Create the managed object and add it to the cache. + func create( + _ entityName: EntityName, + for id: ObjectID, + in model: NSManagedObjectModel, + cache: inout ManagedObjectCache + ) throws -> NSManagedObject { + let managedObject = try create(entityName, for: id, in: model) + cache[entityName, default: [:]][id] = managedObject + return managedObject + } +} + #endif From 3b7e20dec909b47a9e23584ec708c3a44531d542 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 9 Jul 2026 11:58:35 -0400 Subject: [PATCH 2/4] Updated unit tests --- Tests/CoreModelTests/BatchInsertTests.swift | 172 ++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 Tests/CoreModelTests/BatchInsertTests.swift diff --git a/Tests/CoreModelTests/BatchInsertTests.swift b/Tests/CoreModelTests/BatchInsertTests.swift new file mode 100644 index 0000000..e0dd420 --- /dev/null +++ b/Tests/CoreModelTests/BatchInsertTests.swift @@ -0,0 +1,172 @@ +// +// BatchInsertTests.swift +// +// +// Created by Alsey Coleman Miller on 7/9/26. +// + +#if canImport(CoreData) + +import Foundation +import CoreData +import Testing +@testable import CoreModel +@testable import CoreDataModel + +@Suite +struct BatchInsertTests { + + /// Synthetic catalog payload: many events sharing a small set of people through + /// to-many relationships, duplicate values in the same batch, relationship targets + /// appearing after the values that reference them, and an upsert pass over + /// existing data. + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + @Test + func batchInsert() async throws { + + let store = try await makeStore() + + // shared relationship targets + var people = (0 ..< 20).map { + Person(name: "Person \($0)", age: UInt(20 + $0)) + } + // parents, each referencing 5 shared people + let events = (0 ..< 200).map { index in + Event( + name: "Event \(index)", + date: Date(timeIntervalSinceReferenceDate: TimeInterval(index)), + people: (0 ..< 5).map { people[(index + $0) % people.count].id } + ) + } + // populate the inverse side so every value in the batch is self-consistent: + // applying a value replaces its relationships wholesale (last write wins), + // so a value encoding an empty inverse would sever links set by earlier values + for index in people.indices { + people[index].events = events + .filter { $0.people.contains(people[index].id) } + .map { $0.id } + } + + // events first, so their relationship targets appear later in the batch; + // people encoded twice, like shared objects repeated in a server payload + var values = try events.map { try $0.encode() } + values += try people.map { try $0.encode() } + values += try people.map { try $0.encode() } + + try await store.insert(values) + + // duplicate values resolve to a single object + let personCount = try await store.count(FetchRequest(entity: Person.entityName)) + let eventCount = try await store.count(FetchRequest(entity: Event.entityName)) + #expect(personCount == 20) + #expect(eventCount == 200) + + // attributes and relationships round-trip + let fetchedEvent = try #require(try await store.fetch(Event.self, for: events[0].id)) + #expect(fetchedEvent.name == "Event 0") + #expect(Set(fetchedEvent.people) == Set(events[0].people)) + + // inverse relationships are connected + let fetchedPerson = try #require(try await store.fetch(Person.self, for: people[0].id)) + let expectedEvents = events.filter { $0.people.contains(people[0].id) }.map { $0.id } + #expect(Set(fetchedPerson.events) == Set(expectedEvents)) + + // re-inserting the batch updates objects in place + var updatedEvents = events + updatedEvents[42].name = "Updated Event" + try await store.insert(try updatedEvents.map { try $0.encode() }) + let updatedCount = try await store.count(FetchRequest(entity: Event.entityName)) + #expect(updatedCount == 200) + let updatedEvent = try #require(try await store.fetch(Event.self, for: events[42].id)) + #expect(updatedEvent.name == "Updated Event") + + // to-one relationships resolve to targets that appear later in the same batch + let campgroundID = UUID() + let officeHours = Campground.Schedule(start: 60 * 8, end: 60 * 18) + let units = (0 ..< 5).map { index in + Campground.Unit( + campground: campgroundID, + name: "A\(index)", + checkout: officeHours + ) + } + let campground = Campground( + id: campgroundID, + name: "Fair Play RV Park", + address: "243 Fisher Cove Rd, Fair Play, SC", + location: .init(latitude: 34.51446212994721, longitude: -83.01371101951648), + descriptionText: "Batch insert test campground", + units: units.map { $0.id }, + officeHours: officeHours + ) + var campgroundValues = try units.map { try $0.encode() } + campgroundValues.append(try campground.encode()) + try await store.insert(campgroundValues) + + let fetchedCampground = try #require(try await store.fetch(Campground.self, for: campground.id)) + #expect(Set(fetchedCampground.units) == Set(units.map { $0.id })) + let fetchedUnit = try #require(try await store.fetch(Campground.Unit.self, for: units[0].id)) + #expect(fetchedUnit.campground == campground.id) + } + + /// The batched insert must scale roughly linearly with batch size. + /// + /// The previous per-object find-or-create degraded quadratically — a 10x larger + /// batch cost ~100x more — because each fetch request evaluates its predicate + /// against every pending unsaved object in the context. The prefetched object + /// cache keeps the cost of a 10x larger batch at roughly 10x. + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + @Test + func batchInsertScaling() async throws { + // warm up the Core Data stack setup costs + _ = try await measureInsert(count: 50) + let small = try await measureInsert(count: 500) + let large = try await measureInsert(count: 5_000) + let ratio = large / small + print("[BatchInsertTests] 500 objects: \(small)s, 5000 objects: \(large)s, ratio: \(ratio)") + // linear scaling costs ~10x, the old quadratic behavior ~100x + #expect(ratio < 40, "10x larger batch should cost roughly 10x, not \(ratio)x") + } + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + private func measureInsert(count: Int) async throws -> TimeInterval { + let store = try await makeStore() + let people = (0 ..< 20).map { + Person(name: "Person \($0)", age: 30) + } + let events = (0 ..< count).map { index in + Event( + name: "Event \(index)", + date: Date(timeIntervalSinceReferenceDate: TimeInterval(index)), + people: [people[index % people.count].id] + ) + } + var values = try people.map { try $0.encode() } + values += try events.map { try $0.encode() } + let start = Date() + try await store.insert(values) + return Date().timeIntervalSince(start) + } + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + private func makeStore() async throws -> NSPersistentContainer { + let model = Model( + entities: + Person.self, + Event.self, + Campground.self, + Campground.Unit.self + ) + let managedObjectModel = NSManagedObjectModel(model: model) + let store = NSPersistentContainer( + name: "Test\(UUID())", + managedObjectModel: managedObjectModel + ) + for try await store in store.loadPersistentStores() { + _ = store + } + return store + } +} + +#endif From f91dbbb8ea640e878fca531cad18878ccdcaf68d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 9 Jul 2026 12:07:37 -0400 Subject: [PATCH 3/4] Update CI for Swift 6.3.2 --- .github/workflows/swift-arm.yml | 4 ++-- .github/workflows/swift-wasm.yml | 2 +- .github/workflows/swift-windows.yml | 2 +- .github/workflows/swift.yml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/swift-arm.yml b/.github/workflows/swift-arm.yml index f731dce..71cd4e6 100644 --- a/.github/workflows/swift-arm.yml +++ b/.github/workflows/swift-arm.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: arch: ["armv6", "armv7"] - swift: ["6.1.2"] + swift: ["6.3.2"] config: ["debug" , "release"] linux: ["raspios"] release: ["bookworm"] @@ -34,7 +34,7 @@ jobs: strategy: matrix: arch: ["armv7"] - swift: ["6.1.2"] + swift: ["6.3.2"] config: ["debug" , "release"] linux: ["debian"] release: ["bookworm", "bullseye"] diff --git a/.github/workflows/swift-wasm.yml b/.github/workflows/swift-wasm.yml index f9a2537..f389c1c 100644 --- a/.github/workflows/swift-wasm.yml +++ b/.github/workflows/swift-wasm.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - swift: ["6.0.3"] + swift: ["6.3.2"] config: ["debug", "release"] container: swift:${{ matrix.swift }} steps: diff --git a/.github/workflows/swift-windows.yml b/.github/workflows/swift-windows.yml index 3135ba3..933b7d0 100644 --- a/.github/workflows/swift-windows.yml +++ b/.github/workflows/swift-windows.yml @@ -6,7 +6,7 @@ jobs: runs-on: windows-latest strategy: matrix: - swift: ["6.1.2"] + swift: ["6.3.2"] config: ["debug", "release"] steps: - uses: compnerd/gha-setup-swift@main diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 9e0fd88..03b989d 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -23,7 +23,7 @@ jobs: name: Linux strategy: matrix: - container: ["swift:6.0.3", "swift:6.1.2"] + container: ["swift:6.1.2", "swift:6.3.2"] config: ["debug", "release"] options: ["", "SWIFT_BUILD_DYNAMIC_LIBRARY=1"] runs-on: ubuntu-latest @@ -43,7 +43,7 @@ jobs: strategy: fail-fast: false matrix: - swift: ['6.1', 'nightly-6.2'] + swift: ['6.2', 'nightly-6.3'] arch: ["aarch64", "x86_64"] runs-on: macos-15 timeout-minutes: 30 From b23bba29d1d819bbcf2592141ec83b164c3e7196 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 9 Jul 2026 12:19:39 -0400 Subject: [PATCH 4/4] Update CI --- .github/workflows/swift-wasm.yml | 37 +++++++++++++++++++---------- .github/workflows/swift-windows.yml | 24 +++++++++---------- .github/workflows/swift.yml | 19 ++++++++------- 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/.github/workflows/swift-wasm.yml b/.github/workflows/swift-wasm.yml index f389c1c..887c512 100644 --- a/.github/workflows/swift-wasm.yml +++ b/.github/workflows/swift-wasm.yml @@ -1,19 +1,30 @@ -name: Swift WASM +name: Swift WebAssembly on: [push] jobs: - build-wasm: - name: Build for WASM + wasm: + name: WebAssembly runs-on: ubuntu-latest + container: swift:6.3.2 strategy: + fail-fast: false matrix: - swift: ["6.3.2"] - config: ["debug", "release"] - container: swift:${{ matrix.swift }} + config: [debug, release] steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Swift Version - run: swift --version - - uses: swiftwasm/setup-swiftwasm@v2 - - name: Build - run: swift build -c ${{ matrix.config }} --swift-sdk wasm32-unknown-wasi + - name: Checkout + uses: actions/checkout@v4 + - name: Swift Version + run: swift --version + - name: Install dependencies + run: apt-get update -y && apt-get install -y curl + - name: Install WASM SDK + run: | + set -eux + url="https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz" + curl -fsSL "$url" -o wasm.artifactbundle.tar.gz + swift sdk install wasm.artifactbundle.tar.gz + swift sdk list + - name: Build + run: >- + swift build + -c ${{ matrix.config }} + --swift-sdk swift-6.3.2-RELEASE_wasm diff --git a/.github/workflows/swift-windows.yml b/.github/workflows/swift-windows.yml index 933b7d0..339538e 100644 --- a/.github/workflows/swift-windows.yml +++ b/.github/workflows/swift-windows.yml @@ -1,21 +1,21 @@ name: Swift Windows on: [push] jobs: - windows-build: + + windows: name: Windows runs-on: windows-latest strategy: matrix: - swift: ["6.3.2"] config: ["debug", "release"] steps: - - uses: compnerd/gha-setup-swift@main - with: - branch: swift-${{ matrix.swift }}-release - tag: ${{ matrix.swift }}-RELEASE - - name: Checkout - uses: actions/checkout@v4 - - name: Swift Version - run: swift --version - - name: Build - run: swift build -c ${{ matrix.config }} \ No newline at end of file + - uses: compnerd/gha-setup-swift@main + with: + branch: swift-6.3.2-release + tag: 6.3.2-RELEASE + - name: Checkout + uses: actions/checkout@v4 + - name: Swift Version + run: swift --version + - name: Build + run: swift build -c ${{ matrix.config }} diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 03b989d..0581d4d 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -4,7 +4,7 @@ jobs: macos: name: macOS - runs-on: macos-15 + runs-on: macos-26 strategy: matrix: config: ["debug", "release"] @@ -18,16 +18,17 @@ jobs: run: ${{ matrix.options }} swift build -c ${{ matrix.config }} - name: Test run: ${{ matrix.options }} swift test -c ${{ matrix.config }} - + linux: - name: Linux + name: Linux (${{ matrix.container }}) + runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - container: ["swift:6.1.2", "swift:6.3.2"] + container: ["swift:latest", "swiftlang/swift:nightly-noble"] config: ["debug", "release"] options: ["", "SWIFT_BUILD_DYNAMIC_LIBRARY=1"] - runs-on: ubuntu-latest - container: ${{ matrix.container }}-jammy + container: ${{ matrix.container }} steps: - name: Checkout uses: actions/checkout@v4 @@ -43,9 +44,9 @@ jobs: strategy: fail-fast: false matrix: - swift: ['6.2', 'nightly-6.3'] + swift: ['6.3.2'] arch: ["aarch64", "x86_64"] - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -53,4 +54,4 @@ jobs: run: | brew install skiptools/skip/skip || (brew update && brew install skiptools/skip/skip) skip android sdk install --version ${{ matrix.swift }} - ANDROID_NDK_ROOT="" skip android build --arch ${{ matrix.arch }} \ No newline at end of file + ANDROID_NDK_ROOT="" skip android build --arch ${{ matrix.arch }}