From e59377bfa5dfc1a0aa2eed209b10735e2f338b2d Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 22 Dec 2025 05:44:43 -0800 Subject: [PATCH 1/2] Added action name when checking datastore count --- Sources/CodableDatastore/Datastore/Datastore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodableDatastore/Datastore/Datastore.swift b/Sources/CodableDatastore/Datastore/Datastore.swift index aa40082..96b995d 100644 --- a/Sources/CodableDatastore/Datastore/Datastore.swift +++ b/Sources/CodableDatastore/Datastore/Datastore.swift @@ -460,7 +460,7 @@ extension Datastore { try await warmupIfNeeded() return try await persistence._withTransaction( - actionName: nil, + actionName: "Check Count", options: [.idempotent, .readOnly] ) { transaction, _ in let descriptor = try await transaction.datastoreDescriptor(for: self.key) From 79a3eb19c5b521a0f507c42a7c6a55f2015d24d6 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 22 Dec 2025 05:47:50 -0800 Subject: [PATCH 2/2] Fixed an issue where readonly transactions could override written data when they complete out of sequence --- .../Transaction/Transaction.swift | 24 +-- .../DiskPersistenceDatastoreTests.swift | 156 ++++++++++++++++++ .../Task+Unresolved.swift | 24 +++ 3 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 Tests/CodableDatastoreTests/Task+Unresolved.swift diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift index 7f7c81c..3bc4181 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift @@ -138,18 +138,20 @@ extension DiskPersistence { deletedPages.removeAll() } + /// If the transaction is read-only, stop here without applying anything to the parent. + guard !options.contains(.readOnly) else { + assert(entryMutations.isEmpty, "Entries were mutated in a read-only transaction!") + assert(createdRootObjects.isEmpty, "Root objects were created in a read-only transaction!") + assert(createdIndexes.isEmpty, "Indexes were created in a read-only transaction!") + assert(createdPages.isEmpty, "Pages were created in a read-only transaction!") + assert(deletedRootObjects.isEmpty, "Root objects were deleted in a read-only transaction!") + assert(deletedIndexes.isEmpty, "Indexes were deleted in a read-only transaction!") + assert(deletedPages.isEmpty, "Pages were deleted in a read-only transaction!") + return + } + + /// If the transaction has a parent, offload responsibility to it to persist the changes along with other children. if let parent { - /// If the transaction is read-only, stop here without applying anything to the parent. - guard !options.contains(.readOnly) else { - assert(entryMutations.isEmpty, "Entries were mutated in a read-only transaction!") - assert(createdRootObjects.isEmpty, "Root objects were created in a read-only transaction!") - assert(createdIndexes.isEmpty, "Indexes were created in a read-only transaction!") - assert(createdPages.isEmpty, "Pages were created in a read-only transaction!") - assert(deletedRootObjects.isEmpty, "Root objects were deleted in a read-only transaction!") - assert(deletedIndexes.isEmpty, "Indexes were deleted in a read-only transaction!") - assert(deletedPages.isEmpty, "Pages were deleted in a read-only transaction!") - return - } // print("[CDS] Offloading persist to parent \(transactionIndex) - \(options)") try await parent.apply( rootObjects: rootObjects, diff --git a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift index b17e747..f07a0a1 100644 --- a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift +++ b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift @@ -915,6 +915,162 @@ final class DiskPersistenceDatastoreTests: XCTestCase, @unchecked Sendable { } } + func testReadOnlyTransactionDoesNotOverrideWrittenTransactions() async throws { + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: Int + var value: String + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero + } + + let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) + try await persistence.createPersistenceIfNecessary() + + let datastore = Datastore.JSONStore( + persistence: persistence, + format: TestFormat.self, + migrations: [ + .zero: { data, decoder in + try decoder.decode(TestFormat.Instance.self, from: data) + } + ] + ) + + let valueBank = [ + "Hello, World!", + "My name is Dimitri", + "Writen using CodableDatastore", + "Swift is better than Objective-C, there, I said it", + "Twenty Three is Number One" + ] + + try await persistence.perform { + for id in 0..<10 { + try await datastore.persist(.init(id: id, value: valueBank.randomElement()!)) + } + } + + var totalCount = try await datastore.count + XCTAssertEqual(totalCount, 10) + + let (startReadingTask, startReadingContinuation) = await Task.makeUnresolved() + let (didStartReadingTask, didStartReadingContinuation) = await Task.makeUnresolved() + + let readTask = Task { + let allEntries = datastore.load(...) + + didStartReadingContinuation.resume() + await startReadingTask.value + + var count = 0 + for try await _ in allEntries { + count += 1 + } + } + + await didStartReadingTask.value + + try await persistence.perform { + for id in 10..<20 { + try await datastore.persist(.init(id: id, value: valueBank.randomElement()!)) + } + } + totalCount = try await datastore.count + XCTAssertEqual(totalCount, 20) + + startReadingContinuation.resume() + try await readTask.value + + totalCount = try await datastore.count + XCTAssertEqual(totalCount, 20) + } + + func testNestedReadOnlyTransactionDoesNotOverrideWrittenTransactions() async throws { + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: Int + var value: String + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero + } + + let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) + try await persistence.createPersistenceIfNecessary() + + let datastore = Datastore.JSONStore( + persistence: persistence, + format: TestFormat.self, + migrations: [ + .zero: { data, decoder in + try decoder.decode(TestFormat.Instance.self, from: data) + } + ] + ) + + let valueBank = [ + "Hello, World!", + "My name is Dimitri", + "Writen using CodableDatastore", + "Swift is better than Objective-C, there, I said it", + "Twenty Three is Number One" + ] + + try await persistence.perform { + for id in 0..<10 { + try await datastore.persist(.init(id: id, value: valueBank.randomElement()!)) + } + } + + var totalCount = try await datastore.count + XCTAssertEqual(totalCount, 10) + + let (startReadingTask, startReadingContinuation) = await Task.makeUnresolved() + let (didStartReadingTask, didStartReadingContinuation) = await Task.makeUnresolved() + + let readTask = Task { + try await persistence.perform(options: .readOnly) { + let allEntries = datastore.load(...) + + didStartReadingContinuation.resume() + await startReadingTask.value + + var count = 0 + for try await _ in allEntries { + count += 1 + } + } + } + + await didStartReadingTask.value + + try await persistence.perform { + for id in 10..<20 { + try await datastore.persist(.init(id: id, value: valueBank.randomElement()!)) + } + } + totalCount = try await datastore.count + XCTAssertEqual(totalCount, 20) + + startReadingContinuation.resume() + try await readTask.value + + totalCount = try await datastore.count + XCTAssertEqual(totalCount, 20) + } + func testTakingSnapshots() async throws { struct TestFormat: DatastoreFormat { enum Version: Int, CaseIterable { diff --git a/Tests/CodableDatastoreTests/Task+Unresolved.swift b/Tests/CodableDatastoreTests/Task+Unresolved.swift new file mode 100644 index 0000000..4ba1def --- /dev/null +++ b/Tests/CodableDatastoreTests/Task+Unresolved.swift @@ -0,0 +1,24 @@ +// +// Task+Unresolved.swift +// CodableDatastore +// +// Created by Dimitri Bouniol on 12/22/25. +// Copyright © 2023-25 Mochi Development, Inc. All rights reserved. +// + +extension Task where Success == Void, Failure == Never { + static func makeUnresolved() async -> ( + task: Task, + continuation: CheckedContinuation + ) { + var task: Task? + let continuation = await withCheckedContinuation { factoryContinuation in + task = Task { + await withCheckedContinuation { taskContinuation in + factoryContinuation.resume(returning: taskContinuation) + } + } + } + return (task!, continuation) + } +}