From c875ba4358931f5948f6256a089af1022f2b7cfc Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 22 Dec 2025 04:22:15 -0800 Subject: [PATCH 1/3] Added transaction Indexes and commented out logs for debugging transactions --- .../Disk Persistence/DiskPersistence.swift | 21 +++++++++++++++++-- .../Transaction/Transaction.swift | 20 ++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift index 877d4dc..1a99c3c 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift @@ -39,6 +39,8 @@ public actor DiskPersistence: Persistence { var rollingPageCacheIndex = 0 var rollingPageCache: [Datastore.Page] = [] + var transactionCounter = 0 + /// Initialize a ``DiskPersistence`` with a read-write URL. /// /// Use this initializer when creating a persistence from the main process that will access it, such as your app. To access the same persistence from another process, use ``init(readOnlyURL:)`` instead. @@ -541,16 +543,26 @@ extension DiskPersistence { // MARK: - Transactions extension DiskPersistence { + func nextTransactionCounter() -> Int { + let transactionIndex = transactionCounter + transactionCounter += 1 + return transactionIndex + } + public func _withTransaction( actionName: String?, options: UnsafeTransactionOptions, transaction: @Sendable (_ transaction: DatastoreInterfaceProtocol, _ isDurable: Bool) async throws -> T ) async throws -> T { try await withoutActuallyEscaping(transaction) { escapingTransaction in + let currentCounter = nextTransactionCounter() +// print("[CDS] Starting transaction \(currentCounter) “\(actionName ?? "")” - \(options)") let (transaction, task) = await Transaction.makeTransaction( persistence: self, + transactionIndex: currentCounter, lastTransaction: lastTransaction, - actionName: actionName, options: options + actionName: actionName, + options: options ) { interface, isDurable in try await escapingTransaction(interface, isDurable) } @@ -560,11 +572,14 @@ extension DiskPersistence { lastTransaction = transaction } - return try await task.value + let result = try await task.value +// print("[CDS] Finished transaction \(currentCounter) “\(actionName ?? "")” - \(options)") + return result } } func persist( + transactionIndex: Int, actionName: String?, roots: [DatastoreKey : Datastore.RootObject], addedDatastoreRoots: Set, @@ -583,6 +598,8 @@ extension DiskPersistence { /// If nothing changed, don't bother writing anything. if !containsEdits { return } +// print("[CDS] Persisting \(transactionIndex) “\(actionName ?? "")” - \(roots.keys), added \(addedDatastoreRoots), removed \(removedDatastoreRoots)") + /// If we are read-only, make sure no edits have been made guard let self = self as? DiskPersistence else { throw DiskPersistenceError.cannotWrite } diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift index ec64246..e82fb4c 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift @@ -17,8 +17,9 @@ extension DiskPersistence { weak var lastReadWriteChildTransaction: Transaction? private(set) var task: Task! - let options: UnsafeTransactionOptions + let transactionIndex: Int let actionName: String? + let options: UnsafeTransactionOptions var rootObjects: [DatastoreKey : Datastore.RootObject] = [:] @@ -37,6 +38,7 @@ extension DiskPersistence { private init( persistence: DiskPersistence, + transactionIndex: Int, parent: Transaction?, actionName: String?, options: UnsafeTransactionOptions @@ -45,6 +47,7 @@ extension DiskPersistence { self.parent = parent self.actionName = actionName self.options = options + self.transactionIndex = transactionIndex } private func attachTask( @@ -147,6 +150,7 @@ extension DiskPersistence { 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, entryMutations: entryMutations, @@ -176,6 +180,7 @@ extension DiskPersistence { let removedDatastoreRoots = Set(deletedRootObjects.map(\.referenceID)) try await persistence.persist( + transactionIndex: transactionIndex, actionName: actionName, roots: rootObjects, addedDatastoreRoots: addedDatastoreRoots, @@ -197,18 +202,26 @@ extension DiskPersistence { static func makeTransaction( persistence: DiskPersistence, + transactionIndex: Int, lastTransaction: Transaction?, actionName: String?, options: UnsafeTransactionOptions, @_inheritActorContext handler: @Sendable @escaping (_ transaction: Transaction, _ isDurable: Bool) async throws -> T ) async -> (Transaction, Task) { if let parent = Self.unsafeCurrentTransaction { - let (child, task) = await parent.childTransaction(options: options, handler: handler) +// print("[CDS] Found parent, making child \(transactionIndex)") + let (child, task) = await parent.childTransaction( + transactionIndex: transactionIndex, + actionName: actionName, + options: options, + handler: handler + ) return (child, task) } let transaction = Transaction( persistence: persistence, + transactionIndex: transactionIndex, parent: nil, actionName: actionName, options: options @@ -226,12 +239,15 @@ extension DiskPersistence { } func childTransaction( + transactionIndex: Int, + actionName: String?, options: UnsafeTransactionOptions, @_inheritActorContext handler: @Sendable @escaping (_ transaction: Transaction, _ isDurable: Bool) async throws -> T ) async -> (Transaction, Task) { assert(!self.options.contains(.readOnly) || options.contains(.readOnly), "A child transaction was declared read-write, even though its parent was read-only!") let childTransaction = Transaction( persistence: persistence, + transactionIndex: transactionIndex, parent: self, actionName: nil, options: options From 547cb8149c68ec54327fdcf330dc6ff6fe2cfd22 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 22 Dec 2025 04:22:37 -0800 Subject: [PATCH 2/3] Fixed an issue where the action name was not shared with child transactions --- .../Persistence/Disk Persistence/Transaction/Transaction.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift index e82fb4c..7f7c81c 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift @@ -249,7 +249,7 @@ extension DiskPersistence { persistence: persistence, transactionIndex: transactionIndex, parent: self, - actionName: nil, + actionName: actionName, options: options ) From 4a789c7295914f331c94b3839371165f5fadd2e1 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 22 Dec 2025 04:23:05 -0800 Subject: [PATCH 3/3] Added `CustomDebugStringConvertible` conformance to `TransactionOptions` --- .../Persistence/TransactionOptions.swift | 22 +++++- .../TransactionOptionsTests.swift | 75 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/Sources/CodableDatastore/Persistence/TransactionOptions.swift b/Sources/CodableDatastore/Persistence/TransactionOptions.swift index 701c7da..5f2da81 100644 --- a/Sources/CodableDatastore/Persistence/TransactionOptions.swift +++ b/Sources/CodableDatastore/Persistence/TransactionOptions.swift @@ -9,7 +9,7 @@ import Foundation /// A set of options that the caller of a transaction can specify. -public struct TransactionOptions: OptionSet, Sendable { +public struct TransactionOptions: OptionSet, Sendable, CustomDebugStringConvertible { public let rawValue: UInt64 public init(rawValue: UInt64) { @@ -24,12 +24,20 @@ public struct TransactionOptions: OptionSet, Sendable { /// The transaction is idempotent and does not modify any other kind of state, and can be retried when it encounters an inconsistency. This allows a transaction to concurrently operate with other writes, which may be necessary in a disptributed environment. public static let idempotent = Self(rawValue: 1 << 2) + + public var debugDescription: String { + var tokens: [String] = [] + if contains(.readOnly) { tokens.append(".readOnly") } + if contains(.collateWrites) { tokens.append(".collateWrites") } + if contains(.idempotent) { tokens.append(".idempotent") } + return "TransactionOptions([\(tokens.joined(separator: ", "))])" + } } /// A set of options that the caller of a transaction can specify. /// /// These options are generally unsafe to use improperly, and should generally not be used. -public struct UnsafeTransactionOptions: OptionSet, Sendable { +public struct UnsafeTransactionOptions: OptionSet, Sendable, CustomDebugStringConvertible { public let rawValue: UInt64 public init(rawValue: UInt64) { @@ -54,4 +62,14 @@ public struct UnsafeTransactionOptions: OptionSet, Sendable { /// The transaction should persist to storage even if it is a child transaction. Note that this must be the _first_ non-readonly child transaction of a parent transaction to succeed. public static let enforceDurability = Self(rawValue: 1 << 17) + + public var debugDescription: String { + var tokens: [String] = [] + if contains(.readOnly) { tokens.append(".readOnly") } + if contains(.collateWrites) { tokens.append(".collateWrites") } + if contains(.idempotent) { tokens.append(".idempotent") } + if contains(.skipObservations) { tokens.append(".skipObservations") } + if contains(.enforceDurability) { tokens.append(".enforceDurability") } + return "UnsafeTransactionOptions([\(tokens.joined(separator: ", "))])" + } } diff --git a/Tests/CodableDatastoreTests/TransactionOptionsTests.swift b/Tests/CodableDatastoreTests/TransactionOptionsTests.swift index a7dd9cd..bff864b 100644 --- a/Tests/CodableDatastoreTests/TransactionOptionsTests.swift +++ b/Tests/CodableDatastoreTests/TransactionOptionsTests.swift @@ -19,6 +19,15 @@ final class TransactionOptionsTests: XCTestCase { XCTAssertEqual(options.rawValue, expectedRawValue, file: file, line: line) } + func assertTransactionOptions( + _ options: TransactionOptions, + haveDebugString expectedString: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(options.debugDescription, expectedString, file: file, line: line) + } + func testTransactionOptions() { assertTransactionOptions(options: [], expectedRawValue: 0) @@ -39,6 +48,21 @@ final class TransactionOptionsTests: XCTestCase { assertTransactionOptions(options: TransactionOptions(rawValue: 10), expectedRawValue: 2) assertTransactionOptions(options: TransactionOptions(rawValue: 11), expectedRawValue: 3) } + + func testDebugStrings() { + assertTransactionOptions([], haveDebugString: "TransactionOptions([])") + + assertTransactionOptions(.readOnly, haveDebugString: "TransactionOptions([.readOnly])") + assertTransactionOptions(.idempotent, haveDebugString: "TransactionOptions([.idempotent])") + assertTransactionOptions(.collateWrites, haveDebugString: "TransactionOptions([.collateWrites])") + + assertTransactionOptions([.idempotent, .readOnly], haveDebugString: "TransactionOptions([.readOnly, .idempotent])") + assertTransactionOptions([.readOnly, .idempotent], haveDebugString: "TransactionOptions([.readOnly, .idempotent])") + assertTransactionOptions([.readOnly, .collateWrites], haveDebugString: "TransactionOptions([.readOnly, .collateWrites])") + assertTransactionOptions([.idempotent, .collateWrites], haveDebugString: "TransactionOptions([.collateWrites, .idempotent])") + + assertTransactionOptions([.readOnly, .idempotent, .collateWrites], haveDebugString: "TransactionOptions([.readOnly, .collateWrites, .idempotent])") + } } final class UnsafeTransactionOptionsTests: XCTestCase { @@ -51,6 +75,15 @@ final class UnsafeTransactionOptionsTests: XCTestCase { XCTAssertEqual(options.rawValue, expectedRawValue, file: file, line: line) } + func assertUnsafeTransactionOptions( + _ options: UnsafeTransactionOptions, + haveDebugString expectedString: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(options.debugDescription, expectedString, file: file, line: line) + } + func testUnsafeTransactionOptions() { assertUnsafeTransactionOptions(options: [], expectedRawValue: 0) @@ -87,4 +120,46 @@ final class UnsafeTransactionOptionsTests: XCTestCase { assertUnsafeTransactionOptions(options: UnsafeTransactionOptions(TransactionOptions([.readOnly, .collateWrites, .idempotent])), expectedRawValue: 7) } + + func testDebugStrings() { + assertUnsafeTransactionOptions([], haveDebugString: "UnsafeTransactionOptions([])") + + assertUnsafeTransactionOptions(.readOnly, haveDebugString: "UnsafeTransactionOptions([.readOnly])") + assertUnsafeTransactionOptions(.idempotent, haveDebugString: "UnsafeTransactionOptions([.idempotent])") + assertUnsafeTransactionOptions(.collateWrites, haveDebugString: "UnsafeTransactionOptions([.collateWrites])") + assertUnsafeTransactionOptions(.skipObservations, haveDebugString: "UnsafeTransactionOptions([.skipObservations])") + assertUnsafeTransactionOptions(.enforceDurability, haveDebugString: "UnsafeTransactionOptions([.enforceDurability])") + + assertUnsafeTransactionOptions([.idempotent, .readOnly], haveDebugString: "UnsafeTransactionOptions([.readOnly, .idempotent])") + assertUnsafeTransactionOptions([.readOnly, .idempotent], haveDebugString: "UnsafeTransactionOptions([.readOnly, .idempotent])") + + assertUnsafeTransactionOptions([.readOnly, .collateWrites], haveDebugString: "UnsafeTransactionOptions([.readOnly, .collateWrites])") + assertUnsafeTransactionOptions([.readOnly, .skipObservations], haveDebugString: "UnsafeTransactionOptions([.readOnly, .skipObservations])") + assertUnsafeTransactionOptions([.readOnly, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.readOnly, .enforceDurability])") + assertUnsafeTransactionOptions([.collateWrites, .idempotent], haveDebugString: "UnsafeTransactionOptions([.collateWrites, .idempotent])") + assertUnsafeTransactionOptions([.collateWrites, .skipObservations], haveDebugString: "UnsafeTransactionOptions([.collateWrites, .skipObservations])") + assertUnsafeTransactionOptions([.collateWrites, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.collateWrites, .enforceDurability])") + assertUnsafeTransactionOptions([.idempotent, .skipObservations], haveDebugString: "UnsafeTransactionOptions([.idempotent, .skipObservations])") + assertUnsafeTransactionOptions([.idempotent, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.idempotent, .enforceDurability])") + assertUnsafeTransactionOptions([.skipObservations, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.skipObservations, .enforceDurability])") + + assertUnsafeTransactionOptions([.readOnly, .collateWrites, .idempotent], haveDebugString: "UnsafeTransactionOptions([.readOnly, .collateWrites, .idempotent])") + assertUnsafeTransactionOptions([.readOnly, .collateWrites, .skipObservations], haveDebugString: "UnsafeTransactionOptions([.readOnly, .collateWrites, .skipObservations])") + assertUnsafeTransactionOptions([.readOnly, .collateWrites, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.readOnly, .collateWrites, .enforceDurability])") + assertUnsafeTransactionOptions([.readOnly, .idempotent, .skipObservations], haveDebugString: "UnsafeTransactionOptions([.readOnly, .idempotent, .skipObservations])") + assertUnsafeTransactionOptions([.readOnly, .idempotent, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.readOnly, .idempotent, .enforceDurability])") + assertUnsafeTransactionOptions([.readOnly, .skipObservations, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.readOnly, .skipObservations, .enforceDurability])") + assertUnsafeTransactionOptions([.collateWrites, .idempotent, .skipObservations], haveDebugString: "UnsafeTransactionOptions([.collateWrites, .idempotent, .skipObservations])") + assertUnsafeTransactionOptions([.collateWrites, .idempotent, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.collateWrites, .idempotent, .enforceDurability])") + assertUnsafeTransactionOptions([.collateWrites, .skipObservations, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.collateWrites, .skipObservations, .enforceDurability])") + assertUnsafeTransactionOptions([.idempotent, .skipObservations, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.idempotent, .skipObservations, .enforceDurability])") + + assertUnsafeTransactionOptions([.readOnly, .collateWrites, .idempotent, .skipObservations], haveDebugString: "UnsafeTransactionOptions([.readOnly, .collateWrites, .idempotent, .skipObservations])") + assertUnsafeTransactionOptions([.readOnly, .collateWrites, .idempotent, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.readOnly, .collateWrites, .idempotent, .enforceDurability])") + assertUnsafeTransactionOptions([.readOnly, .collateWrites, .skipObservations, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.readOnly, .collateWrites, .skipObservations, .enforceDurability])") + assertUnsafeTransactionOptions([.readOnly, .idempotent, .skipObservations, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.readOnly, .idempotent, .skipObservations, .enforceDurability])") + assertUnsafeTransactionOptions([.collateWrites, .idempotent, .skipObservations, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.collateWrites, .idempotent, .skipObservations, .enforceDurability])") + + assertUnsafeTransactionOptions([.readOnly, .collateWrites, .idempotent, .skipObservations, .enforceDurability], haveDebugString: "UnsafeTransactionOptions([.readOnly, .collateWrites, .idempotent, .skipObservations, .enforceDurability])") + } }