diff --git a/CMakeLists.txt b/CMakeLists.txt index d9236b6..9dd8697 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,7 +11,7 @@ endif() set(FDB_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/bindings/c) set(SWIFT_SDK_DIR "/usr/lib/swift/linux/") -set(SWIFT_SDK_STATIC_DIR "/usr/libexec/swift/6.0.3/lib/swift_static/linux") +set(SWIFT_SDK_STATIC_DIR "/usr/lib/swift_static/linux") file(GLOB_RECURSE SWIFT_BINDING_SOURCES "Sources/**/*.swift") add_library(FoundationDB-Swift ${SWIFT_BINDING_SOURCES}) @@ -71,10 +71,22 @@ set_target_properties(FoundationDB-Swift-Tests PROPERTIES Swift_MODULE_NAME "FoundationDBTests" ) -install(TARGETS FoundationDB-Swift +add_executable(stacktester_swift + Tests/StackTester/Sources/StackTester/StackTester.swift + Tests/StackTester/Sources/StackTester/main.swift +) +add_dependencies(stacktester_swift FoundationDB-Swift) +target_link_libraries(stacktester_swift PRIVATE FoundationDB-Swift) + +set_target_properties(stacktester_swift PROPERTIES + Swift_MODULE_NAME "StackTester" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bindings/swift/bin" +) + +install(TARGETS FoundationDB-Swift stacktester_swift + RUNTIME DESTINATION bindings/swift/bin ARCHIVE DESTINATION lib LIBRARY DESTINATION lib ) -# Configure test to run with Swift Testing add_test(NAME FoundationDB-Swift-Tests COMMAND FoundationDB-Swift-Tests) diff --git a/Package.swift b/Package.swift index 41343d2..f5e7eb2 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,8 @@ let package = Package( .macOS(.v14) ], products: [ - .library(name: "FoundationDB", targets: ["FoundationDB"]) + .library(name: "FoundationDB", targets: ["FoundationDB"]), + .executable(name: "stacktester", targets: ["StackTester"]) ], targets: [ .systemLibrary( @@ -45,5 +46,10 @@ let package = Package( name: "FoundationDBTests", dependencies: ["FoundationDB"] ), + .executableTarget( + name: "StackTester", + dependencies: ["FoundationDB"], + path: "Tests/StackTester/Sources/StackTester" + ), ] ) diff --git a/Sources/CFoundationDB/fdb_c_wrapper.h b/Sources/CFoundationDB/fdb_c_wrapper.h index 77515ab..4f61c56 100644 --- a/Sources/CFoundationDB/fdb_c_wrapper.h +++ b/Sources/CFoundationDB/fdb_c_wrapper.h @@ -21,7 +21,7 @@ #ifndef FDB_C_WRAPPER_H #define FDB_C_WRAPPER_H -#define FDB_API_VERSION 730 +#define FDB_API_VERSION FDB_LATEST_API_VERSION #include #endif diff --git a/Sources/FoundationDB/Client.swift b/Sources/FoundationDB/Client.swift index 88c82b3..e5d05a7 100644 --- a/Sources/FoundationDB/Client.swift +++ b/Sources/FoundationDB/Client.swift @@ -37,8 +37,8 @@ import CFoundationDB public class FdbClient { /// FoundationDB API version constants. public enum APIVersion { - /// The current supported API version (730). - public static let current: Int32 = 730 + /// The current supported API version (710). + public static let current: Int32 = 710 } /// Initializes the FoundationDB client with the specified API version. diff --git a/Sources/FoundationDB/Database.swift b/Sources/FoundationDB/Database.swift index 354b487..f31a63f 100644 --- a/Sources/FoundationDB/Database.swift +++ b/Sources/FoundationDB/Database.swift @@ -66,4 +66,53 @@ public class FdbDatabase: IDatabase { return FdbTransaction(transaction: tr) } + + /// Sets a database option with a byte array value. + /// + /// - Parameters: + /// - option: The database option to set. + /// - value: The value for the option (optional). + /// - Throws: `FdbError` if the option cannot be set. + public func setOption(_ option: Fdb.DatabaseOption, value: Fdb.Value? = nil) throws { + let error: Int32 + if let value = value { + error = value.withUnsafeBytes { bytes in + fdb_database_set_option( + database, + FDBDatabaseOption(option.rawValue), + bytes.bindMemory(to: UInt8.self).baseAddress, + Int32(value.count) + ) + } + } else { + error = fdb_database_set_option(database, FDBDatabaseOption(option.rawValue), nil, 0) + } + + if error != 0 { + throw FdbError(code: error) + } + } + + /// Sets a database option with a string value. + /// + /// - Parameters: + /// - option: The database option to set. + /// - value: The string value for the option. + /// - Throws: `FdbError` if the option cannot be set. + public func setOption(_ option: Fdb.DatabaseOption, value: String) throws { + try setOption(option, value: Array(value.utf8)) + } + + /// Sets a database option with an integer value. + /// + /// - Parameters: + /// - option: The database option to set. + /// - value: The integer value for the option. + /// - Throws: `FdbError` if the option cannot be set. + public func setOption(_ option: Fdb.DatabaseOption, value: Int) throws { + var val = Int64(value).littleEndian + try withUnsafeBytes(of: &val) { bytes in + try setOption(option, value: Array(bytes)) + } + } } diff --git a/Sources/FoundationDB/Fdb+Options.swift b/Sources/FoundationDB/Fdb+Options.swift index 74341a0..deb9b86 100644 --- a/Sources/FoundationDB/Fdb+Options.swift +++ b/Sources/FoundationDB/Fdb+Options.swift @@ -473,7 +473,7 @@ public extension Fdb { } /** Conflict range types used internally by the C API. */ - enum ConflictRangeType: UInt32, @unchecked Sendable { + public enum ConflictRangeType: UInt32, @unchecked Sendable { /** Used to add a read conflict range */ case read = 0 diff --git a/Sources/FoundationDB/FoundationdDB.swift b/Sources/FoundationDB/FoundationdDB.swift index 4ff90ff..7d53c44 100644 --- a/Sources/FoundationDB/FoundationdDB.swift +++ b/Sources/FoundationDB/FoundationdDB.swift @@ -218,6 +218,56 @@ public protocol ITransaction: Sendable { /// - Throws: `FdbError` if the operation fails. func getReadVersion() async throws -> Int64 + /// Handles transaction errors and implements retry logic with exponential backoff. + /// + /// If this method returns successfully, the transaction has been reset and can be retried. + /// If it throws an error, the transaction should not be retried. + /// + /// - Parameter error: The error encountered during transaction execution. + /// - Throws: `FdbError` if the error is not retryable or retry limits have been exceeded. + func onError(_ error: FdbError) async throws + + /// Returns an estimated byte size of the specified key range. + /// + /// The estimate is calculated based on sampling done by FDB server. Larger key-value pairs + /// are more likely to be sampled. For accuracy, use on large ranges (>3MB recommended). + /// + /// - Parameters: + /// - beginKey: The start of the range (inclusive). + /// - endKey: The end of the range (exclusive). + /// - Returns: The estimated size in bytes. + /// - Throws: `FdbError` if the operation fails. + func getEstimatedRangeSizeBytes(beginKey: Fdb.Key, endKey: Fdb.Key) async throws -> Int64 + + /// Returns a list of keys that can split the given range into roughly equal chunks. + /// + /// The returned split points include the start and end keys of the range. + /// + /// - Parameters: + /// - beginKey: The start of the range. + /// - endKey: The end of the range. + /// - chunkSize: The desired size of each chunk in bytes. + /// - Returns: An array of keys representing split points. + /// - Throws: `FdbError` if the operation fails. + func getRangeSplitPoints(beginKey: Fdb.Key, endKey: Fdb.Key, chunkSize: Int64) async throws -> [[UInt8]] + + /// Returns the version number at which a committed transaction modified the database. + /// + /// Must only be called after a successful commit. Read-only transactions return -1. + /// + /// - Returns: The committed version number. + /// - Throws: `FdbError` if called before commit or if the operation fails. + func getCommittedVersion() throws -> Int64 + + /// Returns the approximate transaction size so far. + /// + /// This is the sum of estimated sizes of mutations, read conflict ranges, and write conflict ranges. + /// Can be called multiple times before commit. + /// + /// - Returns: The approximate size in bytes. + /// - Throws: `FdbError` if the operation fails. + func getApproximateSize() async throws -> Int64 + /// Performs an atomic operation on a key. /// /// - Parameters: @@ -226,6 +276,18 @@ public protocol ITransaction: Sendable { /// - mutationType: The type of atomic operation to perform. func atomicOp(key: Fdb.Key, param: Fdb.Value, mutationType: Fdb.MutationType) + /// Adds a conflict range to the transaction. + /// + /// Conflict ranges are used to manually declare the read and write sets of the transaction. + /// This can be useful for ensuring serializability when certain keys are accessed indirectly. + /// + /// - Parameters: + /// - beginKey: The start of the range (inclusive) as a byte array. + /// - endKey: The end of the range (exclusive) as a byte array. + /// - type: The type of conflict range (read or write). + /// - Throws: `FdbError` if the operation fails. + func addConflictRange(beginKey: Fdb.Key, endKey: Fdb.Key, type: Fdb.ConflictRangeType) throws + // MARK: - Transaction option methods /// Sets a transaction option with an optional value. diff --git a/Sources/FoundationDB/Future.swift b/Sources/FoundationDB/Future.swift index 8ca9bae..b94b78f 100644 --- a/Sources/FoundationDB/Future.swift +++ b/Sources/FoundationDB/Future.swift @@ -163,6 +163,28 @@ struct ResultVersion: FutureResult { } } +/// A result type for futures that return 64-bit integer values. +/// +/// Used for operations that return size estimates or counts. +struct ResultInt64: FutureResult { + /// The extracted integer value. + let value: Int64 + + /// Extracts an Int64 from the future. + /// + /// - Parameter fromFuture: The C future containing the integer. + /// - Returns: A `ResultInt64` with the extracted value. + /// - Throws: `FdbError` if the future contains an error. + static func extract(fromFuture: CFuturePtr) throws -> Self? { + var value: Int64 = 0 + let err = fdb_future_get_int64(fromFuture, &value) + if err != 0 { + throw FdbError(code: err) + } + return Self(value: value) + } +} + /// A result type for futures that return key data. /// /// Used for operations like key selectors that resolve to actual keys. @@ -230,9 +252,9 @@ struct ResultValue: FutureResult { /// with information about whether more data is available. public struct ResultRange: FutureResult { /// The array of key-value pairs returned by the range operation. - let records: Fdb.KeyValueArray + public let records: Fdb.KeyValueArray /// Indicates whether there are more records beyond this result. - let more: Bool + public let more: Bool /// Extracts key-value pairs from a range future. /// @@ -264,3 +286,39 @@ public struct ResultRange: FutureResult { return Self(records: keyValueArray, more: more > 0) } } + +/// A result type for futures that return arrays of keys. +/// +/// Used for operations like get range split points that return multiple keys. +struct ResultKeyArray: FutureResult { + /// The array of keys returned by the operation. + let value: [[UInt8]] + + /// Extracts an array of keys from the future. + /// + /// - Parameter fromFuture: The C future containing the key array. + /// - Returns: A `ResultKeyArray` with the extracted keys. + /// - Throws: `FdbError` if the future contains an error. + static func extract(fromFuture: CFuturePtr) throws -> Self? { + var keysPtr: UnsafePointer? + var count: Int32 = 0 + + let err = fdb_future_get_key_array(fromFuture, &keysPtr, &count) + if err != 0 { + throw FdbError(code: err) + } + + guard let keysPtr = keysPtr, count > 0 else { + return Self(value: []) + } + + var keyArray: [[UInt8]] = [] + for i in 0 ..< Int(count) { + let fdbKey = keysPtr[i] + let key = Array(UnsafeBufferPointer(start: fdbKey.key, count: Int(fdbKey.key_length))) + keyArray.append(key) + } + + return Self(value: keyArray) + } +} diff --git a/Sources/FoundationDB/Network.swift b/Sources/FoundationDB/Network.swift index 756e557..1e8d560 100644 --- a/Sources/FoundationDB/Network.swift +++ b/Sources/FoundationDB/Network.swift @@ -36,16 +36,16 @@ import CFoundationDB /// let network = FdbNetwork.shared /// try network.initialize(version: 740) /// ``` -// TODO: stopNetwork at deinit. @MainActor class FdbNetwork { /// The shared singleton instance of the network manager. static let shared = FdbNetwork() /// Indicates whether the network has been set up. - private var networkSetup = false + private nonisolated(unsafe) var networkSetup = false + /// The pthread handle for the network thread. - private var networkThread: pthread_t? = nil + private nonisolated(unsafe) var networkThread: pthread_t? = nil /// Initializes the FoundationDB network with the specified API version. /// @@ -65,6 +65,23 @@ class FdbNetwork { startNetwork() } + /// Stops the FoundationDB network and waits for the network thread to complete. + deinit { + if !networkSetup { + return + } + + // Call stop_network and wait for network thread to complete + let error = fdb_stop_network() + if error != 0 { + print("Failed to stop network in deinit: \(FdbError(code: error).description)") + } + + if let thread = networkThread { + pthread_join(thread, nil) + } + } + /// Selects the FoundationDB API version. /// /// - Parameter version: The API version to select. @@ -117,21 +134,6 @@ class FdbNetwork { } } - /// Stops the FoundationDB network and waits for the network thread to complete. - /// - /// - Throws: `FdbError` if the network cannot be stopped cleanly. - func stopNetwork() throws { - let error = fdb_stop_network() - if error != 0 { - throw FdbError(code: error) - } - - if networkSetup { - pthread_join(networkThread!, nil) - } - networkSetup = false - } - /// Sets a network option with an optional byte array value. /// /// - Parameters: diff --git a/Sources/FoundationDB/Transaction.swift b/Sources/FoundationDB/Transaction.swift index 7d6e4f3..c0c52f6 100644 --- a/Sources/FoundationDB/Transaction.swift +++ b/Sources/FoundationDB/Transaction.swift @@ -157,6 +157,79 @@ public class FdbTransaction: ITransaction, @unchecked Sendable { ).getAsync()?.value ?? 0 } + public func onError(_ error: FdbError) async throws { + try await Future( + fdb_transaction_on_error(transaction, error.code) + ).getAsync() + } + + public func getEstimatedRangeSizeBytes(beginKey: Fdb.Key, endKey: Fdb.Key) async throws -> Int64 { + try await beginKey.withUnsafeBytes { beginKeyBytes in + endKey.withUnsafeBytes { endKeyBytes in + Future( + fdb_transaction_get_estimated_range_size_bytes( + transaction, + beginKeyBytes.bindMemory(to: UInt8.self).baseAddress, + Int32(beginKey.count), + endKeyBytes.bindMemory(to: UInt8.self).baseAddress, + Int32(endKey.count) + ) + ) + } + }.getAsync()?.value ?? 0 + } + + public func getRangeSplitPoints(beginKey: Fdb.Key, endKey: Fdb.Key, chunkSize: Int64) async throws -> [[UInt8]] { + try await beginKey.withUnsafeBytes { beginKeyBytes in + endKey.withUnsafeBytes { endKeyBytes in + Future( + fdb_transaction_get_range_split_points( + transaction, + beginKeyBytes.bindMemory(to: UInt8.self).baseAddress, + Int32(beginKey.count), + endKeyBytes.bindMemory(to: UInt8.self).baseAddress, + Int32(endKey.count), + chunkSize + ) + ) + } + }.getAsync()?.value ?? [] + } + + public func getCommittedVersion() throws -> Int64 { + var version: Int64 = 0 + let err = fdb_transaction_get_committed_version(transaction, &version) + if err != 0 { + throw FdbError(code: err) + } + return version + } + + public func getApproximateSize() async throws -> Int64 { + try await Future( + fdb_transaction_get_approximate_size(transaction) + ).getAsync()?.value ?? 0 + } + + public func addConflictRange(beginKey: Fdb.Key, endKey: Fdb.Key, type: Fdb.ConflictRangeType) throws { + let error = beginKey.withUnsafeBytes { beginKeyBytes in + endKey.withUnsafeBytes { endKeyBytes in + fdb_transaction_add_conflict_range( + transaction, + beginKeyBytes.bindMemory(to: UInt8.self).baseAddress, + Int32(beginKey.count), + endKeyBytes.bindMemory(to: UInt8.self).baseAddress, + Int32(endKey.count), + FDBConflictRangeType(rawValue: type.rawValue) + ) + } + } + + if error != 0 { + throw FdbError(code: error) + } + } + public func getRange( beginSelector: Fdb.KeySelector, endSelector: Fdb.KeySelector, limit: Int32 = 0, snapshot: Bool diff --git a/Tests/FoundationDBTests/FoundationDBTests.swift b/Tests/FoundationDBTests/FoundationDBTests.swift index 6240f03..e6ebc48 100644 --- a/Tests/FoundationDBTests/FoundationDBTests.swift +++ b/Tests/FoundationDBTests/FoundationDBTests.swift @@ -1136,31 +1136,6 @@ func atomicOpByteMax() async throws { #expect(resultString == "zebra", "byte_max should choose lexicographically larger value") } -@Test("network option setting - method validation") -func networkOptionMethods() throws { - // Test that network option methods accept different parameter types - // Note: These tests verify the API works but don't actually set options - // since network initialization happens globally - - // Test Data parameter - let data = [UInt8]("test_value".utf8) - // This would normally throw if the method signature was wrong - - // Test String parameter - _ = "test_string" - // This would normally throw if the method signature was wrong - - // Test Int parameter - _ = 1_048_576 - // This would normally throw if the method signature was wrong - - // Test no parameter (for boolean options) - // This would normally throw if the method signature was wrong - - // If we get here, the method signatures are correct - #expect(data.count > 0, "Network option method signatures are valid") -} - @Test("network option enum values") func networkOptionEnumValues() { // Test that network option enum has expected values @@ -1452,3 +1427,238 @@ extension String { return String(repeating: pad, count: toLength - count) + self } } + +@Test("getEstimatedRangeSizeBytes returns size estimate") +func testGetEstimatedRangeSizeBytes() async throws { + try await FdbClient.initialize() + let database = try FdbClient.openDatabase() + + // Write some test data + try await database.withTransaction { transaction in + transaction.clearRange(beginKey: "test_size_", endKey: "test_size`") + for i in 0..<100 { + let key = "test_size_\(String.padded(i))" + let value = String(repeating: "x", count: 1000) + transaction.setValue(value, for: key) + } + return () + } + + // Get estimated size + let transaction = try database.createTransaction() + let beginKey: Fdb.Key = Array("test_size_".utf8) + let endKey: Fdb.Key = Array("test_size`".utf8) + let estimatedSize = try await transaction.getEstimatedRangeSizeBytes(beginKey: beginKey, endKey: endKey) + + // Size should be positive (may not be exact due to sampling) + #expect(estimatedSize >= 0, "Estimated size should be non-negative") +} + +@Test("getRangeSplitPoints returns split keys") +func testGetRangeSplitPoints() async throws { + try await FdbClient.initialize() + let database = try FdbClient.openDatabase() + + // Write some test data + try await database.withTransaction { transaction in + transaction.clearRange(beginKey: "test_split_", endKey: "test_split`") + for i in 0..<200 { + let key = "test_split_\(String.padded(i))" + let value = String(repeating: "y", count: 500) + transaction.setValue(value, for: key) + } + return () + } + + // Get split points with 10KB chunks + let transaction = try database.createTransaction() + let beginKey: Fdb.Key = Array("test_split_".utf8) + let endKey: Fdb.Key = Array("test_split`".utf8) + let splitPoints = try await transaction.getRangeSplitPoints( + beginKey: beginKey, + endKey: endKey, + chunkSize: 10000 + ) + + // Should return at least begin and end keys + #expect(splitPoints.count >= 2, "Should return at least begin and end keys") + #expect(splitPoints.first == beginKey, "First split point should be begin key") + #expect(splitPoints.last == endKey, "Last split point should be end key") +} + +@Test("getCommittedVersion returns version after commit") +func testGetCommittedVersion() async throws { + try await FdbClient.initialize() + let database = try FdbClient.openDatabase() + let transaction = try database.createTransaction() + + transaction.setValue("test_value", for: "test_version_key") + _ = try await transaction.commit() + + let committedVersion = try transaction.getCommittedVersion() + #expect(committedVersion > 0, "Committed version should be positive for write transaction") +} + +@Test("getCommittedVersion returns -1 for read-only transaction") +func testGetCommittedVersionReadOnly() async throws { + try await FdbClient.initialize() + let database = try FdbClient.openDatabase() + let transaction = try database.createTransaction() + + // Read-only transaction + _ = try await transaction.getValue(for: "test_readonly_key") + _ = try await transaction.commit() + + let committedVersion = try transaction.getCommittedVersion() + #expect(committedVersion == -1, "Read-only transaction should return -1") +} + +@Test("getApproximateSize returns transaction size") +func testGetApproximateSize() async throws { + try await FdbClient.initialize() + let database = try FdbClient.openDatabase() + let transaction = try database.createTransaction() + + // Initial size + let initialSize = try await transaction.getApproximateSize() + #expect(initialSize >= 0, "Initial size should be non-negative") + + // Add some mutations + for i in 0..<10 { + let key = "test_approx_\(i)" + let value = String(repeating: "z", count: 100) + transaction.setValue(value, for: key) + } + + // Size should increase + let sizeAfterMutations = try await transaction.getApproximateSize() + #expect(sizeAfterMutations > initialSize, "Size should increase after mutations") +} + +@Test("addConflictRange read conflict") +func testAddReadConflictRange() async throws { + try await FdbClient.initialize() + let database = try FdbClient.openDatabase() + + // Clear test key range + let clearTransaction = try database.createTransaction() + clearTransaction.clearRange(beginKey: "test_conflict_", endKey: "test_conflict`") + _ = try await clearTransaction.commit() + + // Set up initial data + let setupTransaction = try database.createTransaction() + setupTransaction.setValue("initial_value", for: "test_conflict_a") + _ = try await setupTransaction.commit() + + // Test adding read conflict range + let transaction = try database.createTransaction() + let beginKey: Fdb.Key = Array("test_conflict_a".utf8) + let endKey: Fdb.Key = Array("test_conflict_b".utf8) + + // Add read conflict range - should succeed + try transaction.addConflictRange(beginKey: beginKey, endKey: endKey, type: .read) + + // Should be able to commit successfully + let result = try await transaction.commit() + #expect(result == true, "Transaction with read conflict range should commit successfully") +} + +@Test("addConflictRange write conflict") +func testAddWriteConflictRange() async throws { + try await FdbClient.initialize() + let database = try FdbClient.openDatabase() + + // Clear test key range + let clearTransaction = try database.createTransaction() + clearTransaction.clearRange(beginKey: "test_write_conflict_", endKey: "test_write_conflict`") + _ = try await clearTransaction.commit() + + // Test adding write conflict range + let transaction = try database.createTransaction() + let beginKey: Fdb.Key = Array("test_write_conflict_a".utf8) + let endKey: Fdb.Key = Array("test_write_conflict_b".utf8) + + // Add write conflict range - should succeed + try transaction.addConflictRange(beginKey: beginKey, endKey: endKey, type: .write) + + // Should be able to commit successfully + let result = try await transaction.commit() + #expect(result == true, "Transaction with write conflict range should commit successfully") +} + +@Test("addConflictRange detects concurrent write conflicts") +func testConflictRangeDetectsConcurrentWrites() async throws { + try await FdbClient.initialize() + let database = try FdbClient.openDatabase() + + // Clear test key range + let clearTransaction = try database.createTransaction() + clearTransaction.clearRange(beginKey: "test_concurrent_", endKey: "test_concurrent`") + _ = try await clearTransaction.commit() + + // Set up initial data + let setupTransaction = try database.createTransaction() + setupTransaction.setValue("initial", for: "test_concurrent_key") + _ = try await setupTransaction.commit() + + // Create first transaction and add a write conflict range + let transaction1 = try database.createTransaction() + let beginKey: Fdb.Key = Array("test_concurrent_key".utf8) + var endKey: Fdb.Key = beginKey + endKey.append(0x00) + + try transaction1.addConflictRange(beginKey: beginKey, endKey: endKey, type: .write) + + // Create second transaction that writes to the same key + let transaction2 = try database.createTransaction() + transaction2.setValue("modified_by_tr2", for: "test_concurrent_key") + _ = try await transaction2.commit() + + // Now try to commit transaction1 - it should detect a conflict + do { + _ = try await transaction1.commit() + // If it succeeds, that's also acceptable behavior + } catch let error as FdbError { + // Expected to fail with a conflict error (not_committed) + #expect(error.isRetryable, "Conflict should be a retryable error") + } +} + +@Test("addConflictRange multiple ranges") +func testAddMultipleConflictRanges() async throws { + try await FdbClient.initialize() + let database = try FdbClient.openDatabase() + + // Clear test key range + let clearTransaction = try database.createTransaction() + clearTransaction.clearRange(beginKey: "test_multi_", endKey: "test_multi`") + _ = try await clearTransaction.commit() + + // Test adding multiple conflict ranges + let transaction = try database.createTransaction() + + // Add multiple read conflict ranges + let beginKey1: Fdb.Key = Array("test_multi_a".utf8) + let endKey1: Fdb.Key = Array("test_multi_b".utf8) + try transaction.addConflictRange(beginKey: beginKey1, endKey: endKey1, type: .read) + + let beginKey2: Fdb.Key = Array("test_multi_c".utf8) + let endKey2: Fdb.Key = Array("test_multi_d".utf8) + try transaction.addConflictRange(beginKey: beginKey2, endKey: endKey2, type: .read) + + // Add write conflict range + let beginKey3: Fdb.Key = Array("test_multi_x".utf8) + let endKey3: Fdb.Key = Array("test_multi_y".utf8) + try transaction.addConflictRange(beginKey: beginKey3, endKey: endKey3, type: .write) + + // Should be able to commit with multiple conflict ranges + let result = try await transaction.commit() + #expect(result == true, "Transaction with multiple conflict ranges should commit successfully") +} + +@Test("ConflictRangeType enum values") +func testConflictRangeTypeValues() { + // Test that conflict range type enum has expected values + #expect(Fdb.ConflictRangeType.read.rawValue == 0, "read should have value 0") + #expect(Fdb.ConflictRangeType.write.rawValue == 1, "write should have value 1") +} diff --git a/Tests/StackTester/Sources/StackTester/StackTester.swift b/Tests/StackTester/Sources/StackTester/StackTester.swift new file mode 100644 index 0000000..48fd172 --- /dev/null +++ b/Tests/StackTester/Sources/StackTester/StackTester.swift @@ -0,0 +1,726 @@ +/* + * StackTester.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2013-2024 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import FoundationDB + +// Simple stack entry - equivalent to Go's stackEntry +struct StackEntry { + let item: Any + let idx: Int +} + +class StackMachine { + private let prefix: [UInt8] + private var stack: [StackEntry] = [] + private var database: FdbDatabase + private let verbose: Bool + private var transaction: (any ITransaction)? + private var transactionMap: [String: any ITransaction] = [:] + private var transactionName: String = "MAIN" + private var lastVersion: Int64 = 0 + + init(prefix: [UInt8], database: FdbDatabase, verbose: Bool) { + self.prefix = prefix + self.database = database + self.verbose = verbose + } + + // Equivalent to Go's waitAndPop with error handling + func waitAndPop() -> StackEntry { + guard !stack.isEmpty else { + fatalError("Stack is empty") + } + + let ret = stack.removeLast() + + // Handle futures and convert types like in Go + switch ret.item { + case let data as [UInt8]: + return StackEntry(item: data, idx: ret.idx) + case let string as String: + return StackEntry(item: Array(string.utf8), idx: ret.idx) + case let int as Int64: + return StackEntry(item: int, idx: ret.idx) + default: + return ret + } + } + + // Equivalent to Go's store + func store(_ idx: Int, _ item: Any) { + stack.append(StackEntry(item: item, idx: idx)) + } + + // Get current transaction (create if needed) + func currentTransaction() throws -> any ITransaction { + if let existingTransaction = transactionMap[transactionName] { + return existingTransaction + } + + // Create new transaction if it doesn't exist + let newTransaction = try database.createTransaction() + transactionMap[transactionName] = newTransaction + return newTransaction + } + + // Create a new transaction for the current transaction name + func newTransaction() throws { + let newTransaction = try database.createTransaction() + transactionMap[transactionName] = newTransaction + } + + // Switch to a different transaction by name + func switchTransaction(_ name: [UInt8]) throws { + let nameString = String(bytes: name, encoding: .utf8) ?? "MAIN" + transactionName = nameString + + // Create transaction if it doesn't exist + if transactionMap[transactionName] == nil { + try newTransaction() + } + } + + // Helper method to pack range results like Python's push_range + func pushRange(_ idx: Int, _ records: [(key: [UInt8], value: [UInt8])], prefixFilter: [UInt8]? = nil) { + var kvs: [any TupleElement] = [] + for (key, value) in records { + if let prefix = prefixFilter { + if key.starts(with: prefix) { + kvs.append(key) + kvs.append(value) + } + } else { + kvs.append(key) + kvs.append(value) + } + } + let tuple = Tuple(kvs) + store(idx, tuple.encode()) + } + + // Helper method to filter key results with prefix + func filterKeyResult(_ key: [UInt8], prefix: [UInt8]) -> [UInt8] { + if key.starts(with: prefix) { + return key + } else if key.lexicographicallyPrecedes(prefix) { + return prefix + } else { + return prefix + [0xFF] + } + } + + // Helper method to log a batch of stack entries + func logStackBatch(_ entries: [(stackIndex: Int, entry: StackEntry)], prefix: [UInt8]) async throws { + try await database.withTransaction { transaction in + for (stackIndex, entry) in entries { + // Create key: prefix + tuple(stackIndex, entry.idx) + let keyTuple = Tuple([Int64(stackIndex), Int64(entry.idx)]) + var key = prefix + key.append(contentsOf: keyTuple.encode()) + + // Pack value as a tuple (matching Python/Go behavior) + let valueTuple: Tuple + if let data = entry.item as? [UInt8] { + valueTuple = Tuple([data]) + } else if let str = entry.item as? String { + valueTuple = Tuple([str]) + } else if let int = entry.item as? Int64 { + valueTuple = Tuple([int]) + } else { + valueTuple = Tuple([Array("UNKNOWN_ITEM".utf8)]) + } + + var packedValue = valueTuple.encode() + + // Limit value size to 40000 bytes + let maxSize = 40000 + if packedValue.count > maxSize { + packedValue = Array(packedValue.prefix(maxSize)) + } + + transaction.setValue(packedValue, for: key) + } + return () + } + } + + // Process a single instruction - subset of Go's processInst + func processInstruction(_ idx: Int, _ instruction: [Any]) async throws { + guard let op = instruction.first as? String else { + fatalError("Invalid instruction format") + } + + if verbose { + print("\(idx). Instruction is \(op)") + print("Stack: [\(stack.map { "\($0.item)" }.joined(separator: ", "))] (\(stack.count))") + } + + switch op { + case "PUSH": + assert(instruction.count > 1) + store(idx, instruction[1]) + + case "POP": + assert(!stack.isEmpty) + let _ = waitAndPop() + + case "DUP": + assert(!stack.isEmpty) + let entry = stack.last! + store(entry.idx, entry.item) + + case "EMPTY_STACK": + stack.removeAll() + + case "SWAP": + assert(!stack.isEmpty) + let swapIdx = waitAndPop().item as! Int64 + let lastIdx = stack.count - 1 + let targetIdx = lastIdx - Int(swapIdx) + assert(targetIdx >= 0 && targetIdx < stack.count) + stack.swapAt(lastIdx, targetIdx) + + case "SUB": + assert(stack.count >= 2) + let x = waitAndPop().item as! Int64 + let y = waitAndPop().item as! Int64 + store(idx, x - y) + + case "CONCAT": + assert(stack.count >= 2) + let str1 = waitAndPop().item + let str2 = waitAndPop().item + + if let s1 = str1 as? String, let s2 = str2 as? String { + store(idx, s1 + s2) + } else if let d1 = str1 as? [UInt8], let d2 = str2 as? [UInt8] { + store(idx, d1 + d2) + } else { + fatalError("Invalid CONCAT parameters") + } + + case "NEW_TRANSACTION": + try newTransaction() + + case "USE_TRANSACTION": + let name = waitAndPop().item as! [UInt8] + try switchTransaction(name) + + case "ON_ERROR": + let errorCode = waitAndPop().item as! Int64 + let transaction = try currentTransaction() + + // Create FdbError from the error code + let error = FdbError(code: Int32(errorCode)) + + // Call onError which will wait and handle the error appropriately + do { + try await transaction.onError(error) + // If onError succeeds, the transaction has been reset and is ready to retry + store(idx, Array("RESULT_NOT_PRESENT".utf8)) + } catch { + // If onError fails, store the error (transaction should not be retried) + throw error + } + + case "GET_READ_VERSION": + let transaction = try currentTransaction() + lastVersion = try await transaction.getReadVersion() + store(idx, Array("GOT_READ_VERSION".utf8)) + + case "SET": + assert(stack.count >= 2) + let key = waitAndPop().item as! [UInt8] + let value = waitAndPop().item as! [UInt8] + + try await database.withTransaction { transaction in + transaction.setValue(value, for: key) + return () + } + + case "GET": + assert(!stack.isEmpty) + let key = waitAndPop().item as! [UInt8] + + let result = try await database.withTransaction { transaction in + return try await transaction.getValue(for: key, snapshot: false) + } + + if let value = result { + store(idx, value) + } else { + store(idx, Array("RESULT_NOT_PRESENT".utf8)) + } + + case "LOG_STACK": + assert(!stack.isEmpty) + let logPrefix = waitAndPop().item as! [UInt8] + + // Process stack in batches of 100 like Python/Go implementations + var entries: [(stackIndex: Int, entry: StackEntry)] = [] + var stackIndex = stack.count - 1 + + while !stack.isEmpty { + let entry = waitAndPop() + entries.append((stackIndex: stackIndex, entry: entry)) + stackIndex -= 1 + + if entries.count == 100 { + try await logStackBatch(entries, prefix: logPrefix) + entries.removeAll() + } + } + + // Log remaining entries + if !entries.isEmpty { + try await logStackBatch(entries, prefix: logPrefix) + } + + case "COMMIT": + let transaction = try currentTransaction() + let success = try await transaction.commit() + store(idx, Array("COMMIT_RESULT".utf8)) + + case "RESET": + if let transaction = transactionMap[transactionName] as? FdbTransaction { + try newTransaction() + } + + case "CANCEL": + if let transaction = transactionMap[transactionName] { + transaction.cancel() + } + + case "GET_KEY": + // Python order: key, or_equal, offset, prefix = inst.pop(4) + let prefix = waitAndPop().item as! [UInt8] + let offset = Int32(waitAndPop().item as! Int64) + let orEqual = (waitAndPop().item as! Int64) != 0 + let key = waitAndPop().item as! [UInt8] + + let selector = Fdb.KeySelector(key: key, orEqual: orEqual, offset: offset) + let transaction = try currentTransaction() + + if let resultKey = try await transaction.getKey(selector: selector, snapshot: false) { + let filteredKey = filterKeyResult(resultKey, prefix: prefix) + store(idx, filteredKey) + } else { + store(idx, Array("RESULT_NOT_PRESENT".utf8)) + } + + case "GET_RANGE": + // Python/Go order: begin, end, limit, reverse, mode (but Go pops in reverse) + // Go pops: mode, reverse, limit, endKey, beginKey + let mode = waitAndPop().item as! Int64 // Streaming mode, ignore for now + let reverse = (waitAndPop().item as! Int64) != 0 + let limit = Int32(waitAndPop().item as! Int64) + let endKey = waitAndPop().item as! [UInt8] + let beginKey = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + + let result = try await transaction.getRange( + beginKey: beginKey, + endKey: endKey, + limit: limit, + snapshot: false + ) + + pushRange(idx, result.records) + + case "GET_RANGE_STARTS_WITH": + // Python order: prefix, limit, reverse, mode (pops 4 parameters) + // Go order: same but pops in reverse + let mode = waitAndPop().item as! Int64 // Streaming mode, ignore for now + let reverse = (waitAndPop().item as! Int64) != 0 + let limit = Int32(waitAndPop().item as! Int64) + let prefix = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + + var endKey = prefix + endKey.append(0xFF) + + let result = try await transaction.getRange( + beginKey: prefix, + endKey: endKey, + limit: limit, + snapshot: false + ) + + pushRange(idx, result.records) + + case "GET_RANGE_SELECTOR": + // Python pops 10 parameters: begin_key, begin_or_equal, begin_offset, end_key, end_or_equal, end_offset, limit, reverse, mode, prefix + // Go pops in reverse order + let prefix = waitAndPop().item as! [UInt8] + let mode = waitAndPop().item as! Int64 // Streaming mode, ignore for now + let reverse = (waitAndPop().item as! Int64) != 0 + let limit = Int32(waitAndPop().item as! Int64) + let endOffset = Int32(waitAndPop().item as! Int64) + let endOrEqual = (waitAndPop().item as! Int64) != 0 + let endKey = waitAndPop().item as! [UInt8] + let beginOffset = Int32(waitAndPop().item as! Int64) + let beginOrEqual = (waitAndPop().item as! Int64) != 0 + let beginKey = waitAndPop().item as! [UInt8] + + let beginSelector = Fdb.KeySelector(key: beginKey, orEqual: beginOrEqual, offset: beginOffset) + let endSelector = Fdb.KeySelector(key: endKey, orEqual: endOrEqual, offset: endOffset) + let transaction = try currentTransaction() + + let result = try await transaction.getRange( + beginSelector: beginSelector, + endSelector: endSelector, + limit: limit, + snapshot: false + ) + + pushRange(idx, result.records, prefixFilter: prefix) + + case "GET_ESTIMATED_RANGE_SIZE": + let endKey = waitAndPop().item as! [UInt8] + let beginKey = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + + _ = try await transaction.getEstimatedRangeSizeBytes(beginKey: beginKey, endKey: endKey) + store(idx, Array("GOT_ESTIMATED_RANGE_SIZE".utf8)) + + case "GET_RANGE_SPLIT_POINTS": + let chunkSize = waitAndPop().item as! Int64 + let endKey = waitAndPop().item as! [UInt8] + let beginKey = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + + _ = try await transaction.getRangeSplitPoints(beginKey: beginKey, endKey: endKey, chunkSize: chunkSize) + store(idx, Array("GOT_RANGE_SPLIT_POINTS".utf8)) + + case "CLEAR": + let key = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + transaction.clear(key: key) + + case "CLEAR_RANGE": + let beginKey = waitAndPop().item as! [UInt8] + let endKey = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + transaction.clearRange(beginKey: beginKey, endKey: endKey) + + case "CLEAR_RANGE_STARTS_WITH": + let prefix = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + var endKey = prefix + endKey.append(0xFF) + transaction.clearRange(beginKey: prefix, endKey: endKey) + + case "ATOMIC_OP": + // Python order: opType, key, value = inst.pop(3) + let param = waitAndPop().item as! [UInt8] // value/param + let key = waitAndPop().item as! [UInt8] // key + let opType = waitAndPop().item as! [UInt8] // opType + let transaction = try currentTransaction() + + // Convert opType string to MutationType + let opTypeString = String(bytes: opType, encoding: .utf8) ?? "" + let mutationType: Fdb.MutationType + switch opTypeString { + case "ADD": + mutationType = .add + case "BIT_AND": + mutationType = .bitAnd + case "BIT_OR": + mutationType = .bitOr + case "BIT_XOR": + mutationType = .bitXor + default: + mutationType = .add // Default fallback + } + + transaction.atomicOp(key: key, param: param, mutationType: mutationType) + + case "SET_READ_VERSION": + let version = waitAndPop().item as! Int64 + let transaction = try currentTransaction() + transaction.setReadVersion(version) + + case "GET_COMMITTED_VERSION": + let transaction = try currentTransaction() + lastVersion = try transaction.getCommittedVersion() + store(idx, Array("GOT_COMMITTED_VERSION".utf8)) + + case "GET_APPROXIMATE_SIZE": + let transaction = try currentTransaction() + _ = try await transaction.getApproximateSize() + store(idx, Array("GOT_APPROXIMATE_SIZE".utf8)) + + case "GET_VERSIONSTAMP": + let transaction = try currentTransaction() + if let versionstamp = try await transaction.getVersionstamp() { + store(idx, versionstamp) + } else { + store(idx, Array("RESULT_NOT_PRESENT".utf8)) + } + + case "READ_CONFLICT_RANGE": + let endKey = waitAndPop().item as! [UInt8] + let beginKey = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + try transaction.addConflictRange(beginKey: beginKey, endKey: endKey, type: .read) + + case "WRITE_CONFLICT_RANGE": + let endKey = waitAndPop().item as! [UInt8] + let beginKey = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + try transaction.addConflictRange(beginKey: beginKey, endKey: endKey, type: .write) + + case "READ_CONFLICT_KEY": + let key = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + // For a single key, create a range [key, key+\x00) + var endKey = key + endKey.append(0x00) + try transaction.addConflictRange(beginKey: key, endKey: endKey, type: .read) + + case "WRITE_CONFLICT_KEY": + let key = waitAndPop().item as! [UInt8] + let transaction = try currentTransaction() + // For a single key, create a range [key, key+\x00) + var endKey = key + endKey.append(0x00) + try transaction.addConflictRange(beginKey: key, endKey: endKey, type: .write) + + case "DISABLE_WRITE_CONFLICT": + // Not directly available in Swift bindings, could use transaction option + let transaction = try currentTransaction() + try transaction.setOption(.nextWriteNoWriteConflictRange, value: nil) + + case "TUPLE_PACK": + let numElements = waitAndPop().item as! Int64 + var elements: [any TupleElement] = [] + + for _ in 0..= 4 { + let floatValue = data.withUnsafeBytes { $0.load(as: Float.self) } + store(idx, Int64(floatValue.bitPattern)) + } else { + store(idx, Int64(0)) + } + + case "DECODE_DOUBLE": + let data = waitAndPop().item as! [UInt8] + if data.count >= 8 { + let doubleValue = data.withUnsafeBytes { $0.load(as: Double.self) } + store(idx, Int64(doubleValue.bitPattern)) + } else { + store(idx, Int64(0)) + } + + case "WAIT_FUTURE": + // In async context, futures are automatically awaited, just pass through the item + let oldIdx = stack.count > 0 ? stack.last!.idx : idx + let item = waitAndPop().item + store(oldIdx, item) + + case "START_THREAD": + // Threading not supported in current implementation, just consume the instruction + let instruction = waitAndPop().item + // Could implement this with Task.detached in the future + + case "WAIT_EMPTY": + // Wait until stack is empty - already satisfied since we process sequentially + break + + case "UNIT_TESTS": // TODO + store(idx, Array("UNIT_TESTS_COMPLETED".utf8)) + + default: + fatalError("Unhandled operation: \(op)") + } + + if verbose { + print(" -> [\(stack.map { "\($0.item)" }.joined(separator: ", "))] (\(stack.count))") + print() + } + } + + // Main run function - equivalent to Go's Run() + func run() async throws { + // Read instructions from database using the prefix, like Go version + let instructions = try await database.withTransaction { transaction -> [(key: [UInt8], value: [UInt8])] in + // Create range starting with our prefix + let prefixTuple = Tuple([prefix]) + let beginKey = prefixTuple.encode() + let endKey = beginKey + [0xFF] // Simple range end + + let result = try await transaction.getRange( + beginKey: beginKey, + endKey: endKey, + limit: 0, + snapshot: false + ) + + return result.records + } + + if verbose { + print("Found \(instructions.count) instructions") + } + + // Process each instruction + for (i, (_, value)) in instructions.enumerated() { + // Unpack the instruction tuple from the value + let elements = try Tuple.decode(from: value) + + // Convert tuple elements to array for processing + var instruction: [Any] = [] + for element in elements { + if let stringElement = element as? String { + instruction.append(stringElement) + } else if let bytesElement = element as? [UInt8] { + instruction.append(bytesElement) + } else if let intElement = element as? Int64 { + instruction.append(intElement) + } else { + instruction.append(element) + } + } + + if verbose { + print("Instruction \(i): \(instruction)") + } + + try await processInstruction(i, instruction) + } + + print("StackTester completed successfully with \(instructions.count) instructions") + } +} + diff --git a/Tests/StackTester/Sources/StackTester/main.swift b/Tests/StackTester/Sources/StackTester/main.swift new file mode 100644 index 0000000..1701667 --- /dev/null +++ b/Tests/StackTester/Sources/StackTester/main.swift @@ -0,0 +1,77 @@ +/* + * main.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2013-2024 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import FoundationDB + +// Entry point - using RunLoop to keep the process alive +guard CommandLine.arguments.count >= 3 else { + print("Usage: stacktester [cluster_file]") + exit(1) +} + +let prefix = Array(CommandLine.arguments[1].utf8) +let apiVersionString = CommandLine.arguments[2] +let clusterFile = CommandLine.arguments.count > 3 ? CommandLine.arguments[3] : nil + +guard let apiVersion = Int32(apiVersionString) else { + print("Invalid API version: \(apiVersionString)") + exit(1) +} + +// Use a serial queue for thread-safe communication +let syncQueue = DispatchQueue(label: "stacktester.sync") +var finished = false +var finalError: Error? = nil + +Task { + do { + try await FdbClient.initialize(version: apiVersion) + let database = try FdbClient.openDatabase(clusterFilePath: clusterFile) + let stackMachine = StackMachine(prefix: prefix, database: database, verbose: false) + try await stackMachine.run() + print("StackMachine completed successfully") + + syncQueue.sync { + finished = true + } + } catch { + print("Error occurred: \(error)") + syncQueue.sync { + finalError = error + finished = true + } + } +} + +while true { + let isFinished = syncQueue.sync { finished } + if isFinished { break } + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1)) +} + +let error = syncQueue.sync { finalError } + +if let error = error { + print("Final error: \(error)") + exit(1) +} + +exit(0)