From 51935fb4254e07bc38050a1d4b3e7b8fc1f174f9 Mon Sep 17 00:00:00 2001 From: 1amageek Date: Sat, 1 Nov 2025 15:26:22 +0900 Subject: [PATCH 1/4] Add Versionstamp and Subspace support with comprehensive tests This commit adds two major features to the Swift bindings: ## Versionstamp Support - Implement 12-byte Versionstamp structure (10-byte transaction version + 2-byte user version) - Add incomplete versionstamp support for transaction-time assignment - Implement Tuple integration with versionstamp encoding (type code 0x33) - Add packWithVersionstamp() for atomic operations ## Subspace Implementation - Implement Subspace for key namespace management with tuple encoding - Add range() method using prefix + [0x00] / prefix + [0xFF] pattern - Implement strinc() algorithm for raw binary prefix support - Add prefixRange() method for complete prefix coverage - Define SubspaceError for proper error handling ## Testing - Add VersionstampTests with 15 test cases - Add StringIncrementTests with 14 test cases for strinc() algorithm - Add SubspaceTests with 22 test cases covering range() and prefixRange() - Verify cross-language compatibility with official bindings All implementations follow the canonical behavior of official Java, Python, Go, and C++ bindings. --- Sources/FoundationDB/Subspace.swift | 424 ++++++++++++++++++ Sources/FoundationDB/Tuple+Versionstamp.swift | 217 +++++++++ Sources/FoundationDB/Tuple.swift | 2 +- Sources/FoundationDB/Versionstamp.swift | 196 ++++++++ .../StringIncrementTests.swift | 194 ++++++++ Tests/FoundationDBTests/SubspaceTests.swift | 309 +++++++++++++ .../FoundationDBTests/VersionstampTests.swift | 311 +++++++++++++ 7 files changed, 1652 insertions(+), 1 deletion(-) create mode 100644 Sources/FoundationDB/Subspace.swift create mode 100644 Sources/FoundationDB/Tuple+Versionstamp.swift create mode 100644 Sources/FoundationDB/Versionstamp.swift create mode 100644 Tests/FoundationDBTests/StringIncrementTests.swift create mode 100644 Tests/FoundationDBTests/SubspaceTests.swift create mode 100644 Tests/FoundationDBTests/VersionstampTests.swift diff --git a/Sources/FoundationDB/Subspace.swift b/Sources/FoundationDB/Subspace.swift new file mode 100644 index 0000000..d04fb07 --- /dev/null +++ b/Sources/FoundationDB/Subspace.swift @@ -0,0 +1,424 @@ +/* + * Subspace.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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 + +/// FoundationDB subspace for key management +/// +/// A Subspace represents a well-defined region of keyspace in FoundationDB. +/// It provides methods for encoding keys with a prefix and decoding them back. +/// +/// Subspaces are used to partition the key space into logical regions, similar to +/// tables in a relational database. They ensure that keys from different regions +/// don't collide by prepending a unique prefix to all keys. +/// +/// ## Example Usage +/// +/// ```swift +/// // Create a root subspace +/// let userSpace = Subspace(rootPrefix: "users") +/// +/// // Create nested subspaces +/// let activeUsers = userSpace.subspace("active") +/// +/// // Pack keys with the subspace prefix +/// let key = userSpace.pack(Tuple(12345, "alice")) +/// +/// // Unpack keys to get the original tuple +/// let tuple = try userSpace.unpack(key) +/// ``` +public struct Subspace: Sendable { + /// The binary prefix for this subspace + public let prefix: FDB.Bytes + + // MARK: - Initialization + + /// Create a subspace with a binary prefix + /// + /// - Warning: Subspace is primarily designed for tuple-encoded prefixes. + /// Using raw binary prefixes may result in range queries that do not + /// include all keys within the subspace if the prefix ends with 0xFF bytes. + /// + /// **Known Limitation**: The `range()` method uses `prefix + [0xFF]` as + /// the exclusive upper bound. This means keys like `[prefix, 0xFF, 0x00]` + /// will fall outside the returned range because they are lexicographically + /// greater than `[prefix, 0xFF]`. + /// + /// Example: + /// ```swift + /// let subspace = Subspace(prefix: [0x01, 0xFF]) + /// let (begin, end) = subspace.range() + /// // begin = [0x01, 0xFF, 0x00] + /// // end = [0x01, 0xFF, 0xFF] + /// + /// // Keys like [0x01, 0xFF, 0xFF, 0x00] will NOT be included + /// // because they are > [0x01, 0xFF, 0xFF] in lexicographical order + /// ``` + /// + /// - Important: For tuple-encoded data (created via `init(rootPrefix:)` or + /// `subspace(_:)`), this limitation does not apply because tuple type codes + /// never include 0xFF. + /// + /// - Note: This behavior matches the official Java, C++, Python, and Go + /// implementations. A subspace formed with a raw byte string as a prefix + /// is not fully compatible with the tuple layer, and keys stored within it + /// cannot be unpacked as tuples unless they were originally tuple-encoded. + /// + /// - Recommendation: Use `init(rootPrefix:)` for tuple-encoded data whenever + /// possible. Reserve this initializer for special cases like system + /// prefixes (e.g., DirectoryLayer internal keys). + /// + /// - Parameter prefix: The binary prefix + /// + /// - SeeAlso: https://apple.github.io/foundationdb/developer-guide.html#subspaces + public init(prefix: FDB.Bytes) { + self.prefix = prefix + } + + /// Create a subspace with a string prefix + /// - Parameter rootPrefix: The string prefix (will be encoded as a Tuple) + public init(rootPrefix: String) { + let tuple = Tuple(rootPrefix) + self.prefix = tuple.encode() + } + + // MARK: - Subspace Creation + + /// Create a nested subspace by appending tuple elements + /// - Parameter elements: Tuple elements to append + /// - Returns: A new subspace with the extended prefix + /// + /// ## Example + /// + /// ```swift + /// let users = Subspace(rootPrefix: "users") + /// let activeUsers = users.subspace("active") // prefix = users + "active" + /// let userById = activeUsers.subspace(12345) // prefix = users + "active" + 12345 + /// ``` + public func subspace(_ elements: any TupleElement...) -> Subspace { + let tuple = Tuple(elements) + return Subspace(prefix: prefix + tuple.encode()) + } + + // MARK: - Key Encoding/Decoding + + /// Encode a tuple into a key with this subspace's prefix + /// - Parameter tuple: The tuple to encode + /// - Returns: The encoded key with prefix + /// + /// The returned key will have the format: `[prefix][encoded tuple]` + public func pack(_ tuple: Tuple) -> FDB.Bytes { + return prefix + tuple.encode() + } + + /// Decode a key into a tuple, removing this subspace's prefix + /// - Parameter key: The key to decode + /// - Returns: The decoded tuple + /// - Throws: `TupleError.invalidDecoding` if the key doesn't start with this prefix + /// + /// This operation is the inverse of `pack(_:)`. It removes the subspace prefix + /// and decodes the remaining bytes as a tuple. + public func unpack(_ key: FDB.Bytes) throws -> Tuple { + guard key.starts(with: prefix) else { + throw TupleError.invalidDecoding("Key does not match subspace prefix") + } + let tupleBytes = Array(key.dropFirst(prefix.count)) + let elements = try Tuple.decode(from: tupleBytes) + return Tuple(elements) + } + + /// Check if a key belongs to this subspace + /// - Parameter key: The key to check + /// - Returns: true if the key starts with this subspace's prefix + /// + /// ## Example + /// + /// ```swift + /// let userSpace = Subspace(rootPrefix: "users") + /// let key = userSpace.pack(Tuple(12345)) + /// print(userSpace.contains(key)) // true + /// + /// let otherKey = Subspace(rootPrefix: "posts").pack(Tuple(1)) + /// print(userSpace.contains(otherKey)) // false + /// ``` + public func contains(_ key: FDB.Bytes) -> Bool { + return key.starts(with: prefix) + } + + // MARK: - Range Operations + + /// Get the range for scanning all keys in this subspace + /// + /// The range is defined as `[prefix + 0x00, prefix + 0xFF)`, which: + /// - Includes all keys that start with the subspace prefix and have additional bytes + /// - Does NOT include the bare prefix itself (if it exists as a key) + /// + /// ## Important Limitation with Raw Binary Prefixes + /// + /// - Warning: If this subspace was created with a raw binary prefix using + /// `init(prefix:)`, keys that begin with `[prefix, 0xFF, ...]` may fall + /// outside the returned range. + /// + /// This is because `prefix + [0xFF]` is used as the exclusive upper bound, + /// and any key starting with `[prefix, 0xFF]` followed by additional bytes + /// will be lexicographically greater than `[prefix, 0xFF]`. + /// + /// Example of keys that will be **excluded**: + /// ```swift + /// let subspace = Subspace(prefix: [0x01, 0xFF]) + /// let (begin, end) = subspace.range() + /// // begin = [0x01, 0xFF, 0x00] + /// // end = [0x01, 0xFF, 0xFF] + /// + /// // These keys are OUTSIDE the range: + /// // [0x01, 0xFF, 0xFF] (equal to end, excluded) + /// // [0x01, 0xFF, 0xFF, 0x00] (> end) + /// // [0x01, 0xFF, 0xFF, 0xFF] (> end) + /// ``` + /// + /// ## Why This Works for Tuple-Encoded Data + /// + /// For tuple-encoded data (created via `init(rootPrefix:)` or `subspace(_:)`), + /// this limitation does not apply because: + /// - Tuple type codes range from 0x00 to 0x33 + /// - 0xFF is not a valid tuple type code + /// - Therefore, no tuple-encoded key will ever have 0xFF immediately after the prefix + /// + /// This makes `prefix + [0xFF]` a safe exclusive upper bound for all + /// tuple-encoded keys within the subspace. + /// + /// ## Cross-Language Compatibility + /// + /// This implementation matches the canonical behavior of all official bindings: + /// - Java: `new Range(prefix + 0x00, prefix + 0xFF)` + /// - Python: `slice(prefix + b"\x00", prefix + b"\xff")` + /// - Go: `(prefix + 0x00, prefix + 0xFF)` + /// - C++: `(prefix + 0x00, prefix + 0xFF)` + /// + /// The limitation with raw binary prefixes exists in all these implementations. + /// + /// ## Recommended Usage + /// + /// - ✅ **Recommended**: Use with tuple-encoded data via `init(rootPrefix:)` or `subspace(_:)` + /// - ⚠️ **Caution**: Avoid raw binary prefixes ending in 0xFF bytes + /// - 💡 **Alternative**: For raw binary prefix ranges, consider using a strinc-based + /// method (to be provided in future versions) + /// + /// ## Example (Tuple-Encoded Data) + /// + /// ```swift + /// let userSpace = Subspace(rootPrefix: "users") + /// let (begin, end) = userSpace.range() + /// + /// // Scan all user keys (safe - tuple-encoded) + /// let sequence = transaction.getRange( + /// beginKey: begin, + /// endKey: end + /// ) + /// for try await (key, value) in sequence { + /// // Process each user key-value pair + /// } + /// ``` + /// + /// - Returns: A tuple of (begin, end) keys for range operations + /// + /// - SeeAlso: `init(prefix:)` for warnings about raw binary prefixes + public func range() -> (begin: FDB.Bytes, end: FDB.Bytes) { + let begin = prefix + [0x00] + let end = prefix + [0xFF] + return (begin, end) + } + + /// Get a range with specific start and end tuples + /// - Parameters: + /// - start: Start tuple (inclusive) + /// - end: End tuple (exclusive) + /// - Returns: A tuple of (begin, end) keys + /// + /// ## Example + /// + /// ```swift + /// let userSpace = Subspace(rootPrefix: "users") + /// // Scan users with IDs from 1000 to 2000 + /// let (begin, end) = userSpace.range(from: Tuple(1000), to: Tuple(2000)) + /// ``` + public func range(from start: Tuple, to end: Tuple) -> (begin: FDB.Bytes, end: FDB.Bytes) { + return (pack(start), pack(end)) + } +} + +// MARK: - Equatable + +extension Subspace: Equatable { + public static func == (lhs: Subspace, rhs: Subspace) -> Bool { + return lhs.prefix == rhs.prefix + } +} + +// MARK: - Hashable + +extension Subspace: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(prefix) + } +} + +// MARK: - CustomStringConvertible + +extension Subspace: CustomStringConvertible { + public var description: String { + let hexString = prefix.map { String(format: "%02x", $0) }.joined() + return "Subspace(prefix: \(hexString))" + } +} + +// MARK: - SubspaceError + +/// Errors that can occur in Subspace operations +public enum SubspaceError: Error { + /// The key cannot be incremented because it contains only 0xFF bytes + case cannotIncrementKey(String) +} + +// MARK: - FDB.Bytes String Increment Extension + +extension FDB.Bytes { + /// String increment for raw binary prefixes + /// + /// Returns the first key that would sort outside the range prefixed by this byte array. + /// This implements the canonical strinc algorithm used in FoundationDB. + /// + /// The algorithm: + /// 1. Strip all trailing 0xFF bytes + /// 2. Increment the last remaining byte + /// 3. Return the truncated result + /// + /// This matches the behavior of: + /// - Go: `fdb.Strinc()` + /// - Java: `ByteArrayUtil.strinc()` + /// - Python: `fdb.strinc()` + /// + /// - Returns: Incremented byte array + /// - Throws: `SubspaceError.cannotIncrementKey` if the byte array is empty + /// or contains only 0xFF bytes + /// + /// ## Example + /// + /// ```swift + /// try [0x01, 0x02].strinc() // → [0x01, 0x03] + /// try [0x01, 0xFF].strinc() // → [0x02] + /// try [0x01, 0x02, 0xFF, 0xFF].strinc() // → [0x01, 0x03] + /// try [0xFF, 0xFF].strinc() // throws SubspaceError.cannotIncrementKey + /// try [].strinc() // throws SubspaceError.cannotIncrementKey + /// ``` + /// + /// - SeeAlso: `Subspace.prefixRange()` for usage with Subspace + public func strinc() throws -> FDB.Bytes { + // Strip trailing 0xFF bytes + var result = self + while result.last == 0xFF { + result.removeLast() + } + + // Check if result is empty (input was empty or all 0xFF) + guard !result.isEmpty else { + throw SubspaceError.cannotIncrementKey( + "Key must contain at least one byte not equal to 0xFF" + ) + } + + // Increment the last byte + result[result.count - 1] = result[result.count - 1] &+ 1 + + return result + } +} + +// MARK: - Subspace Prefix Range Extension + +extension Subspace { + /// Get range for raw binary prefix (includes prefix itself) + /// + /// This method is useful when working with raw binary prefixes that were not + /// tuple-encoded. It uses the strinc algorithm to compute the exclusive upper bound, + /// which ensures that ALL keys starting with the prefix are included in the range. + /// + /// Unlike `range()`, which uses `prefix + [0xFF]` as the upper bound, this method + /// uses `strinc(prefix)`, which correctly handles prefixes ending in 0xFF bytes. + /// + /// ## When to Use This Method + /// + /// - ✅ Use this when the subspace was created with `init(prefix:)` using raw binary data + /// - ✅ Use this when you need to ensure ALL keys with the prefix are included + /// - ✅ Use this for non-tuple-encoded keys + /// + /// ## When to Use `range()` Instead + /// + /// - ✅ Use `range()` for tuple-encoded data (via `init(rootPrefix:)` or `subspace(_:)`) + /// - ✅ Use `range()` for standard tuple-based data modeling + /// + /// ## Comparison + /// + /// ```swift + /// let subspace = Subspace(prefix: [0x01, 0xFF]) + /// + /// // range() - may miss keys + /// let (begin1, end1) = subspace.range() + /// // begin1 = [0x01, 0xFF, 0x00] + /// // end1 = [0x01, 0xFF, 0xFF] + /// // Excludes: [0x01, 0xFF, 0xFF, 0x00], [0x01, 0xFF, 0xFF, 0xFF], etc. + /// + /// // prefixRange() - includes all keys + /// let (begin2, end2) = try subspace.prefixRange() + /// // begin2 = [0x01, 0xFF] + /// // end2 = [0x02] + /// // Includes: ALL keys starting with [0x01, 0xFF] + /// ``` + /// + /// - Returns: Range from prefix (inclusive) to strinc(prefix) (exclusive) + /// - Throws: `SubspaceError.cannotIncrementKey` if prefix cannot be incremented + /// (i.e., if the prefix is empty or contains only 0xFF bytes) + /// + /// ## Example + /// + /// ```swift + /// let subspace = Subspace(prefix: [0x01, 0xFF]) + /// + /// do { + /// let (begin, end) = try subspace.prefixRange() + /// // begin = [0x01, 0xFF] + /// // end = [0x02] + /// + /// let sequence = transaction.getRange(beginKey: begin, endKey: end) + /// for try await (key, value) in sequence { + /// // Process all keys starting with [0x01, 0xFF] + /// // Including [0x01, 0xFF, 0xFF, 0x00] and beyond + /// } + /// } catch SubspaceError.cannotIncrementKey(let message) { + /// print("Cannot create range: \(message)") + /// } + /// ``` + /// + /// - SeeAlso: `range()` for tuple-encoded data ranges + /// - SeeAlso: `FDB.Bytes.strinc()` for the underlying algorithm + public func prefixRange() throws -> (begin: FDB.Bytes, end: FDB.Bytes) { + return (prefix, try prefix.strinc()) + } +} diff --git a/Sources/FoundationDB/Tuple+Versionstamp.swift b/Sources/FoundationDB/Tuple+Versionstamp.swift new file mode 100644 index 0000000..9d6a2b7 --- /dev/null +++ b/Sources/FoundationDB/Tuple+Versionstamp.swift @@ -0,0 +1,217 @@ +/* + * Tuple+Versionstamp.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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. + */ + +// MARK: - Versionstamp Support + +extension Tuple { + + /// Pack tuple with an incomplete versionstamp and append offset + /// + /// This method packs a tuple that contains exactly one incomplete versionstamp, + /// and appends the byte offset where the versionstamp appears. + /// + /// The offset size depends on API version: + /// - API < 520: 2 bytes (uint16, little-endian) + /// - API >= 520: 4 bytes (uint32, little-endian) + /// + /// The resulting key can be used with `SET_VERSIONSTAMPED_KEY` atomic operation. + /// At commit time, FoundationDB will replace the 10-byte placeholder with the + /// actual transaction versionstamp. + /// + /// - Parameter prefix: Optional prefix bytes to prepend (default: empty) + /// - Returns: Packed bytes with offset appended + /// - Throws: `TupleError.invalidEncoding` if: + /// - No incomplete versionstamp found + /// - Multiple incomplete versionstamps found + /// - Offset exceeds maximum value (65535 for API < 520, 4294967295 for API >= 520) + /// + /// Example usage: + /// ```swift + /// let vs = Versionstamp.incomplete(userVersion: 0) + /// let tuple = Tuple("user", 12345, vs) + /// let key = try tuple.packWithVersionstamp() + /// + /// transaction.atomicOp( + /// key: key, + /// param: [], + /// mutationType: .setVersionstampedKey + /// ) + /// ``` + public func packWithVersionstamp(prefix: FDB.Bytes = []) throws -> FDB.Bytes { + var packed = prefix + var versionstampPosition: Int? = nil + var incompleteCount = 0 + + // Encode each element and track incomplete versionstamp position + for element in elements { + if let vs = element as? Versionstamp { + if !vs.isComplete { + incompleteCount += 1 + if versionstampPosition == nil { + // Position points to start of 10-byte transaction version + // (after type code byte and before the 10-byte placeholder) + versionstampPosition = packed.count + 1 // +1 for type code 0x33 + } + } + } + + packed.append(contentsOf: element.encodeTuple()) + } + + // Validate exactly one incomplete versionstamp + guard incompleteCount == 1, let position = versionstampPosition else { + throw TupleError.invalidEncoding + } + + // Append offset based on API version + // Default to API 520+ behavior (4-byte offset) + let apiVersion = 520 // TODO: Get from FDBClient.apiVersion when available + + if apiVersion < 520 { + // API < 520: Use 2-byte offset (uint16, little-endian) + guard position <= UInt16.max else { + throw TupleError.invalidEncoding + } + + let offset = UInt16(position) + packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) + + } else { + // API >= 520: Use 4-byte offset (uint32, little-endian) + guard position <= UInt32.max else { + throw TupleError.invalidEncoding + } + + let offset = UInt32(position) + packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) + } + + return packed + } + + /// Check if tuple contains an incomplete versionstamp + /// - Returns: true if any element is an incomplete versionstamp + public func hasIncompleteVersionstamp() -> Bool { + return elements.contains { element in + if let vs = element as? Versionstamp { + return !vs.isComplete + } + return false + } + } + + /// Count incomplete versionstamps in tuple + /// - Returns: Number of incomplete versionstamps + public func countIncompleteVersionstamps() -> Int { + return elements.reduce(0) { count, element in + if let vs = element as? Versionstamp, !vs.isComplete { + return count + 1 + } + return count + } + } + + /// Validate tuple for use with packWithVersionstamp() + /// - Throws: `TupleError.invalidEncoding` if validation fails + public func validateForVersionstamp() throws { + let incompleteCount = countIncompleteVersionstamps() + + guard incompleteCount == 1 else { + throw TupleError.invalidEncoding + } + } +} + +// MARK: - Tuple Decoding Support + +extension Tuple { + + /// Decode tuple that may contain versionstamps + /// + /// This is an enhanced version of decode() that supports TupleTypeCode.versionstamp (0x33). + /// It maintains backward compatibility with existing decode() implementation. + /// + /// - Parameter bytes: Encoded tuple bytes + /// - Returns: Array of decoded tuple elements + /// - Throws: `TupleError.invalidEncoding` if decoding fails + public static func decodeWithVersionstamp(from bytes: FDB.Bytes) throws -> [any TupleElement] { + var elements: [any TupleElement] = [] + var offset = 0 + + while offset < bytes.count { + guard offset < bytes.count else { break } + + let typeCode = bytes[offset] + offset += 1 + + switch typeCode { + case TupleTypeCode.versionstamp.rawValue: + let element = try Versionstamp.decodeTuple(from: bytes, at: &offset) + elements.append(element) + + // For other type codes, delegate to existing decode logic + // This requires refactoring Tuple.decode() to be reusable + // For now, we handle the most common cases: + + case TupleTypeCode.bytes.rawValue: + var value: [UInt8] = [] + while offset < bytes.count && bytes[offset] != 0x00 { + if bytes[offset] == 0xFF { + offset += 1 + if offset < bytes.count && bytes[offset] == 0xFF { + value.append(0x00) + offset += 1 + } + } else { + value.append(bytes[offset]) + offset += 1 + } + } + offset += 1 // Skip terminating 0x00 + elements.append(value as FDB.Bytes) + + case TupleTypeCode.string.rawValue: + var value: [UInt8] = [] + while offset < bytes.count && bytes[offset] != 0x00 { + if bytes[offset] == 0xFF { + offset += 1 + if offset < bytes.count && bytes[offset] == 0xFF { + value.append(0x00) + offset += 1 + } + } else { + value.append(bytes[offset]) + offset += 1 + } + } + offset += 1 // Skip terminating 0x00 + let string = String(decoding: value, as: UTF8.self) + elements.append(string) + + default: + // For other types, fall back to standard decode + // This is a simplified version; full implementation should reuse Tuple.decode() + throw TupleError.invalidEncoding + } + } + + return elements + } +} diff --git a/Sources/FoundationDB/Tuple.swift b/Sources/FoundationDB/Tuple.swift index 23b6bce..5310f21 100644 --- a/Sources/FoundationDB/Tuple.swift +++ b/Sources/FoundationDB/Tuple.swift @@ -70,7 +70,7 @@ public protocol TupleElement: Sendable, Hashable, Equatable { /// These semantic differences ensure consistency with FoundationDB's tuple ordering and are /// important when using tuples as dictionary keys or in sets. public struct Tuple: Sendable, Hashable, Equatable { - private let elements: [any TupleElement] + internal let elements: [any TupleElement] public init(_ elements: any TupleElement...) { self.elements = elements diff --git a/Sources/FoundationDB/Versionstamp.swift b/Sources/FoundationDB/Versionstamp.swift new file mode 100644 index 0000000..ccec704 --- /dev/null +++ b/Sources/FoundationDB/Versionstamp.swift @@ -0,0 +1,196 @@ +/* + * Versionstamp.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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. + */ + +/// Represents a FoundationDB versionstamp (96-bit / 12 bytes) +/// +/// A versionstamp is a 12-byte value consisting of: +/// - 10 bytes: Transaction version (assigned by FDB at commit time) +/// - 2 bytes: User-defined version (for ordering within a transaction) +/// +/// Versionstamps are used for: +/// - Optimistic concurrency control +/// - Creating globally unique, monotonically increasing keys +/// - Maintaining temporal ordering of records +/// +/// Example usage: +/// ```swift +/// // Create an incomplete versionstamp for writing +/// let vs = Versionstamp.incomplete(userVersion: 0) +/// let tuple = Tuple("prefix", vs) +/// let key = try tuple.packWithVersionstamp() +/// transaction.atomicOp(key: key, param: [], mutationType: .setVersionstampedKey) +/// +/// // After commit, read the completed versionstamp +/// let committedVersion = try await transaction.getVersionstamp() +/// let complete = Versionstamp(transactionVersion: committedVersion!, userVersion: 0) +/// ``` +public struct Versionstamp: Sendable, Hashable, Equatable, CustomStringConvertible { + + // MARK: - Constants + + /// Size of transaction version in bytes (10 bytes / 80 bits) + public static let transactionVersionSize = 10 + + /// Size of user version in bytes (2 bytes / 16 bits) + public static let userVersionSize = 2 + + /// Total size of versionstamp in bytes (12 bytes / 96 bits) + public static let totalSize = transactionVersionSize + userVersionSize + + /// Placeholder for incomplete transaction version (10 bytes of 0xFF) + private static let incompletePlaceholder: [UInt8] = [UInt8](repeating: 0xFF, count: transactionVersionSize) + + // MARK: - Properties + + /// Transaction version (10 bytes) + /// - nil for incomplete versionstamp (to be filled by FDB at commit time) + /// - Non-nil for complete versionstamp (after commit) + public let transactionVersion: [UInt8]? + + /// User-defined version (2 bytes, big-endian) + /// Used for ordering within a single transaction + /// Range: 0-65535 + public let userVersion: UInt16 + + // MARK: - Initialization + + /// Create a versionstamp + /// - Parameters: + /// - transactionVersion: 10-byte transaction version from FDB (nil for incomplete) + /// - userVersion: User-defined version (0-65535) + public init(transactionVersion: [UInt8]?, userVersion: UInt16 = 0) { + if let tv = transactionVersion { + precondition( + tv.count == Self.transactionVersionSize, + "Transaction version must be exactly \(Self.transactionVersionSize) bytes" + ) + } + self.transactionVersion = transactionVersion + self.userVersion = userVersion + } + + /// Create an incomplete versionstamp + /// - Parameter userVersion: User-defined version (0-65535) + /// - Returns: Versionstamp with placeholder transaction version + /// + /// Use this when creating keys/values that will be filled by FDB at commit time. + public static func incomplete(userVersion: UInt16 = 0) -> Versionstamp { + return Versionstamp(transactionVersion: nil, userVersion: userVersion) + } + + // MARK: - Properties + + /// Check if versionstamp is complete + /// - Returns: true if transaction version has been set, false otherwise + public var isComplete: Bool { + return transactionVersion != nil + } + + /// Convert to 12-byte representation + /// - Returns: 12-byte array (10 bytes transaction version + 2 bytes user version, big-endian) + public func toBytes() -> FDB.Bytes { + var bytes = transactionVersion ?? Self.incompletePlaceholder + + // User version is stored as big-endian + bytes.append(contentsOf: withUnsafeBytes(of: userVersion.bigEndian) { Array($0) }) + + return bytes + } + + /// Create from 12-byte representation + /// - Parameter bytes: 12-byte array + /// - Returns: Versionstamp + /// - Throws: `TupleError.invalidEncoding` if bytes length is not 12 + public static func fromBytes(_ bytes: FDB.Bytes) throws -> Versionstamp { + guard bytes.count == totalSize else { + throw TupleError.invalidEncoding + } + + let trVersionBytes = Array(bytes.prefix(transactionVersionSize)) + let userVersionBytes = bytes.suffix(userVersionSize) + + let userVersion = userVersionBytes.withUnsafeBytes { + $0.load(as: UInt16.self).bigEndian + } + + // Check if transaction version is incomplete (all 0xFF) + let isIncomplete = trVersionBytes == incompletePlaceholder + + return Versionstamp( + transactionVersion: isIncomplete ? nil : trVersionBytes, + userVersion: userVersion + ) + } + + // MARK: - Hashable & Equatable + + public func hash(into hasher: inout Hasher) { + hasher.combine(transactionVersion) + hasher.combine(userVersion) + } + + public static func == (lhs: Versionstamp, rhs: Versionstamp) -> Bool { + return lhs.transactionVersion == rhs.transactionVersion && + lhs.userVersion == rhs.userVersion + } + + // MARK: - Comparable + + /// Versionstamps are ordered lexicographically by their byte representation + public static func < (lhs: Versionstamp, rhs: Versionstamp) -> Bool { + return lhs.toBytes().lexicographicallyPrecedes(rhs.toBytes()) + } + + // MARK: - CustomStringConvertible + + public var description: String { + if let tv = transactionVersion { + let tvHex = tv.map { String(format: "%02x", $0) }.joined() + return "Versionstamp(tr:\(tvHex), user:\(userVersion))" + } else { + return "Versionstamp(incomplete, user:\(userVersion))" + } + } +} + +// MARK: - Comparable Conformance + +extension Versionstamp: Comparable {} + +// MARK: - TupleElement Conformance + +extension Versionstamp: TupleElement { + public func encodeTuple() -> FDB.Bytes { + var bytes: FDB.Bytes = [TupleTypeCode.versionstamp.rawValue] + bytes.append(contentsOf: toBytes()) + return bytes + } + + public static func decodeTuple(from bytes: FDB.Bytes, at offset: inout Int) throws -> Versionstamp { + guard offset + Versionstamp.totalSize <= bytes.count else { + throw TupleError.invalidEncoding + } + + let versionstampBytes = Array(bytes[offset..<(offset + Versionstamp.totalSize)]) + offset += Versionstamp.totalSize + + return try Versionstamp.fromBytes(versionstampBytes) + } +} diff --git a/Tests/FoundationDBTests/StringIncrementTests.swift b/Tests/FoundationDBTests/StringIncrementTests.swift new file mode 100644 index 0000000..d3ccf1b --- /dev/null +++ b/Tests/FoundationDBTests/StringIncrementTests.swift @@ -0,0 +1,194 @@ +/* + * StringIncrementTests.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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 Testing +@testable import FoundationDB + +@Suite("String Increment (strinc) Tests") +struct StringIncrementTests { + + // MARK: - Basic strinc() Tests + + @Test("strinc increments normal byte array") + func strincNormal() throws { + let input: FDB.Bytes = [0x01, 0x02, 0x03] + let result = try input.strinc() + #expect(result == [0x01, 0x02, 0x04]) + } + + @Test("strinc increments single byte") + func strincSingleByte() throws { + let input: FDB.Bytes = [0x42] + let result = try input.strinc() + #expect(result == [0x43]) + } + + @Test("strinc strips trailing 0xFF and increments") + func strincWithTrailing0xFF() throws { + let input: FDB.Bytes = [0x01, 0x02, 0xFF] + let result = try input.strinc() + #expect(result == [0x01, 0x03]) + } + + @Test("strinc strips multiple trailing 0xFF bytes") + func strincWithMultipleTrailing0xFF() throws { + let input: FDB.Bytes = [0x01, 0xFF, 0xFF] + let result = try input.strinc() + #expect(result == [0x02]) + } + + @Test("strinc handles complex case") + func strincComplex() throws { + let input: FDB.Bytes = [0x01, 0x02, 0xFF, 0xFF, 0xFF] + let result = try input.strinc() + #expect(result == [0x01, 0x03]) + } + + @Test("strinc handles 0xFE correctly") + func strinc0xFE() throws { + let input: FDB.Bytes = [0x01, 0xFE] + let result = try input.strinc() + #expect(result == [0x01, 0xFF]) + } + + @Test("strinc handles overflow to 0xFF") + func strincOverflowTo0xFF() throws { + let input: FDB.Bytes = [0x00, 0xFE] + let result = try input.strinc() + #expect(result == [0x00, 0xFF]) + } + + // MARK: - Error Cases + + @Test("strinc throws error on all 0xFF bytes") + func strincAllFF() { + let input: FDB.Bytes = [0xFF, 0xFF] + + do { + _ = try input.strinc() + Issue.record("Should throw error for all-0xFF input") + } catch let error as SubspaceError { + if case .cannotIncrementKey(let message) = error { + #expect(message.contains("0xFF")) + } else { + Issue.record("Wrong error case") + } + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + @Test("strinc throws error on empty array") + func strincEmpty() { + let input: FDB.Bytes = [] + + do { + _ = try input.strinc() + Issue.record("Should throw error for empty input") + } catch let error as SubspaceError { + if case .cannotIncrementKey(let message) = error { + #expect(message.contains("0xFF")) + } else { + Issue.record("Wrong error case") + } + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + @Test("strinc throws error on single 0xFF") + func strincSingle0xFF() { + let input: FDB.Bytes = [0xFF] + + do { + _ = try input.strinc() + Issue.record("Should throw error for single 0xFF") + } catch let error as SubspaceError { + if case .cannotIncrementKey = error { + // Expected + } else { + Issue.record("Wrong error case") + } + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + // MARK: - Cross-Reference with Official Implementations + + @Test("strinc matches Java ByteArrayUtil.strinc behavior") + func strincJavaCompatibility() throws { + // Test cases from Java implementation + let testCases: [(input: FDB.Bytes, expected: FDB.Bytes)] = [ + ([0x01], [0x02]), + ([0x01, 0x02], [0x01, 0x03]), + ([0x01, 0xFF], [0x02]), + ([0xFE], [0xFF]), + ([0x00, 0xFF], [0x01]), + ([0x01, 0x02, 0xFF, 0xFF], [0x01, 0x03]) + ] + + for (input, expected) in testCases { + let result = try input.strinc() + #expect(result == expected, + "strinc(\(input.map { String(format: "%02x", $0) }.joined(separator: " "))) should equal \(expected.map { String(format: "%02x", $0) }.joined(separator: " "))") + } + } + + @Test("strinc matches Go fdb.Strinc behavior") + func strincGoCompatibility() throws { + // Test cases from Go implementation + let testCases: [(input: FDB.Bytes, expected: FDB.Bytes)] = [ + ([0x01, 0x00], [0x01, 0x01]), + ([0x01, 0x00, 0xFF], [0x01, 0x01]), + ([0xFE, 0xFF, 0xFF], [0xFF]) + ] + + for (input, expected) in testCases { + let result = try input.strinc() + #expect(result == expected) + } + } + + // MARK: - Edge Cases + + @Test("strinc handles byte overflow correctly") + func strincByteOverflow() throws { + // When incrementing 0xFF, it wraps to 0x00 (via &+ operator) + // But since we increment the LAST non-0xFF byte, this should work + let input: FDB.Bytes = [0x01, 0xFF, 0xFF] + let result = try input.strinc() + #expect(result == [0x02]) + } + + @Test("strinc preserves leading bytes") + func strincPreservesLeading() throws { + let input: FDB.Bytes = [0xAA, 0xBB, 0xCC, 0xFF, 0xFF] + let result = try input.strinc() + #expect(result == [0xAA, 0xBB, 0xCD]) + } + + @Test("strinc works with maximum non-0xFF value") + func strincMaxNon0xFF() throws { + let input: FDB.Bytes = [0xFE] + let result = try input.strinc() + #expect(result == [0xFF]) + } +} diff --git a/Tests/FoundationDBTests/SubspaceTests.swift b/Tests/FoundationDBTests/SubspaceTests.swift new file mode 100644 index 0000000..aa107d1 --- /dev/null +++ b/Tests/FoundationDBTests/SubspaceTests.swift @@ -0,0 +1,309 @@ +/* + * SubspaceTests.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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 Testing +@testable import FoundationDB + +@Suite("Subspace Tests") +struct SubspaceTests { + @Test("Subspace creation creates non-empty prefix") + func subspaceCreation() { + let subspace = Subspace(rootPrefix: "test") + #expect(!subspace.prefix.isEmpty) + } + + @Test("Nested subspace prefix includes root prefix") + func nestedSubspace() { + let root = Subspace(rootPrefix: "test") + let nested = root.subspace(Int64(1), "child") + + #expect(nested.prefix.starts(with: root.prefix)) + #expect(nested.prefix.count > root.prefix.count) + } + + @Test("Pack/unpack preserves subspace prefix") + func packUnpack() throws { + let subspace = Subspace(rootPrefix: "test") + let tuple = Tuple("key", Int64(123)) + + let packed = subspace.pack(tuple) + _ = try subspace.unpack(packed) + + // Verify the packed key has the subspace prefix + #expect(packed.starts(with: subspace.prefix)) + } + + @Test("Range returns correct begin and end keys") + func range() { + let subspace = Subspace(rootPrefix: "test") + let (begin, end) = subspace.range() + + // Begin should be prefix + 0x00 + #expect(begin == subspace.prefix + [0x00]) + + // End should be prefix + 0xFF + #expect(end == subspace.prefix + [0xFF]) + + // Verify range is non-empty (begin < end) + #expect(begin.lexicographicallyPrecedes(end)) + } + + @Test("Range handles 0xFF suffix correctly") + func rangeWithTrailing0xFF() { + let subspace = Subspace(prefix: [0x01, 0xFF]) + let (begin, end) = subspace.range() + + // Correct: append 0x00 and 0xFF + #expect(begin == [0x01, 0xFF, 0x00]) + #expect(end == [0x01, 0xFF, 0xFF]) + + // Verify that a key like [0x01, 0xFF, 0x01] is within the range + let testKey: FDB.Bytes = [0x01, 0xFF, 0x01] + #expect(!testKey.lexicographicallyPrecedes(begin)) // testKey >= begin + #expect(testKey.lexicographicallyPrecedes(end)) // testKey < end + } + + @Test("Range handles multiple trailing 0xFF bytes") + func rangeWithMultipleTrailing0xFF() { + let subspace = Subspace(prefix: [0x01, 0x02, 0xFF, 0xFF]) + let (begin, end) = subspace.range() + + // Correct: append 0x00 and 0xFF + #expect(begin == [0x01, 0x02, 0xFF, 0xFF, 0x00]) + #expect(end == [0x01, 0x02, 0xFF, 0xFF, 0xFF]) + } + + @Test("Range handles all-0xFF prefix") + func rangeWithAll0xFF() { + let subspace = Subspace(prefix: [0xFF, 0xFF]) + let (begin, end) = subspace.range() + + // Correct: append 0x00 and 0xFF even for all-0xFF prefix + #expect(begin == [0xFF, 0xFF, 0x00]) + #expect(end == [0xFF, 0xFF, 0xFF]) + + // Verify range is valid (begin < end) + #expect(begin.lexicographicallyPrecedes(end)) + } + + @Test("Range handles single 0xFF prefix") + func rangeWithSingle0xFF() { + let subspace = Subspace(prefix: [0xFF]) + let (begin, end) = subspace.range() + + // Note: [0xFF] is the start of system key space + // but range() still follows the pattern + #expect(begin == [0xFF, 0x00]) + #expect(end == [0xFF, 0xFF]) + + // Verify range is valid + #expect(begin.lexicographicallyPrecedes(end)) + } + + @Test("Range handles special characters") + func rangeSpecialCharacters() { + let subspace = Subspace(rootPrefix: "test_special_chars") + let (begin, end) = subspace.range() + + #expect(begin == subspace.prefix) + #expect(end != begin) + #expect(end.count > 0) + } + + @Test("Range handles empty string root prefix") + func rangeEmptyStringPrefix() { + // Empty string encodes to [0x02, 0x00] in tuple encoding + let subspace = Subspace(rootPrefix: "") + let (begin, end) = subspace.range() + + // Prefix should be tuple-encoded empty string + let encodedEmpty = Tuple("").encode() + #expect(begin == encodedEmpty + [0x00]) + #expect(end == encodedEmpty + [0xFF]) + } + + @Test("Range handles truly empty prefix") + func rangeTrulyEmptyPrefix() { + // Directly construct subspace with empty byte array + let subspace = Subspace(prefix: []) + let (begin, end) = subspace.range() + + // Should cover all user key space + #expect(begin == [0x00]) + #expect(end == [0xFF]) + } + + @Test("Contains checks if key belongs to subspace") + func contains() { + let subspace = Subspace(rootPrefix: "test") + let tuple = Tuple("key") + let key = subspace.pack(tuple) + + #expect(subspace.contains(key)) + + let otherSubspace = Subspace(rootPrefix: "other") + #expect(!otherSubspace.contains(key)) + } + + // MARK: - prefixRange() Tests + + @Test("prefixRange returns prefix and strinc as bounds") + func prefixRange() throws { + let subspace = Subspace(prefix: [0x01, 0x02]) + let (begin, end) = try subspace.prefixRange() + + // Begin should be the prefix itself + #expect(begin == [0x01, 0x02]) + + // End should be strinc(prefix) = [0x01, 0x03] + #expect(end == [0x01, 0x03]) + } + + @Test("prefixRange handles trailing 0xFF correctly") + func prefixRangeWithTrailing0xFF() throws { + let subspace = Subspace(prefix: [0x01, 0xFF]) + let (begin, end) = try subspace.prefixRange() + + // Begin is the prefix + #expect(begin == [0x01, 0xFF]) + + // End should be strinc([0x01, 0xFF]) = [0x02] + #expect(end == [0x02]) + + // Verify that keys like [0x01, 0xFF, 0xFF, 0x00] are included + let testKey: FDB.Bytes = [0x01, 0xFF, 0xFF, 0x00] + #expect(!testKey.lexicographicallyPrecedes(begin)) // testKey >= begin + #expect(testKey.lexicographicallyPrecedes(end)) // testKey < end + } + + @Test("prefixRange handles multiple trailing 0xFF bytes") + func prefixRangeWithMultipleTrailing0xFF() throws { + let subspace = Subspace(prefix: [0x01, 0x02, 0xFF, 0xFF]) + let (begin, end) = try subspace.prefixRange() + + #expect(begin == [0x01, 0x02, 0xFF, 0xFF]) + #expect(end == [0x01, 0x03]) // strinc strips trailing 0xFF and increments + } + + @Test("prefixRange throws error for all-0xFF prefix") + func prefixRangeWithAll0xFF() { + let subspace = Subspace(prefix: [0xFF, 0xFF]) + + do { + _ = try subspace.prefixRange() + Issue.record("Should throw error for all-0xFF prefix") + } catch let error as SubspaceError { + if case .cannotIncrementKey = error { + // Expected + } else { + Issue.record("Wrong error case") + } + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + @Test("prefixRange throws error for empty prefix") + func prefixRangeWithEmptyPrefix() { + let subspace = Subspace(prefix: []) + + do { + _ = try subspace.prefixRange() + Issue.record("Should throw error for empty prefix") + } catch let error as SubspaceError { + if case .cannotIncrementKey = error { + // Expected + } else { + Issue.record("Wrong error case") + } + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + @Test("prefixRange vs range comparison for raw binary prefix") + func prefixRangeVsRangeComparison() throws { + // Raw binary prefix ending in 0xFF + let subspace = Subspace(prefix: [0x01, 0xFF]) + + // range() uses prefix + [0x00] / prefix + [0xFF] + let (rangeBegin, rangeEnd) = subspace.range() + #expect(rangeBegin == [0x01, 0xFF, 0x00]) + #expect(rangeEnd == [0x01, 0xFF, 0xFF]) + + // prefixRange() uses prefix / strinc(prefix) + let (prefixBegin, prefixEnd) = try subspace.prefixRange() + #expect(prefixBegin == [0x01, 0xFF]) + #expect(prefixEnd == [0x02]) + + // Keys that are included in prefixRange but NOT in range + let excludedByRange: FDB.Bytes = [0x01, 0xFF, 0xFF, 0x00] + + // Not in range() - excluded because >= rangeEnd + #expect(!excludedByRange.lexicographicallyPrecedes(rangeEnd)) + + // But IS in prefixRange() - included because < prefixEnd + #expect(!excludedByRange.lexicographicallyPrecedes(prefixBegin)) // >= begin + #expect(excludedByRange.lexicographicallyPrecedes(prefixEnd)) // < end + } + + @Test("prefixRange includes the prefix itself as a key") + func prefixRangeIncludesPrefix() throws { + let subspace = Subspace(prefix: [0x01, 0x02]) + let (begin, end) = try subspace.prefixRange() + + // The prefix itself is included (begin is inclusive) + let prefixKey = subspace.prefix + #expect(!prefixKey.lexicographicallyPrecedes(begin)) // >= begin + #expect(prefixKey.lexicographicallyPrecedes(end)) // < end + } + + @Test("prefixRange works with single byte prefix") + func prefixRangeSingleByte() throws { + let subspace = Subspace(prefix: [0x42]) + let (begin, end) = try subspace.prefixRange() + + #expect(begin == [0x42]) + #expect(end == [0x43]) + } + + @Test("prefixRange works with 0xFE prefix") + func prefixRange0xFE() throws { + let subspace = Subspace(prefix: [0xFE]) + let (begin, end) = try subspace.prefixRange() + + #expect(begin == [0xFE]) + #expect(end == [0xFF]) + } + + @Test("prefixRange for tuple-encoded data") + func prefixRangeTupleEncoded() throws { + // Tuple-encoded prefix (no trailing 0xFF possible) + let subspace = Subspace(rootPrefix: "users") + let (begin, end) = try subspace.prefixRange() + + // Begin is the tuple-encoded prefix + #expect(begin == subspace.prefix) + + // End is strinc(prefix) - should work fine + #expect(end.count >= begin.count) // Could be shorter or equal length + #expect(!end.lexicographicallyPrecedes(begin)) // end >= begin + } +} diff --git a/Tests/FoundationDBTests/VersionstampTests.swift b/Tests/FoundationDBTests/VersionstampTests.swift new file mode 100644 index 0000000..58e1850 --- /dev/null +++ b/Tests/FoundationDBTests/VersionstampTests.swift @@ -0,0 +1,311 @@ +/* + * VersionstampTests.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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 Testing +@testable import FoundationDB + +@Suite("Versionstamp Tests") +struct VersionstampTests { + + // MARK: - Basic Versionstamp Tests + + @Test("Versionstamp incomplete creation") + func testIncompleteCreation() { + let vs = Versionstamp.incomplete(userVersion: 0) + + #expect(!vs.isComplete) + #expect(vs.userVersion == 0) + + let bytes = vs.toBytes() + #expect(bytes.count == 12) + #expect(bytes.prefix(10).allSatisfy { $0 == 0xFF }) + } + + @Test("Versionstamp incomplete with user version") + func testIncompleteWithUserVersion() { + let vs = Versionstamp.incomplete(userVersion: 42) + + #expect(!vs.isComplete) + #expect(vs.userVersion == 42) + + let bytes = vs.toBytes() + #expect(bytes.count == 12) + #expect(bytes.prefix(10).allSatisfy { $0 == 0xFF }) + + // User version is big-endian + #expect(bytes[10] == 0x00) + #expect(bytes[11] == 0x2A) // 42 in hex + } + + @Test("Versionstamp complete creation") + func testCompleteCreation() { + let trVersion: [UInt8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A] + let vs = Versionstamp(transactionVersion: trVersion, userVersion: 100) + + #expect(vs.isComplete) + #expect(vs.userVersion == 100) + + let bytes = vs.toBytes() + #expect(bytes.count == 12) + #expect(Array(bytes.prefix(10)) == trVersion) + } + + @Test("Versionstamp fromBytes incomplete") + func testFromBytesIncomplete() throws { + let bytes: FDB.Bytes = [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // incomplete + 0x00, 0x10 // userVersion = 16 + ] + + let vs = try Versionstamp.fromBytes(bytes) + + #expect(!vs.isComplete) + #expect(vs.userVersion == 16) + } + + @Test("Versionstamp fromBytes complete") + func testFromBytesComplete() throws { + let bytes: FDB.Bytes = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, // complete + 0x00, 0x20 // userVersion = 32 + ] + + let vs = try Versionstamp.fromBytes(bytes) + + #expect(vs.isComplete) + #expect(vs.userVersion == 32) + #expect(vs.transactionVersion == [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A]) + } + + @Test("Versionstamp equality") + func testEquality() { + let vs1 = Versionstamp.incomplete(userVersion: 10) + let vs2 = Versionstamp.incomplete(userVersion: 10) + let vs3 = Versionstamp.incomplete(userVersion: 20) + + #expect(vs1 == vs2) + #expect(vs1 != vs3) + } + + @Test("Versionstamp hashable") + func testHashable() { + let vs1 = Versionstamp.incomplete(userVersion: 5) + let vs2 = Versionstamp.incomplete(userVersion: 5) + + var set: Set = [] + set.insert(vs1) + set.insert(vs2) + + #expect(set.count == 1) + } + + @Test("Versionstamp description") + func testDescription() { + let incompleteVs = Versionstamp.incomplete(userVersion: 100) + #expect(incompleteVs.description.contains("incomplete")) + + let trVersion: [UInt8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A] + let completeVs = Versionstamp(transactionVersion: trVersion, userVersion: 200) + #expect(completeVs.description.contains("0102030405060708090a")) + } + + // MARK: - TupleElement Tests + + @Test("Versionstamp encodeTuple") + func testEncodeTuple() { + let vs = Versionstamp.incomplete(userVersion: 0) + let encoded = vs.encodeTuple() + + #expect(encoded.count == 13) // 1 byte type code + 12 bytes versionstamp + #expect(encoded[0] == 0x33) // TupleTypeCode.versionstamp + #expect(encoded.suffix(12) == vs.toBytes()) + } + + @Test("Versionstamp decodeTuple") + func testDecodeTuple() throws { + let vs = Versionstamp.incomplete(userVersion: 42) + let encoded = vs.encodeTuple() + + var offset = 1 // Skip type code + let decoded = try Versionstamp.decodeTuple(from: encoded, at: &offset) + + #expect(decoded == vs) + #expect(offset == 13) + } + + // MARK: - Tuple.packWithVersionstamp() Tests + + @Test("Tuple packWithVersionstamp basic") + func testPackWithVersionstampBasic() throws { + let vs = Versionstamp.incomplete(userVersion: 0) + let tuple = Tuple("prefix", vs) + + let packed = try tuple.packWithVersionstamp() + + // Verify structure: + // - String "prefix" encoded + // - Versionstamp 0x33 + 12 bytes + // - 4-byte offset (little-endian) + #expect(packed.count > 13 + 4) + + // Last 4 bytes should be the offset + let offsetBytes = packed.suffix(4) + let offset = offsetBytes.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian } + + // Offset should point to the start of the 10-byte transaction version + // (after type code 0x33) + #expect(offset > 0) + #expect(Int(offset) < packed.count - 4) + } + + @Test("Tuple packWithVersionstamp with prefix") + func testPackWithVersionstampWithPrefix() throws { + let vs = Versionstamp.incomplete(userVersion: 0) + let tuple = Tuple(vs) + let prefix: FDB.Bytes = [0x01, 0x02, 0x03] + + let packed = try tuple.packWithVersionstamp(prefix: prefix) + + // Verify prefix is prepended + #expect(Array(packed.prefix(3)) == prefix) + + // Last 4 bytes should be the offset + let offsetBytes = packed.suffix(4) + let offset = offsetBytes.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian } + + // Offset should account for prefix length + #expect(offset == 3 + 1) // prefix (3) + type code (1) + } + + @Test("Tuple packWithVersionstamp no incomplete error") + func testPackWithVersionstampNoIncomplete() { + let trVersion: [UInt8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A] + let completeVs = Versionstamp(transactionVersion: trVersion, userVersion: 0) + let tuple = Tuple("prefix", completeVs) + + do { + _ = try tuple.packWithVersionstamp() + Issue.record("Should throw error when no incomplete versionstamp") + } catch { + #expect(error is TupleError) + } + } + + @Test("Tuple packWithVersionstamp multiple incomplete error") + func testPackWithVersionstampMultipleIncomplete() { + let vs1 = Versionstamp.incomplete(userVersion: 0) + let vs2 = Versionstamp.incomplete(userVersion: 1) + let tuple = Tuple("prefix", vs1, vs2) + + do { + _ = try tuple.packWithVersionstamp() + Issue.record("Should throw error when multiple incomplete versionstamps") + } catch { + #expect(error is TupleError) + } + } + + @Test("Tuple hasIncompleteVersionstamp") + func testHasIncompleteVersionstamp() { + let incompleteVs = Versionstamp.incomplete(userVersion: 0) + let tuple1 = Tuple("test", incompleteVs) + #expect(tuple1.hasIncompleteVersionstamp()) + + let trVersion: [UInt8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A] + let completeVs = Versionstamp(transactionVersion: trVersion, userVersion: 0) + let tuple2 = Tuple("test", completeVs) + #expect(!tuple2.hasIncompleteVersionstamp()) + + let tuple3 = Tuple("test", "no versionstamp") + #expect(!tuple3.hasIncompleteVersionstamp()) + } + + @Test("Tuple countIncompleteVersionstamps") + func testCountIncompleteVersionstamps() { + let vs1 = Versionstamp.incomplete(userVersion: 0) + let vs2 = Versionstamp.incomplete(userVersion: 1) + + let tuple1 = Tuple(vs1) + #expect(tuple1.countIncompleteVersionstamps() == 1) + + let tuple2 = Tuple(vs1, "middle", vs2) + #expect(tuple2.countIncompleteVersionstamps() == 2) + + let tuple3 = Tuple("no versionstamp") + #expect(tuple3.countIncompleteVersionstamps() == 0) + } + + @Test("Tuple validateForVersionstamp") + func testValidateForVersionstamp() throws { + let vs = Versionstamp.incomplete(userVersion: 0) + let tuple1 = Tuple(vs) + try tuple1.validateForVersionstamp() // Should not throw + + let tuple2 = Tuple("no versionstamp") + do { + try tuple2.validateForVersionstamp() + Issue.record("Should throw when no versionstamp") + } catch { + #expect(error is TupleError) + } + + let vs2 = Versionstamp.incomplete(userVersion: 1) + let tuple3 = Tuple(vs, vs2) + do { + try tuple3.validateForVersionstamp() + Issue.record("Should throw when multiple versionstamps") + } catch { + #expect(error is TupleError) + } + } + + // MARK: - Integration Test Structure + // Note: These tests require a running FDB cluster + // Uncomment and adapt when ready for integration testing + + /* + @Test("Integration: Write and read versionstamped key") + func testIntegrationWriteReadVersionstampedKey() async throws { + try await FDBClient.initialize() + let database = try FDBClient.openDatabase() + + let result = try await database.withTransaction { transaction in + let vs = Versionstamp.incomplete(userVersion: 0) + let tuple = Tuple("test_prefix", vs) + let key = try tuple.packWithVersionstamp() + + // Write versionstamped key + transaction.atomicOp( + key: key, + param: [], + mutationType: .setVersionstampedKey + ) + + // Get committed versionstamp + return try await transaction.getVersionstamp() + } + + // Verify versionstamp was returned + #expect(result != nil) + #expect(result!.count == 10) + } + */ +} From b1fe3ec0c4bb07830013703d0709bce9eab7ce25 Mon Sep 17 00:00:00 2001 From: 1amageek Date: Sun, 2 Nov 2025 00:01:08 +0900 Subject: [PATCH 2/4] Add Versionstamp decode support and roundtrip tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit completes the Versionstamp implementation by adding decode support and comprehensive roundtrip tests. ## Tuple.decode() Integration - Add versionstamp case (0x33) to Tuple.decode() switch - Enable automatic Versionstamp decoding in tuples - Allows reading versionstamped keys from database ## Roundtrip Tests - Add 5 roundtrip tests (encode → decode) - Complete versionstamp roundtrip - Incomplete versionstamp roundtrip - Mixed tuple with multiple types - Multiple versionstamps in one tuple - Error handling for insufficient bytes ## Test Fixes - Fix withUnsafeBytes crash by ensuring exact 4-byte array - Add size validation before unsafe memory access - Fix range test expectations (prefix vs prefix + [0x00]) ## Code Cleanup - Remove dead code for API < 520 (no longer supported) - Simplify to single code path using 4-byte offsets - Update documentation to reflect API 520+ requirement All 150 tests now pass successfully. --- Sources/FoundationDB/Tuple+Versionstamp.swift | 32 ++--- Sources/FoundationDB/Tuple.swift | 3 + Tests/FoundationDBTests/SubspaceTests.swift | 5 +- .../FoundationDBTests/VersionstampTests.swift | 109 +++++++++++++++++- 4 files changed, 124 insertions(+), 25 deletions(-) diff --git a/Sources/FoundationDB/Tuple+Versionstamp.swift b/Sources/FoundationDB/Tuple+Versionstamp.swift index 9d6a2b7..c7a196b 100644 --- a/Sources/FoundationDB/Tuple+Versionstamp.swift +++ b/Sources/FoundationDB/Tuple+Versionstamp.swift @@ -27,9 +27,8 @@ extension Tuple { /// This method packs a tuple that contains exactly one incomplete versionstamp, /// and appends the byte offset where the versionstamp appears. /// - /// The offset size depends on API version: - /// - API < 520: 2 bytes (uint16, little-endian) - /// - API >= 520: 4 bytes (uint32, little-endian) + /// The offset is always 4 bytes (uint32, little-endian) as per API version 520+. + /// API versions prior to 520 used 2-byte offsets but are no longer supported. /// /// The resulting key can be used with `SET_VERSIONSTAMPED_KEY` atomic operation. /// At commit time, FoundationDB will replace the 10-byte placeholder with the @@ -81,28 +80,17 @@ extension Tuple { } // Append offset based on API version - // Default to API 520+ behavior (4-byte offset) - let apiVersion = 520 // TODO: Get from FDBClient.apiVersion when available + // Currently defaults to API 520+ behavior (4-byte offset) + // API < 520 used 2-byte offset, but is no longer supported - if apiVersion < 520 { - // API < 520: Use 2-byte offset (uint16, little-endian) - guard position <= UInt16.max else { - throw TupleError.invalidEncoding - } - - let offset = UInt16(position) - packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) - - } else { - // API >= 520: Use 4-byte offset (uint32, little-endian) - guard position <= UInt32.max else { - throw TupleError.invalidEncoding - } - - let offset = UInt32(position) - packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) + // API >= 520: Use 4-byte offset (uint32, little-endian) + guard position <= UInt32.max else { + throw TupleError.invalidEncoding } + let offset = UInt32(position) + packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) + return packed } diff --git a/Sources/FoundationDB/Tuple.swift b/Sources/FoundationDB/Tuple.swift index 5310f21..6df1256 100644 --- a/Sources/FoundationDB/Tuple.swift +++ b/Sources/FoundationDB/Tuple.swift @@ -134,6 +134,9 @@ public struct Tuple: Sendable, Hashable, Equatable { case TupleTypeCode.nested.rawValue: let element = try Tuple.decodeTuple(from: bytes, at: &offset) elements.append(element) + case TupleTypeCode.versionstamp.rawValue: + let element = try Versionstamp.decodeTuple(from: bytes, at: &offset) + elements.append(element) default: throw TupleError.invalidDecoding("Unknown type code: \(typeCode)") } diff --git a/Tests/FoundationDBTests/SubspaceTests.swift b/Tests/FoundationDBTests/SubspaceTests.swift index aa107d1..f2ca5ae 100644 --- a/Tests/FoundationDBTests/SubspaceTests.swift +++ b/Tests/FoundationDBTests/SubspaceTests.swift @@ -122,7 +122,10 @@ struct SubspaceTests { let subspace = Subspace(rootPrefix: "test_special_chars") let (begin, end) = subspace.range() - #expect(begin == subspace.prefix) + // begin should be prefix + [0x00] + #expect(begin == subspace.prefix + [0x00]) + // end should be prefix + [0xFF] + #expect(end == subspace.prefix + [0xFF]) #expect(end != begin) #expect(end.count > 0) } diff --git a/Tests/FoundationDBTests/VersionstampTests.swift b/Tests/FoundationDBTests/VersionstampTests.swift index 58e1850..96ea66e 100644 --- a/Tests/FoundationDBTests/VersionstampTests.swift +++ b/Tests/FoundationDBTests/VersionstampTests.swift @@ -167,7 +167,10 @@ struct VersionstampTests { #expect(packed.count > 13 + 4) // Last 4 bytes should be the offset - let offsetBytes = packed.suffix(4) + #expect(packed.count >= 4, "Packed data must have at least 4 bytes for offset") + let offsetBytes = Array(packed.suffix(4)) + #expect(offsetBytes.count == 4, "Offset must be exactly 4 bytes") + let offset = offsetBytes.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian } // Offset should point to the start of the 10-byte transaction version @@ -188,7 +191,10 @@ struct VersionstampTests { #expect(Array(packed.prefix(3)) == prefix) // Last 4 bytes should be the offset - let offsetBytes = packed.suffix(4) + #expect(packed.count >= 4, "Packed data must have at least 4 bytes for offset") + let offsetBytes = Array(packed.suffix(4)) + #expect(offsetBytes.count == 4, "Offset must be exactly 4 bytes") + let offset = offsetBytes.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian } // Offset should account for prefix length @@ -277,6 +283,105 @@ struct VersionstampTests { } } + // MARK: - Roundtrip Tests (Encode → Decode) + + @Test("Versionstamp roundtrip with complete versionstamp") + func testVersionstampRoundtripComplete() throws { + let original = Versionstamp( + transactionVersion: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A], + userVersion: 42 + ) + let tuple = Tuple("prefix", original, "suffix") + + // Encode + let encoded = tuple.encode() + + // Decode through Tuple.decode() + let decoded = try Tuple.decode(from: encoded) + + #expect(decoded.count == 3) + #expect((decoded[0] as? String) == "prefix") + #expect((decoded[1] as? Versionstamp) == original) + #expect((decoded[2] as? String) == "suffix") + } + + @Test("Versionstamp roundtrip with incomplete versionstamp") + func testVersionstampRoundtripIncomplete() throws { + let original = Versionstamp.incomplete(userVersion: 123) + let tuple = Tuple(original) + + // Encode + let encoded = tuple.encode() + + // Decode + let decoded = try Tuple.decode(from: encoded) + + #expect(decoded.count == 1) + let decodedVS = decoded[0] as? Versionstamp + #expect(decodedVS == original) + #expect(decodedVS?.isComplete == false) + #expect(decodedVS?.userVersion == 123) + } + + @Test("Versionstamp roundtrip mixed tuple") + func testVersionstampRoundtripMixed() throws { + let vs = Versionstamp( + transactionVersion: [0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x99, 0x88, 0x77, 0x66], + userVersion: 999 + ) + let tuple = Tuple( + "string", + Int64(12345), + vs, + true, + [UInt8]([0x01, 0x02, 0x03]) + ) + + let encoded = tuple.encode() + let decoded = try Tuple.decode(from: encoded) + + #expect(decoded.count == 5) + #expect((decoded[0] as? String) == "string") + #expect((decoded[1] as? Int64) == 12345) + #expect((decoded[2] as? Versionstamp) == vs) + #expect((decoded[3] as? Bool) == true) + #expect((decoded[4] as? FDB.Bytes) == [0x01, 0x02, 0x03]) + } + + @Test("Decode versionstamp with insufficient bytes throws error") + func testDecodeVersionstampInsufficientBytes() { + let encoded: FDB.Bytes = [ + TupleTypeCode.versionstamp.rawValue, + 0x01, 0x02, 0x03 // Need 12 bytes but only 3 + ] + + do { + _ = try Tuple.decode(from: encoded) + Issue.record("Should throw error for insufficient bytes") + } catch { + // Expected - should throw TupleError.invalidEncoding + #expect(error is TupleError) + } + } + + @Test("Multiple versionstamps roundtrip") + func testMultipleVersionstampsRoundtrip() throws { + let vs1 = Versionstamp.incomplete(userVersion: 1) + let vs2 = Versionstamp( + transactionVersion: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A], + userVersion: 2 + ) + let tuple = Tuple(vs1, "middle", vs2) + + let encoded = tuple.encode() + let decoded = try Tuple.decode(from: encoded) + + #expect(decoded.count == 3) + #expect((decoded[0] as? Versionstamp) == vs1) + #expect((decoded[1] as? String) == "middle") + #expect((decoded[2] as? Versionstamp) == vs2) + } + // MARK: - Integration Test Structure // Note: These tests require a running FDB cluster // Uncomment and adapt when ready for integration testing From 9936fae90332c5e58fe958d6690a80f85bfabc1e Mon Sep 17 00:00:00 2001 From: 1amageek Date: Fri, 7 Nov 2025 20:38:25 +0900 Subject: [PATCH 3/4] Implement FoundationDB Directory Layer with unified prefix coordinate system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a complete implementation of the FoundationDB Directory Layer, including hierarchical namespace management, partition support, and high contention allocation. The implementation ensures consistency between metadata storage and API return values through a unified prefix coordinate system. ## Key Features - **Directory Layer**: Hierarchical path-to-prefix mapping with automatic allocation - **Partition Support**: Isolated namespaces with their own DirectoryLayer instances - **High Contention Allocator**: Window-based allocation for efficient unique prefixes - **Unified Prefix Coordinates**: Relative prefixes in metadata, absolute in API returns ## Core Components ### DirectoryLayer.swift (1,250 lines) - Unified prefix coordinate system (store relative, return absolute) - Partition-aware operations (create, open, move, list, remove) - Automatic prefix allocation via HCA - Manual prefix validation with conflict detection - Cross-partition move prevention ### DirectoryError.swift (181 lines) - Comprehensive error types with DocC documentation - Detailed error descriptions for debugging ### DirectorySubspace.swift (205 lines) - Lightweight wrapper combining Subspace with path and type - Convenient key encoding/decoding methods ### HighContentionAllocator.swift (218 lines) - Official FDB algorithm with dynamic window sizing - Write conflict detection for concurrent allocations ### DirectoryVersion.swift (56 lines) - Version management for metadata compatibility ## Bug Fixes 1. **Double-prefix bug**: Removed redundant partition prefix concatenation 2. **Cross-partition move**: Added explicit boundary validation 3. **Manual prefix validation**: Unified absolute/relative coordinate systems 4. **Partition list operations**: Delegate to partition layer for correct metadata lookup ## Design Decisions ### Prefix Coordinate System - **Metadata storage**: Relative prefixes (HCA-compatible) - **API return values**: Absolute prefixes (contentSubspace + relative) - **Manual prefixes**: Treated as relative (same as HCA) ### Removed isInsidePartition Flag - Replaced with rootLayer reference for cleaner design - Partition nesting check: `rootLayer != nil` ## Testing Comprehensive test suite with 23 tests covering: - Basic operations (create, open, list, move, remove) - Partition operations and traversal - Manual prefix collision detection - Cross-language metadata compatibility - Deep nested directories within partitions - Multi-level partition directories All tests passing: 23/23 ✅ --- .../Directory/DirectoryError.swift | 181 +++ .../Directory/DirectoryLayer.swift | 1250 +++++++++++++++++ .../Directory/DirectorySubspace.swift | 205 +++ .../Directory/DirectoryVersion.swift | 56 + .../Directory/HighContentionAllocator.swift | 218 +++ .../DirectoryLayerTests.swift | 660 +++++++++ 6 files changed, 2570 insertions(+) create mode 100644 Sources/FoundationDB/Directory/DirectoryError.swift create mode 100644 Sources/FoundationDB/Directory/DirectoryLayer.swift create mode 100644 Sources/FoundationDB/Directory/DirectorySubspace.swift create mode 100644 Sources/FoundationDB/Directory/DirectoryVersion.swift create mode 100644 Sources/FoundationDB/Directory/HighContentionAllocator.swift create mode 100644 Tests/FoundationDBTests/DirectoryLayerTests.swift diff --git a/Sources/FoundationDB/Directory/DirectoryError.swift b/Sources/FoundationDB/Directory/DirectoryError.swift new file mode 100644 index 0000000..16bebe2 --- /dev/null +++ b/Sources/FoundationDB/Directory/DirectoryError.swift @@ -0,0 +1,181 @@ +/* + * DirectoryError.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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 + +/// Errors that can occur during Directory Layer operations +/// +/// DirectoryError represents various failure conditions that may occur when +/// working with the FoundationDB Directory Layer. +public enum DirectoryError: Error, Sendable { + /// Directory not found at the specified path + /// + /// Thrown when attempting to open or operate on a directory that doesn't exist. + /// - Parameter path: The path that was not found + case directoryNotFound(path: [String]) + + /// Directory already exists at the specified path + /// + /// Thrown when attempting to create a directory that already exists. + /// - Parameter path: The path where a directory already exists + case directoryAlreadyExists(path: [String]) + + /// Invalid path (empty or contains empty components) + /// + /// Thrown when a path is malformed or contains invalid components. + /// - Parameters: + /// - path: The invalid path + /// - reason: Detailed explanation of why the path is invalid + case invalidPath(path: [String], reason: String) + + /// Layer mismatch between expected and actual + /// + /// Thrown when opening a directory with a different layer type than expected. + /// For example, opening a partition as a normal directory. + /// - Parameters: + /// - expected: The expected layer type + /// - actual: The actual layer type found + case layerMismatch(expected: DirectoryType?, actual: DirectoryType?) + + /// Cannot create partition inside another partition + /// + /// Thrown when attempting to create a partition within an existing partition. + /// Nested partitions are not supported by the Directory Layer specification. + /// - Parameter path: The path where partition creation was attempted + case cannotCreatePartitionInPartition(path: [String]) + + /// Cannot move directory across partition boundaries + /// + /// Thrown when attempting to move a directory from one partition to another. + /// Directories can only be moved within the same partition. + /// - Parameters: + /// - from: The source path + /// - to: The destination path + case cannotMoveAcrossPartitions(from: [String], to: [String]) + + /// Incompatible Directory Layer version + /// + /// Thrown when the stored version is incompatible with the current implementation. + /// - Parameters: + /// - stored: The version stored in the database + /// - expected: The version expected by this implementation + case incompatibleVersion(stored: DirectoryVersion, expected: DirectoryVersion) + + /// Invalid version format in stored data + /// + /// Thrown when version data in the database is corrupted or malformed. + /// - Parameter data: The invalid version data + case invalidVersion(data: Data) + + /// Directory layer not initialized (for partition operations) + /// + /// Thrown when attempting partition-specific operations on an uninitialized layer. + case directoryLayerNotInitialized + + /// Invalid metadata format + /// + /// Thrown when directory metadata is corrupted or doesn't match expected format. + /// - Parameter reason: Detailed explanation of the metadata issue + case invalidMetadata(reason: String) + + /// Prefix already in use by another directory + /// + /// Thrown when attempting to create a directory with a manually-specified prefix + /// that is already allocated to another directory. + /// - Parameter prefix: The prefix that is already in use + case prefixInUse(prefix: FDB.Bytes) + + /// Prefix conflicts with metadata subspace + /// + /// Thrown when attempting to use a prefix that would overlap with the + /// Directory Layer's internal metadata storage (typically starting with 0xFE). + /// - Parameter prefix: The conflicting prefix + case prefixInMetadataSpace(prefix: FDB.Bytes) +} + +// MARK: - CustomStringConvertible + +extension DirectoryError: CustomStringConvertible { + public var description: String { + switch self { + case .directoryNotFound(let path): + return "Directory not found: \(path.joined(separator: "/"))" + + case .directoryAlreadyExists(let path): + return "Directory already exists: \(path.joined(separator: "/"))" + + case .invalidPath(let path, let reason): + return "Invalid path \(path.joined(separator: "/")): \(reason)" + + case .layerMismatch(let expected, let actual): + let expectedStr: String + if let exp = expected { + switch exp { + case .partition: + expectedStr = "partition" + case .custom(let name): + expectedStr = name + } + } else { + expectedStr = "nil" + } + + let actualStr: String + if let act = actual { + switch act { + case .partition: + actualStr = "partition" + case .custom(let name): + actualStr = name + } + } else { + actualStr = "nil" + } + + return "Layer mismatch: expected \(expectedStr), got \(actualStr)" + + case .cannotCreatePartitionInPartition(let path): + return "Cannot create partition inside another partition: \(path.joined(separator: "/"))" + + case .cannotMoveAcrossPartitions(let from, let to): + return "Cannot move directory across partitions: \(from.joined(separator: "/")) → \(to.joined(separator: "/"))" + + case .incompatibleVersion(let stored, let expected): + return "Incompatible Directory Layer version: stored \(stored.major).\(stored.minor).\(stored.patch), expected \(expected.major).\(expected.minor).\(expected.patch)" + + case .invalidVersion(let data): + return "Invalid version format: \(data.map { String(format: "%02x", $0) }.joined())" + + case .directoryLayerNotInitialized: + return "Directory layer not initialized (only available for partitions)" + + case .invalidMetadata(let reason): + return "Invalid metadata: \(reason)" + + case .prefixInUse(let prefix): + let hexString = prefix.map { String(format: "%02x", $0) }.joined() + return "Prefix already in use: 0x\(hexString)" + + case .prefixInMetadataSpace(let prefix): + let hexString = prefix.map { String(format: "%02x", $0) }.joined() + return "Prefix conflicts with metadata subspace: 0x\(hexString)" + } + } +} diff --git a/Sources/FoundationDB/Directory/DirectoryLayer.swift b/Sources/FoundationDB/Directory/DirectoryLayer.swift new file mode 100644 index 0000000..9af2cda --- /dev/null +++ b/Sources/FoundationDB/Directory/DirectoryLayer.swift @@ -0,0 +1,1250 @@ +/* + * DirectoryLayer.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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 + + +/// Directory layer type +/// +/// Defines the layer type for a directory, which affects how the directory +/// manages its metadata and child directories. +/// +/// **Standard Types**: +/// - `partition`: A partition directory that provides isolated namespace +/// +/// **Custom Types**: +/// ```swift +/// let customLayer: DirectoryType = .custom("my-layer") +/// ``` +public enum DirectoryType: Sendable, Equatable, Hashable { + /// Standard partition layer type + /// + /// Partitions provide isolated namespaces with their own DirectoryLayer instance. + /// All subdirectories within a partition share a common prefix and cannot be + /// moved outside the partition boundary. + case partition + + /// Custom layer type with specified name + /// + /// Allows applications to define custom directory layer behaviors. + /// The name is stored as metadata and can be used to identify special + /// directory types. + /// - Parameter name: The custom layer name as a String + case custom(String) + + /// Raw value as FDB.Bytes for storage + public var rawValue: FDB.Bytes { + switch self { + case .partition: + return Array("partition".utf8) + case .custom(let name): + return Array(name.utf8) + } + } + + /// Initialize from raw FDB.Bytes + /// + /// - Parameter rawValue: Raw bytes value + /// - Returns: DirectoryType, or nil if invalid UTF-8 + public init?(rawValue: FDB.Bytes) { + guard let string = String(bytes: rawValue, encoding: .utf8) else { + return nil + } + + switch string { + case "partition": + self = .partition + default: + self = .custom(string) + } + } +} + +/// FoundationDB Directory Layer implementation +/// +/// This implementation follows the official FoundationDB specification used +/// across Python, Java, Go, and Ruby language bindings. +/// +/// **Metadata Structure**: +/// ``` +/// nodeSubspace[parentPrefix][subdirs=0][childName] → childPrefix +/// nodeSubspace[nodePrefix]["layer"] → layerBytes +/// nodeSubspace[rootNode]["version"] → versionArray +/// nodeSubspace[rootNode]["hca"] → HCA subspace +/// ``` +/// +/// **Example**: +/// ```swift +/// let directoryLayer = DirectoryLayer(database: database) +/// let usersDir = try await directoryLayer.createOrOpen(path: ["app", "users"]) +/// ``` +public final class DirectoryLayer: Sendable { + + // MARK: - Properties + + /// Database instance + nonisolated(unsafe) private let database: any DatabaseProtocol + + /// Node subspace for directory metadata + private let nodeSubspace: Subspace + + /// Content subspace for directory data + private let contentSubspace: Subspace + + /// Root node (same as nodeSubspace for root layer) + private let rootNode: Subspace + + /// High Contention Allocator for prefix allocation + private let allocator: HighContentionAllocator + + /// Root directory layer (nil if this IS the root) + /// This allows DirectorySubspace to always reference the root layer + /// for consistent absolute path resolution + private let rootLayer: DirectoryLayer? + + // MARK: - Node Keys + + /// Subdirectory key constant (value = 0) + private static let subdirs: Int64 = 0 + + /// Layer key constant + private static let layerKey = "layer" + + /// Version key constant + private static let versionKey = "version" + + // MARK: - Initialization + + /// Initialize Directory Layer + /// + /// - Parameters: + /// - database: Database instance + /// - nodeSubspace: Node subspace for metadata (default: prefix 0xFE) + /// - contentSubspace: Content subspace for data (default: empty prefix) + /// - rootLayer: Root directory layer (nil if this IS the root, used internally for partitions) + public init( + database: any DatabaseProtocol, + nodeSubspace: Subspace? = nil, + contentSubspace: Subspace? = nil, + rootLayer: DirectoryLayer? = nil + ) { + self.database = database + self.nodeSubspace = nodeSubspace ?? Subspace(prefix: [0xFE]) + self.contentSubspace = contentSubspace ?? Subspace(prefix: []) + self.rootNode = self.nodeSubspace + self.rootLayer = rootLayer + + // Initialize HCA with nodeSubspace["hca"] + let hcaSubspace = self.rootNode.subspace("hca") + self.allocator = HighContentionAllocator(database: database, subspace: hcaSubspace) + } + + // MARK: - Public Methods + + /// Create or open a directory at the specified path + /// + /// - Parameters: + /// - path: Directory path as array of strings + /// - layer: Optional layer type + /// - Returns: DirectorySubspace representing the directory + /// - Throws: DirectoryError if operation fails + public func createOrOpen( + path: [String], + type: DirectoryType? = nil + ) async throws -> DirectorySubspace { + try validatePath(path) + + return try await database.withTransaction { transaction in + try await self.createOrOpenInternal( + transaction: transaction, + path: path, + type: type, + prefix: nil, + allowCreate: true, + allowOpen: true + ) + } + } + + /// Create a new directory at the specified path + /// + /// - Parameters: + /// - path: Directory path as array of strings + /// - layer: Optional layer type + /// - prefix: Optional custom prefix (if nil, allocates automatically) + /// - Returns: DirectorySubspace representing the created directory + /// - Throws: DirectoryError.directoryAlreadyExists if directory exists + public func create( + path: [String], + type: DirectoryType? = nil, + prefix: FDB.Bytes? = nil + ) async throws -> DirectorySubspace { + try validatePath(path) + + return try await database.withTransaction { transaction in + try await self.createOrOpenInternal( + transaction: transaction, + path: path, + type: type, + prefix: prefix, + allowCreate: true, + allowOpen: false + ) + } + } + + /// Open an existing directory at the specified path + /// + /// - Parameter path: Directory path as array of strings + /// - Returns: DirectorySubspace representing the directory + /// - Throws: DirectoryError.directoryNotFound if directory doesn't exist + public func open( + path: [String] + ) async throws -> DirectorySubspace { + try validatePath(path) + + return try await database.withTransaction { transaction in + try await self.createOrOpenInternal( + transaction: transaction, + path: path, + type: nil, + prefix: nil, + allowCreate: false, + allowOpen: true + ) + } + } + + /// Move a directory from one path to another + /// + /// - Parameters: + /// - oldPath: Current directory path + /// - newPath: New directory path + /// - Returns: DirectorySubspace at the new location + /// - Throws: DirectoryError if source doesn't exist or destination exists + public func move( + oldPath: [String], + newPath: [String] + ) async throws -> DirectorySubspace { + try validatePath(oldPath) + try validatePath(newPath) + + return try await database.withTransaction { transaction in + // Check partition ancestors for both paths + let oldPartitionAncestor = try await findPartitionAncestor( + transaction: transaction, + path: oldPath + ) + let newPartitionAncestor = try await findPartitionAncestor( + transaction: transaction, + path: newPath + ) + + // Check if both paths are in the same partition - if so, delegate to partition layer + if let (oldPartitionPath, oldPartitionDir) = oldPartitionAncestor, + let (newPartitionPath, _) = newPartitionAncestor, + oldPartitionPath == newPartitionPath { + // Both paths are in the same partition - delegate to partition layer + let oldPathInPartition = Array(oldPath.dropFirst(oldPartitionPath.count)) + let newPathInPartition = Array(newPath.dropFirst(newPartitionPath.count)) + + let partitionLayer = try createPartitionLayer(prefix: oldPartitionDir.prefix) + let movedSubspace = try await partitionLayer.move( + oldPath: oldPathInPartition, + newPath: newPathInPartition + ) + + // Return with absolute path + // BUG FIX: Same double-prefix issue - partition layer already returns absolute prefix + return DirectorySubspace( + prefix: movedSubspace.prefix, + path: newPath, + type: movedSubspace.type + ) + } + + // BUG FIX: Detect cross-partition moves and reject them + // If partition ancestors don't match, we cannot move across boundaries + if (oldPartitionAncestor != nil || newPartitionAncestor != nil) && + oldPartitionAncestor?.0 != newPartitionAncestor?.0 { + throw DirectoryError.cannotMoveAcrossPartitions(from: oldPath, to: newPath) + } + + // Use resolve() for unified path resolution + guard let oldNode = try await self.resolve(transaction: transaction, path: oldPath) else { + throw DirectoryError.directoryNotFound(path: oldPath) + } + + // Check destination doesn't exist + if let _ = try await self.resolve(transaction: transaction, path: newPath) { + throw DirectoryError.directoryAlreadyExists(path: newPath) + } + + // Create parent path if necessary + if newPath.count > 1 { + let parentPath = Array(newPath.dropLast()) + _ = try await self.createOrOpenInternal( + transaction: transaction, + path: parentPath, + type: nil, + prefix: nil, + allowCreate: true, + allowOpen: true + ) + } + + // Remove old parent's reference + if !oldPath.isEmpty { + let oldParentPath = Array(oldPath.dropLast()) + let oldDirectoryName = oldPath.last! + + guard let oldParentDirectory = try await self.resolve(transaction: transaction, path: oldParentPath) else { + // Parent doesn't exist - this shouldn't happen + throw DirectoryError.directoryNotFound(path: oldParentPath) + } + + // Use unified helper to get metadata + let oldParentMetadata = try await getMetadata(transaction: transaction, for: oldParentDirectory) + let oldSubdirectoryKey = oldParentMetadata + .subspace(Self.subdirs) + .pack(Tuple(oldDirectoryName)) + transaction.clear(key: Array(oldSubdirectoryKey)) + } + + // Add new parent's reference + let newParentPath = Array(newPath.dropLast()) + let newDirectoryName = newPath.last! + + guard let newParentDirectory = try await self.resolve(transaction: transaction, path: newParentPath) else { + // Parent doesn't exist - this shouldn't happen after creation above + throw DirectoryError.directoryNotFound(path: newParentPath) + } + + // Use unified helper to get metadata + let newParentMetadata = try await getMetadata(transaction: transaction, for: newParentDirectory) + let newSubdirectoryKey = newParentMetadata + .subspace(Self.subdirs) + .pack(Tuple(newDirectoryName)) + transaction.setValue( + Array(oldNode.prefix), + for: Array(newSubdirectoryKey) + ) + + // Return DirectorySubspace with new path + return DirectorySubspace( + subspace: Subspace(prefix: Array(oldNode.prefix)), + path: newPath, // Use original requested newPath + type: oldNode.type + ) + } + } + + /// Remove a directory and all its contents + /// + /// - Parameter path: Directory path to remove + /// - Throws: DirectoryError.directoryNotFound if directory doesn't exist + public func remove( + path: [String] + ) async throws { + try validatePath(path) + + try await database.withTransaction { transaction in + try await self.removeInternal(transaction: transaction, path: path) + } + } + + /// Internal remove implementation with transaction parameter + private func removeInternal( + transaction: any TransactionProtocol, + path: [String] + ) async throws { + // Check if path is in a partition - if so, delegate to partition layer + if let (partitionPath, partitionDir) = try await findPartitionAncestor( + transaction: transaction, + path: path + ) { + // Path is in a partition - delegate to partition layer + let pathInPartition = Array(path.dropFirst(partitionPath.count)) + + let partitionLayer = try createPartitionLayer(prefix: partitionDir.prefix) + try await partitionLayer.removeInternal( + transaction: transaction, + path: pathInPartition + ) + return + } + + // Use resolve() for unified path resolution + guard let node = try await self.resolve(transaction: transaction, path: path) else { + throw DirectoryError.directoryNotFound(path: path) + } + + // Remove recursively + try await removeRecursiveNode(transaction: transaction, dir: node) + + // Remove parent's reference + if !node.path.isEmpty { + let parentPath = Array(node.path.dropLast()) + let directoryName = node.path.last! + + guard let parentDirectory = try await self.resolve(transaction: transaction, path: parentPath) else { + // Parent doesn't exist - this shouldn't happen but handle gracefully + return + } + + // Calculate parent metadata using unified helper + let parentMetadata = try await getMetadata(transaction: transaction, for: parentDirectory) + let subdirectoryKey = parentMetadata + .subspace(Self.subdirs) + .pack(Tuple(directoryName)) + transaction.clear(key: Array(subdirectoryKey)) + } + } + + /// Check if a directory exists + /// + /// - Parameter path: Directory path to check + /// - Returns: true if directory exists, false otherwise + public func exists( + path: [String] + ) async throws -> Bool { + try validatePath(path) + + return try await database.withTransaction { transaction in + let node = try await self.resolve(transaction: transaction, path: path) + return node != nil + } + } + + /// List subdirectories of a directory + /// + /// - Parameter path: Directory path to list + /// - Returns: Array of subdirectory names + /// - Throws: DirectoryError.directoryNotFound if directory doesn't exist + public func list( + path: [String] + ) async throws -> [String] { + return try await database.withTransaction { transaction in + // Check if path is inside a partition - if so, delegate to partition layer + if let (partitionPath, partitionDirectory) = try await findPartitionAncestor( + transaction: transaction, + path: path + ) { + // Path is inside a partition - delegate to partition layer + let pathInPartition = Array(path.dropFirst(partitionPath.count)) + let partitionLayer = try createPartitionLayer(prefix: partitionDirectory.prefix) + return try await partitionLayer.list(path: pathInPartition) + } + + // Normal path - use resolve() for unified path resolution + guard let directory = try await self.resolve(transaction: transaction, path: path) else { + throw DirectoryError.directoryNotFound(path: path) + } + + // Use unified helper that works for both partition and normal directories + return try await self.listChildren( + transaction: transaction, + directory: directory + ) + } + } + + // MARK: - Internal Operations + + /// Internal create or open implementation + /// + /// Swift design: Clean separation between path resolution and directory creation. + /// All paths are absolute from root. + private func createOrOpenInternal( + transaction: any TransactionProtocol, + path: [String], + type: DirectoryType?, + prefix: FDB.Bytes?, + allowCreate: Bool, + allowOpen: Bool + ) async throws -> DirectorySubspace { + // Check version on first access + try await checkVersion(transaction: transaction) + + // Resolve the path + if let existingNode = try await self.resolve(transaction: transaction, path: path) { + // === Handle existing directory === + guard allowOpen else { + throw DirectoryError.directoryAlreadyExists(path: path) + } + + // Check layer compatibility + if let expectedLayer = type, existingNode.type != expectedLayer { + throw DirectoryError.layerMismatch(expected: expectedLayer, actual: existingNode.type) + } + + // Return the existing directory + return existingNode + } + + // === Handle new directory === + guard allowCreate else { + throw DirectoryError.directoryNotFound(path: path) + } + + // Check if any ancestor is a partition - if so, delegate to partition layer + if let (partitionPath, partitionDirectory) = try await findPartitionAncestor( + transaction: transaction, + path: path + ) { + // Found a partition ancestor - delegate creation to partition layer + let pathInPartition = Array(path.dropFirst(partitionPath.count)) + let partitionLayer = try createPartitionLayer(prefix: partitionDirectory.prefix) + + let subspace = try await partitionLayer.createOrOpenInternal( + transaction: transaction, + path: pathInPartition, + type: type, + prefix: prefix, + allowCreate: allowCreate, + allowOpen: allowOpen + ) + + // Return with absolute path and absolute prefix + // BUG FIX: partitionLayer already returns absolute prefix (contentSubspace.prefix + newPrefix) + // Don't prepend partition prefix again - it causes double-prefixing + return DirectorySubspace( + prefix: subspace.prefix, + path: path, + type: subspace.type + ) + } + + // No partition ancestors - proceed with normal creation in this layer + let parentPath = Array(path.dropLast()) + if !parentPath.isEmpty { + // Ensure parent exists (may need to create it) + if let _ = try await resolve(transaction: transaction, path: parentPath) { + // Parent exists, continue below + } else { + // Recursively create parent + _ = try await createOrOpenInternal( + transaction: transaction, + path: parentPath, + type: nil, + prefix: nil, + allowCreate: true, + allowOpen: true + ) + } + } + + // Check partition nesting - partitions cannot be created inside other partitions + // If rootLayer is not nil, this layer is already inside a partition + if type == .partition && rootLayer != nil { + throw DirectoryError.cannotCreatePartitionInPartition(path: path) + } + + // Allocate prefix + // Determine the prefix to use + let absolutePrefix: FDB.Bytes + let relativePrefix: FDB.Bytes + + if let manualPrefix = prefix { + // Manual prefix is treated as RELATIVE (same coordinate system as HCA) + relativePrefix = manualPrefix + absolutePrefix = contentSubspace.prefix + relativePrefix + + // Validate the absolute prefix + try await validatePrefix(transaction: transaction, prefix: absolutePrefix) + } else { + // HCA allocation - returns relative prefix + relativePrefix = try await self.allocator.allocate(transaction: transaction) + absolutePrefix = contentSubspace.prefix + relativePrefix + } + + // Store subdirectory reference in parent + guard let dirName = path.last else { + throw DirectoryError.invalidPath(path: path, reason: "Cannot create directory with empty name") + } + + // Get parent's metadata subspace + let parentMetadata: Subspace + if parentPath.isEmpty { + // Root directory - use this layer's root node + parentMetadata = self.rootNode + } else { + // Parent exists in this layer (we checked/created it above) + // Calculate parent metadata + guard let freshParentDirectory = try await resolve( + transaction: transaction, + path: parentPath + ) else { + throw DirectoryError.directoryNotFound(path: parentPath) + } + + // Use getMetadata() helper for consistency + parentMetadata = try await getMetadata(transaction: transaction, for: freshParentDirectory) + } + + // Store RELATIVE prefix in metadata + let subdirKey = parentMetadata + .subspace(Self.subdirs) + .pack(Tuple(dirName)) + transaction.setValue(Array(relativePrefix), for: Array(subdirKey)) + + // Store layer metadata using relative prefix + if let layerType = type { + let prefixSubspace = Subspace(prefix: self.nodeSubspace.prefix + relativePrefix) + let layerKey = prefixSubspace.pack(Tuple(Self.layerKey)) + transaction.setValue(layerType.rawValue, for: Array(layerKey)) + } + + // Create and return DirectorySubspace + if type == .partition { + // For partitions, absolutePrefix becomes the partition's contentSubspace + let partitionLayer = try createPartitionLayer(prefix: absolutePrefix) + return DirectorySubspace( + subspace: partitionLayer.contentSubspace, + path: path, + type: type + ) + } + + // Normal directory - return with ABSOLUTE prefix + return DirectorySubspace( + subspace: Subspace(prefix: Array(absolutePrefix)), + path: path, + type: type + ) + } + + /// Resolve path to DirectorySubspace + /// + /// Swift design: Simple, unified path resolution with automatic partition traversal. + /// Always operates on absolute paths from root. + /// + /// - Parameters: + /// - transaction: Transaction to use for lookups + /// - path: Absolute path to resolve + /// - Returns: DirectorySubspace if exists, nil otherwise + internal func resolve( + transaction: any TransactionProtocol, + path: [String] + ) async throws -> DirectorySubspace? { + let currentLayer = self + var currentPath: [String] = [] + var currentPrefix = rootNode.prefix + var currentMetadata = rootNode + + // Empty path = root directory + if path.isEmpty { + // Root directory should return contentSubspace.prefix for data writes + // This ensures data is written to the content space, not metadata space + return DirectorySubspace( + prefix: contentSubspace.prefix, + path: [], + type: nil + ) + } + + // Traverse path one component at a time + for (index, name) in path.enumerated() { + currentPath.append(name) + + // Look up subdirectory in current layer + let subdirKey = currentMetadata + .subspace(Self.subdirs) + .pack(Tuple(name)) + + guard let relativePrefix = try await transaction.getValue( + for: Array(subdirKey), + snapshot: false + ) else { + // Directory doesn't exist + return nil + } + + // Directory exists - convert RELATIVE prefix to ABSOLUTE + let absolutePrefix = contentSubspace.prefix + relativePrefix + currentPrefix = absolutePrefix + currentMetadata = Subspace(prefix: currentLayer.nodeSubspace.prefix + relativePrefix) + let type = try await loadLayer(transaction: transaction, subspace: currentMetadata) + + // Check if this is a partition + if type == .partition { + // Get remaining path components + let remaining = Array(path.dropFirst(index + 1)) + + if remaining.isEmpty { + // This IS the partition node itself - return with absolute prefix + return DirectorySubspace( + prefix: absolutePrefix, + path: currentPath, + type: type + ) + } + + // Traverse into partition for remaining path + let partitionLayer = try createPartitionLayer(prefix: absolutePrefix) + guard let partitionDir = try await partitionLayer.resolve( + transaction: transaction, + path: remaining + ) else { + return nil + } + + // Return with absolute path and absolute prefix + // partitionDir.prefix is already absolute from partition layer + return DirectorySubspace( + prefix: partitionDir.prefix, + path: currentPath + partitionDir.path, + type: partitionDir.type + ) + } + + // Normal directory - continue to next component + } + + // Reached end of path - return with ABSOLUTE prefix + return DirectorySubspace( + prefix: currentPrefix, + path: currentPath, + type: try await loadLayer(transaction: transaction, subspace: currentMetadata) + ) + } + + /// Legacy find method (calls resolve and converts to Node) + private func find( + transaction: any TransactionProtocol, + path: [String] + ) async throws -> Node { + let dir = try await resolve(transaction: transaction, path: path) + if let dir = dir { + // Directory exists + let metadata = Subspace(prefix: nodeSubspace.prefix + dir.prefix) + return Node( + subspace: metadata, + path: dir.path, + prefix: dir.prefix, + exists: true, + type: dir.type + ) + } else { + // Directory doesn't exist + return Node( + subspace: nodeSubspace, + path: path, + prefix: [], + exists: false, + type: nil + ) + } + } + + /// Load layer metadata for a node + private func loadLayer( + transaction: any TransactionProtocol, + subspace: Subspace + ) async throws -> DirectoryType? { + let layerKey = subspace.pack(Tuple(Self.layerKey)) + guard let layerData = try await transaction.getValue(for: Array(layerKey), snapshot: false) else { + return nil + } + return DirectoryType(rawValue: layerData) + } + + /// List subdirectories of a node + private func listInternal( + transaction: any TransactionProtocol, + node: Subspace + ) async throws -> [String] { + let subdirsSubspace = node.subspace(Self.subdirs) + let (begin, end) = subdirsSubspace.range() + + var children: Set = [] + + let sequence = transaction.getRange( + beginSelector: .firstGreaterOrEqual(begin), + endSelector: .firstGreaterOrEqual(end), + snapshot: true + ) + + for try await (key, _) in sequence { + do { + let tuple = try subdirsSubspace.unpack(Array(key)) + if let childName = tuple[0] as? String { + children.insert(childName) + } + } catch { + // Skip keys that don't belong to this subspace + // This can happen during partition traversal or with corrupted data + continue + } + } + + return Array(children).sorted() + } + + // MARK: - Partition Helper Methods + + /// Find the closest partition ancestor in a path + /// + /// Traverses the path from root to target, returning the first partition encountered. + /// + /// - Parameters: + /// - transaction: Transaction to use + /// - path: Path to check for partition ancestors + /// - Returns: Tuple of (partition path, partition directory) if found, nil otherwise + private func findPartitionAncestor( + transaction: any TransactionProtocol, + path: [String] + ) async throws -> (path: [String], directory: DirectorySubspace)? { + // Empty path has no ancestors + guard !path.isEmpty else { + return nil + } + + // Check each ancestor from immediate parent down to root + for depth in (1...path.count).reversed() { + let ancestorPath = Array(path.prefix(depth)) + + if let ancestor = try await resolve(transaction: transaction, path: ancestorPath) { + if ancestor.type == .partition { + return (ancestorPath, ancestor) + } + } + } + + return nil + } + + // MARK: - Unified Helper Methods + + /// Calculate metadata subspace for a directory + /// + /// Metadata is stored at: nodeSubspace.prefix + relativePrefix + /// where relativePrefix = directory.prefix - contentSubspace.prefix + /// + /// - Parameters: + /// - transaction: Transaction (unused but kept for API compatibility) + /// - directory: Directory to get metadata for + /// - Returns: Subspace containing the directory's metadata + private func getMetadata( + transaction: any TransactionProtocol, + for directory: DirectorySubspace + ) async throws -> Subspace { + // Special case: root directory uses rootNode directly + if directory.path.isEmpty { + return rootNode + } + + // directory.prefix is always ABSOLUTE + // Convert to RELATIVE prefix by stripping contentSubspace.prefix + let relativePrefix: FDB.Bytes + if !contentSubspace.prefix.isEmpty && directory.prefix.starts(with: contentSubspace.prefix) { + relativePrefix = Array(directory.prefix.dropFirst(contentSubspace.prefix.count)) + } else { + relativePrefix = directory.prefix + } + + // Metadata subspace = nodeSubspace.prefix + relativePrefix + return Subspace(prefix: nodeSubspace.prefix + relativePrefix) + } + + /// List children of a directory (unified for partition and normal directories) + /// + /// This method handles both partition and normal directories transparently: + /// - For partitions: Delegates to the partition's DirectoryLayer + /// - For normal directories: Lists subdirectories from metadata + /// + /// - Parameters: + /// - transaction: Transaction to use + /// - directory: Directory to list children of + /// - Returns: Array of child directory names + private func listChildren( + transaction: any TransactionProtocol, + directory: DirectorySubspace + ) async throws -> [String] { + // Handle partition: delegate to partition layer + if directory.type == .partition { + let partitionLayer = try createPartitionLayer(prefix: directory.prefix) + return try await partitionLayer.listInternal( + transaction: transaction, + node: partitionLayer.rootNode + ) + } + + // Handle normal directory: list from metadata + let directoryMetadata = try await getMetadata(transaction: transaction, for: directory) + return try await listInternal( + transaction: transaction, + node: directoryMetadata + ) + } + + /// Remove DirectorySubspace and all its descendants recursively + /// + /// This method uses unified helpers to work correctly for both partition + /// and normal directories. + /// + /// - Parameters: + /// - transaction: Transaction to use + /// - directory: Directory to remove recursively + private func removeRecursiveNode( + transaction: any TransactionProtocol, + dir directory: DirectorySubspace + ) async throws { + // List ALL children using unified helper (works for partition and normal) + let childNames = try await listChildren( + transaction: transaction, + directory: directory + ) + + // Recursively remove children + for childName in childNames { + guard let childDirectory = try await self.resolve( + transaction: transaction, + path: directory.path + [childName] + ) else { + continue + } + try await self.removeRecursiveNode( + transaction: transaction, + dir: childDirectory + ) + } + + // Remove directory metadata using unified helper + let directoryMetadata = try await getMetadata(transaction: transaction, for: directory) + let metadataRange = directoryMetadata.range() + transaction.clearRange( + beginKey: Array(metadataRange.begin), + endKey: Array(metadataRange.end) + ) + + // Remove directory content + let contentSubspace = Subspace(prefix: directory.prefix) + let contentRange = contentSubspace.range() + transaction.clearRange( + beginKey: Array(contentRange.begin), + endKey: Array(contentRange.end) + ) + } + + /// Remove node and all its descendants recursively (legacy) + @available(*, deprecated, message: "Use removeRecursiveNode instead") + private func removeRecursive( + transaction: any TransactionProtocol, + node: Node + ) async throws { + // Convert to DirectorySubspace and use new method + let dir = DirectorySubspace( + prefix: node.prefix, + path: node.path, + type: node.type + ) + try await removeRecursiveNode(transaction: transaction, dir: dir) + } + + /// Create partition layer from node + private func createPartitionLayer(from node: Node) throws -> DirectoryLayer { + return try createPartitionLayer(prefix: node.prefix) + } + + /// Create partition layer with prefix + private func createPartitionLayer(prefix: FDB.Bytes) throws -> DirectoryLayer { + // Partition's nodeSubspace is at prefix + 0xFE (raw bytes, not tuple-encoded) + // This matches the official specification where 0xFE is a reserved system prefix + let partitionNodeSubspace = Subspace(prefix: prefix + [0xFE]) + let partitionContentSubspace = Subspace(prefix: prefix) + + // DESIGN FIX: Propagate rootLayer + // If this layer is already inside a partition, pass through its rootLayer + // Otherwise, this layer IS the root, so pass self as the root + return DirectoryLayer( + database: database, + nodeSubspace: partitionNodeSubspace, + contentSubspace: partitionContentSubspace, + rootLayer: self.rootLayer ?? self // Propagate root layer + ) + } + + /// Check and initialize version + private func checkVersion(transaction: any TransactionProtocol) async throws { + let versionKey = rootNode.pack(Tuple(Self.versionKey)) + + if let versionData = try await transaction.getValue(for: Array(versionKey), snapshot: false) { + // Parse stored version + let tuple = try Tuple.decode(from: versionData) + guard tuple.count == 3 else { + throw DirectoryError.invalidVersion(data: Data(versionData)) + } + + // Try to extract version numbers (could be Int or Int64) + func extractInt(_ element: any TupleElement) -> Int? { + if let value = element as? Int { + return value + } else if let value = element as? Int64 { + return Int(value) + } else if let value = element as? Int32 { + return Int(value) + } + return nil + } + + guard let major = extractInt(tuple[0]), + let minor = extractInt(tuple[1]), + let patch = extractInt(tuple[2]) else { + throw DirectoryError.invalidVersion(data: Data(versionData)) + } + + let storedVersion = DirectoryVersion( + major: major, + minor: minor, + patch: patch + ) + + // Check compatibility + if !DirectoryVersion.current.isCompatible(with: storedVersion) { + throw DirectoryError.incompatibleVersion( + stored: storedVersion, + expected: DirectoryVersion.current + ) + } + } else { + // First time - write version + let version = DirectoryVersion.current + let versionTuple = Tuple(version.major, version.minor, version.patch) + transaction.setValue( + versionTuple.encode(), + for: Array(versionKey) + ) + } + } + + /// Validate path + private func validatePath(_ path: [String]) throws { + guard !path.isEmpty else { + throw DirectoryError.invalidPath(path: path, reason: "Path cannot be empty") + } + + for component in path { + guard !component.isEmpty else { + throw DirectoryError.invalidPath(path: path, reason: "Path component cannot be empty") + } + } + } + + /// Validate user-supplied prefix for collisions + /// + /// Checks if the prefix conflicts with: + /// 1. System metadata subspaces (0xFE) + /// 2. Prefixes already allocated by HCA + /// 3. Prefixes stored in nodeSubspace subdirectories + /// + /// - Parameters: + /// - transaction: Transaction to use for validation + /// - prefix: User-supplied prefix to validate + /// - Throws: DirectoryError if prefix conflicts with metadata or existing directories + private func validatePrefix( + transaction: any TransactionProtocol, + prefix: FDB.Bytes + ) async throws { + // 1. Check if prefix conflicts with metadata subspaces + if isPrefixInMetadataSpace(prefix) { + throw DirectoryError.prefixInMetadataSpace(prefix: prefix) + } + + // 2. Check HCA recent allocations + // HCA allocates prefixes as Tuple(candidate).encode() where candidate is an Int64 + // So if this prefix is HCA-allocated, we need to decode it back to the candidate + // and check recent.pack(Tuple(candidate)) + let hcaSubspace = rootNode.subspace("hca") + let recentSubspace = hcaSubspace.subspace(1) // HCA recent subspace + + // Try to decode prefix as a tuple to get the candidate number + // If it's not HCA-allocated, this will fail or return non-integer + do { + let elements = try Tuple.decode(from: prefix) + // If prefix decodes to a single integer, it might be HCA-allocated + if elements.count == 1, let candidate = elements[0] as? Int64 { + // Check if this candidate is in recent allocations + let recentKey = recentSubspace.pack(Tuple(candidate)) + if let _ = try await transaction.getValue(for: Array(recentKey), snapshot: false) { + throw DirectoryError.prefixInUse(prefix: prefix) + } + } + } catch { + // Prefix is not tuple-encoded (manual prefix), skip HCA check + // Manual prefixes won't be in HCA recent allocations anyway + } + + // 3. Scan subdirectory entries only (not all metadata) + // We need to check all parent[SUBDIRS][*] = prefix entries + // This is more targeted than scanning the entire nodeSubspace + try await validatePrefixRecursive( + transaction: transaction, + prefix: prefix, + node: rootNode, + path: [] + ) + } + + /// Recursively validate prefix against directory tree + /// + /// - Parameters: + /// - transaction: Transaction to use + /// - prefix: ABSOLUTE prefix to validate + /// - node: Current node subspace + /// - path: Current path + private func validatePrefixRecursive( + transaction: any TransactionProtocol, + prefix: FDB.Bytes, + node: Subspace, + path: [String] + ) async throws { + // Check subdirectories at this level + let subdirsSubspace = node.subspace(Self.subdirs) + let (begin, end) = subdirsSubspace.range() + + for try await (key, value) in transaction.getRange( + beginKey: begin, + endKey: end, + snapshot: true // Use snapshot for read-only validation + ) { + // value is the RELATIVE prefix of a subdirectory + let relativePrefix = value + + // Convert to ABSOLUTE prefix for comparison + let existingAbsolutePrefix = contentSubspace.prefix + relativePrefix + + // Check for conflicts with absolute prefixes + if existingAbsolutePrefix == prefix { + throw DirectoryError.prefixInUse(prefix: prefix) + } + + // Check if one prefix is a prefix of the other + if prefix.starts(with: existingAbsolutePrefix) || existingAbsolutePrefix.starts(with: prefix) { + throw DirectoryError.prefixInUse(prefix: prefix) + } + + // IMPORTANT: Recursively check this subdirectory's children + // We must check ALL levels, not just direct subdirs + // Extract child name from key: node[SUBDIRS][childName] + do { + let keyTuple = try subdirsSubspace.unpack(key) + if keyTuple.count > 0, let childName = keyTuple[0] as? String { + // Create child node subspace - relativePrefix is raw bytes + let childNode = Subspace(prefix: nodeSubspace.prefix + relativePrefix) + let childPath = path + [childName] + + // Check if this child is a partition + let layer = try await loadLayer(transaction: transaction, subspace: childNode) + + if layer == .partition { + // Delegate validation to the partition's internal DirectoryLayer + // Only validate if the candidate prefix is inside the partition + if prefix.starts(with: existingAbsolutePrefix) { + // Convert absolute prefix to partition's coordinate system + let partitionRelativePrefix = Array(prefix.dropFirst(existingAbsolutePrefix.count)) + let partitionLayer = try createPartitionLayer(prefix: existingAbsolutePrefix) + try await partitionLayer.validatePrefixRecursive( + transaction: transaction, + prefix: partitionLayer.contentSubspace.prefix + partitionRelativePrefix, + node: partitionLayer.rootNode, + path: childPath + ) + } + // If prefix doesn't start with partition prefix, it won't conflict with partition contents + } else { + // Recurse into child directory normally + try await validatePrefixRecursive( + transaction: transaction, + prefix: prefix, + node: childNode, + path: childPath + ) + } + } + } catch { + // Skip keys that can't be unpacked (might be from different subspace) + continue + } + } + } + + /// Check if prefix conflicts with metadata subspace + /// + /// Prefixes should be in contentSubspace but NOT in nodeSubspace. + /// Only check for conflicts with nodeSubspace (metadata space). + private func isPrefixInMetadataSpace(_ prefix: FDB.Bytes) -> Bool { + // Check if prefix overlaps with nodeSubspace (metadata space) + // This is the only conflict we care about + if prefix.starts(with: nodeSubspace.prefix) || + nodeSubspace.prefix.starts(with: prefix) { + return true + } + + return false + } + + /// Create or open directory with default layer + public func createOrOpen(path: [String]) async throws -> DirectorySubspace { + try await createOrOpen(path: path, type: nil) + } + + /// Create directory with default layer and prefix + public func create(path: [String]) async throws -> DirectorySubspace { + try await create(path: path, type: nil, prefix: nil) + } + + /// Create directory with default prefix + public func create(path: [String], type: DirectoryType?) async throws -> DirectorySubspace { + try await create(path: path, type: type, prefix: nil) + } + + /// List root directories + public func list() async throws -> [String] { + try await list(path: []) + } + + // MARK: - Node + + /// Internal representation of a directory node + private struct Node { + let subspace: Subspace + let path: [String] + let prefix: FDB.Bytes + let exists: Bool + let type: DirectoryType? + } +} + +// MARK: - Database Extension + +extension DatabaseProtocol { + /// Default Directory Layer instance + /// + /// **Note**: This creates a new instance on every access. + /// For better performance, create once and reuse: + /// + /// ```swift + /// let directoryLayer = DirectoryLayer(database: database) + /// ``` + public var directory: DirectoryLayer { + DirectoryLayer(database: self) + } +} diff --git a/Sources/FoundationDB/Directory/DirectorySubspace.swift b/Sources/FoundationDB/Directory/DirectorySubspace.swift new file mode 100644 index 0000000..a113203 --- /dev/null +++ b/Sources/FoundationDB/Directory/DirectorySubspace.swift @@ -0,0 +1,205 @@ +/* + * DirectorySubspace.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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 + +/// A subspace returned from Directory operations +/// +/// DirectorySubspace extends Subspace with directory metadata (path and layer). +/// It provides the same data operations as Subspace (pack/unpack), plus +/// reversible path information. +/// +/// **Design**: DirectorySubspace = Subspace + reversible path where path: [String] +/// +/// **Basic Usage**: +/// ```swift +/// let userDir = try await directoryLayer.createOrOpen(path: ["app", "users"]) +/// +/// // Use for data operations +/// let key = userDir.pack(Tuple("user123")) +/// try await transaction.set(key, value: userData) +/// +/// // Access path information +/// print(userDir.path) // ["app", "users"] +/// print(userDir.prefix) // HCA-allocated prefix +/// ``` +/// +/// **Note**: Directory operations (createOrOpen, list, remove, etc.) should be +/// performed through DirectoryLayer, not DirectorySubspace. +public struct DirectorySubspace: Sendable { + + // MARK: - Properties + + /// Short prefix assigned by Directory Layer (via HCA) + public let subspace: Subspace + + /// Full path of this directory + public let path: [String] + + /// Layer type of this directory + public let type: DirectoryType? + + /// Whether this directory is a partition + public var isPartition: Bool { + type == .partition + } + + /// Convenience: Access the prefix directly + public var prefix: FDB.Bytes { + subspace.prefix + } + + // MARK: - Initialization + + /// Initialize DirectorySubspace + /// + /// - Parameters: + /// - prefix: Prefix assigned by Directory Layer (via HCA) + /// - path: Full directory path + /// - layer: Layer type (optional) + public init( + prefix: FDB.Bytes, + path: [String], + type: DirectoryType? + ) { + self.subspace = Subspace(prefix: prefix) + self.path = path + self.type = type + } + + /// Initialize DirectorySubspace from Subspace (for backward compatibility) + /// + /// - Parameters: + /// - subspace: Subspace with the prefix assigned by Directory Layer + /// - path: Full directory path + /// - layer: Layer type (optional) + public init( + subspace: Subspace, + path: [String], + type: DirectoryType? + ) { + self.subspace = subspace + self.path = path + self.type = type + } + + // MARK: - Subspace Operations + + /// Pack tuple into key + /// + /// - Parameter tuple: Tuple to pack + /// - Returns: Key with prefix + public func pack(_ tuple: Tuple) -> Data { + Data(self.subspace.pack(tuple)) + } + + /// Unpack key into tuple + /// + /// - Parameter key: Key to unpack + /// - Returns: Unpacked tuple + /// - Throws: If key doesn't belong to this subspace + public func unpack(_ key: Data) throws -> Tuple { + try self.subspace.unpack(Array(key)) + } + + /// Create a subspace + /// + /// - Parameter tuple: Subspace tuple + /// - Returns: New subspace + public func subspace(_ tuple: Tuple) -> Subspace { + self.subspace.subspace(tuple) + } + + /// Get key range for this subspace + /// + /// - Returns: Begin and end keys + public func range() -> (begin: FDB.Bytes, end: FDB.Bytes) { + self.subspace.range() + } + + /// Check if key belongs to this subspace + /// + /// - Parameter key: Key to check + /// - Returns: true if key belongs to this subspace + public func contains(_ key: Data) -> Bool { + self.subspace.contains(Array(key)) + } +} + +// MARK: - Equatable + +extension DirectorySubspace: Equatable { + public static func == (lhs: DirectorySubspace, rhs: DirectorySubspace) -> Bool { + lhs.prefix == rhs.prefix && + lhs.path == rhs.path && + lhs.type == rhs.type + } +} + +// MARK: - Hashable + +extension DirectorySubspace: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(`prefix`) + hasher.combine(path) + hasher.combine(type) + } +} + +// MARK: - CustomStringConvertible + +extension DirectorySubspace: CustomStringConvertible { + public var description: String { + let pathStr = path.joined(separator: "/") + if let type = type { + let layerStr: String + switch type { + case .partition: + layerStr = "partition" + case .custom(let name): + layerStr = name + } + return "DirectorySubspace(path: \"\(pathStr)\", layer: \"\(layerStr)\")" + } else { + return "DirectorySubspace(path: \"\(pathStr)\")" + } + } +} + +// MARK: - CustomDebugStringConvertible + +extension DirectorySubspace: CustomDebugStringConvertible { + public var debugDescription: String { + let pathStr = path.joined(separator: "/") + let prefixHex = subspace.prefix.map { String(format: "%02x", $0) }.joined() + if let type = type { + let layerStr: String + switch type { + case .partition: + layerStr = "partition" + case .custom(let name): + layerStr = name + } + return "DirectorySubspace(path: \"\(pathStr)\", prefix: 0x\(prefixHex), layer: \"\(layerStr)\")" + } else { + return "DirectorySubspace(path: \"\(pathStr)\", prefix: 0x\(prefixHex))" + } + } +} diff --git a/Sources/FoundationDB/Directory/DirectoryVersion.swift b/Sources/FoundationDB/Directory/DirectoryVersion.swift new file mode 100644 index 0000000..3e15775 --- /dev/null +++ b/Sources/FoundationDB/Directory/DirectoryVersion.swift @@ -0,0 +1,56 @@ +/* + * DirectoryVersion.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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 + +/// Directory Layer version +public struct DirectoryVersion: Sendable, Equatable, Hashable { + public let major: Int + public let minor: Int + public let patch: Int + + /// Current Directory Layer version + public static let current = DirectoryVersion(major: 1, minor: 0, patch: 0) + + public init(major: Int, minor: Int, patch: Int) { + self.major = major + self.minor = minor + self.patch = patch + } + + /// Check if this version is compatible with another version + /// Compatible if major versions match + public func isCompatible(with other: DirectoryVersion) -> Bool { + self.major == other.major + } + + /// Convert to array representation for storage + var asArray: [Int] { + [major, minor, patch] + } + + /// Create from array representation + init?(from array: [Int]) { + guard array.count == 3 else { return nil } + self.major = array[0] + self.minor = array[1] + self.patch = array[2] + } +} diff --git a/Sources/FoundationDB/Directory/HighContentionAllocator.swift b/Sources/FoundationDB/Directory/HighContentionAllocator.swift new file mode 100644 index 0000000..d93389e --- /dev/null +++ b/Sources/FoundationDB/Directory/HighContentionAllocator.swift @@ -0,0 +1,218 @@ +/* + * HighContentionAllocator.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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 + +/// High Contention Allocator for efficiently allocating unique directory prefixes +/// +/// The HCA uses a window-based allocation strategy with dynamic window sizing +/// to balance key shortness with contention handling. This is the official +/// algorithm used across all FoundationDB language bindings. +/// +/// **Algorithm**: +/// 1. Find appropriate allocation window with dynamic sizing +/// 2. Randomly select candidate prefix within window +/// 3. Check if candidate was recently allocated +/// 4. Use write conflict keys to prevent concurrent allocation +/// +/// **Window Sizes**: +/// - start < 255: window = 64 +/// - start < 65535: window = 1024 +/// - start >= 65535: window = 8192 +public final class HighContentionAllocator: Sendable { + + // MARK: - Properties + + /// Database instance + nonisolated(unsafe) private let database: any DatabaseProtocol + + /// Counters subspace for tracking allocation counts + private let counters: Subspace + + /// Recent subspace for tracking recently allocated prefixes + private let recent: Subspace + + // MARK: - Initialization + + /// Initialize High Contention Allocator + /// + /// - Parameters: + /// - database: Database instance + /// - subspace: Root subspace for HCA (typically nodeSubspace["hca"]) + public init(database: any DatabaseProtocol, subspace: Subspace) { + self.database = database + self.counters = subspace.subspace(0) + self.recent = subspace.subspace(1) + } + + // MARK: - Allocation + + /// Allocate a unique prefix + /// + /// - Parameter transaction: Transaction to use for allocation + /// - Returns: Allocated prefix as FDB.Bytes + /// - Throws: DirectoryError if allocation fails + public func allocate(transaction: any TransactionProtocol) async throws -> FDB.Bytes { + var start: Int64 = 0 + var windowAdvanced = false + + while true { + // 1. Get latest counter start value + let latestCounter = try await getLatestCounter(transaction: transaction) + if let counter = latestCounter { + start = counter + } + + // 2. Find appropriate allocation window + let window = windowSize(start: start) + + while true { + // Increment counter for current window + let counterKey = counters.pack(Tuple(start)) + transaction.atomicOp( + key: Array(counterKey), + param: littleEndianInt64(1), + mutationType: .add + ) + + // Read current count + let countData = try await transaction.getValue(for: Array(counterKey), snapshot: false) + let count = countData.map { decodeInt64($0) } ?? 1 + + // Check if window is less than half full + if count * 2 < window { + break // Found suitable window + } + + // Window is too full - advance to next window + if windowAdvanced { + // Clear old window data to optimize space + // Use setNextWriteNoWriteConflictRange() to avoid conflicts on old data + let oldCountersBegin = counters.pack(Tuple(start)) + let oldCountersEnd = counters.pack(Tuple(start + 1)) + try transaction.setNextWriteNoWriteConflictRange() + transaction.clearRange(beginKey: Array(oldCountersBegin), endKey: Array(oldCountersEnd)) + + let oldRecentBegin = recent.pack(Tuple(start)) + let oldRecentEnd = recent.pack(Tuple(start + window)) + try transaction.setNextWriteNoWriteConflictRange() + transaction.clearRange(beginKey: Array(oldRecentBegin), endKey: Array(oldRecentEnd)) + } + + start += window + windowAdvanced = true + } + + // 3. Randomly select candidate within window + let candidate = start + Int64.random(in: 0.. start { + // Window advanced - restart from beginning + continue + } + + // Try another random candidate in same window + } + } + + // MARK: - Helper Methods + + /// Calculate window size based on start value + /// + /// Dynamic window sizing balances key shortness vs. contention handling: + /// - Small values: smaller window (64) for shorter keys + /// - Medium values: medium window (1024) + /// - Large values: large window (8192) to handle high contention + /// + /// - Parameter start: Current window start value + /// - Returns: Window size + private func windowSize(start: Int64) -> Int64 { + if start < 255 { + return 64 + } else if start < 65535 { + return 1024 + } else { + return 8192 + } + } + + /// Get the latest counter start value + /// + /// Uses snapshot read to avoid unnecessary read conflicts on counter metadata. + /// + /// - Parameter transaction: Transaction to use + /// - Returns: Latest counter start value, or nil if no counters exist + private func getLatestCounter(transaction: any TransactionProtocol) async throws -> Int64? { + let (_, end) = counters.range() + + // Get last key in counters subspace using lastLessThan selector + // Use snapshot read to avoid read conflicts + let sequence = transaction.getRange( + beginSelector: .lastLessThan(end), + endSelector: .firstGreaterOrEqual(end), + snapshot: true + ) + + for try await (key, _) in sequence { + // Check if key belongs to counters subspace before unpacking + guard key.starts(with: counters.prefix) else { + // Key is outside counters subspace, skip it + continue + } + + let tuple = try counters.unpack(Array(key)) + if let start = tuple[0] as? Int64 { + return start + } + } + + return nil + } + + /// Encode Int64 as little-endian bytes + private func littleEndianInt64(_ value: Int64) -> FDB.Bytes { + var val = value.littleEndian + return withUnsafeBytes(of: &val) { Array($0) } + } + + /// Decode Int64 from little-endian bytes + private func decodeInt64(_ bytes: FDB.Bytes) -> Int64 { + guard bytes.count == 8 else { return 0 } + return bytes.withUnsafeBytes { $0.load(as: Int64.self).littleEndian } + } +} diff --git a/Tests/FoundationDBTests/DirectoryLayerTests.swift b/Tests/FoundationDBTests/DirectoryLayerTests.swift new file mode 100644 index 0000000..01eb665 --- /dev/null +++ b/Tests/FoundationDBTests/DirectoryLayerTests.swift @@ -0,0 +1,660 @@ +/* + * DirectoryLayerTests.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 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 Testing +@testable import FoundationDB + +@Suite("Directory Layer Tests") +struct DirectoryLayerTests { + let database: any DatabaseProtocol + + init() async throws { + // Initialize FDB network if needed + try await FDBClient.maybeInitialize() + + self.database = try FDBClient.openDatabase() + + // Clean up ALL test data ONLY in the test subspace + // IMPORTANT: Never clear the entire keyspace [0x00, 0xFF] + // as it would destroy all data in the cluster + let testSubspace = Subspace(rootPrefix: "directory-layer-tests") + + // Clear the entire test subspace before running any tests + let (begin, end) = testSubspace.range() + try await database.withTransaction { transaction in + transaction.clearRange( + beginKey: begin, + endKey: end + ) + } + } + + // Helper to create a unique DirectoryLayer for each test + private func makeDirectoryLayer(name: String) -> DirectoryLayer { + let testSubspace = Subspace(rootPrefix: "directory-layer-tests").subspace(name) + return DirectoryLayer( + database: database, + nodeSubspace: testSubspace.subspace(0xFE), // Test metadata + contentSubspace: testSubspace // Test data + ) + } + + // MARK: - Basic Operations + + @Test("Create and open directory") + func createAndOpen() async throws { + let directoryLayer = makeDirectoryLayer(name: "createAndOpen") + + // Create directory + let dir = try await directoryLayer.createOrOpen(path: ["test"]) + #expect(dir.path == ["test"]) + #expect(!dir.prefix.isEmpty) + + // Reopen should return same prefix + let reopened = try await directoryLayer.open(path: ["test"]) + #expect(reopened.prefix == dir.prefix) + #expect(reopened.path == ["test"]) + } + + @Test("Create already existing directory throws error") + func createAlreadyExists() async throws { + let directoryLayer = makeDirectoryLayer(name: "createAlreadyExists") + + _ = try await directoryLayer.create(path: ["test"]) + + // Second create should fail + await #expect(throws: DirectoryError.self) { + _ = try await directoryLayer.create(path: ["test"]) + } + } + + @Test("Open non-existent directory throws error") + func openNonExistent() async throws { + let directoryLayer = makeDirectoryLayer(name: "openNonExistent") + + await #expect(throws: DirectoryError.self) { + _ = try await directoryLayer.open(path: ["nonexistent"]) + } + } + + @Test("Nested directories") + func nestedDirectories() async throws { + let directoryLayer = makeDirectoryLayer(name: "nestedDirectories") + + let parent = try await directoryLayer.createOrOpen(path: ["parent"]) + let child = try await directoryLayer.createOrOpen(path: ["parent", "child"]) + + #expect(child.path == ["parent", "child"]) + #expect(parent.prefix != child.prefix) + } + + // MARK: - Critical Bug Fix: Version Persistence + + @Test("Version persistence across multiple operations") + func versionPersistence() async throws { + let directoryLayer = makeDirectoryLayer(name: "versionPersistence") + + // First directory creation writes version + _ = try await directoryLayer.createOrOpen(path: ["first"]) + + // Second operation should not fail (tests version reading bug fix) + _ = try await directoryLayer.createOrOpen(path: ["second"]) + + // Third operation to confirm stability + let third = try await directoryLayer.open(path: ["second"]) + #expect(third.path == ["second"]) + } + + // MARK: - Partition Operations + + @Test("Partition creation") + func partitionCreation() async throws { + let directoryLayer = makeDirectoryLayer(name: "partitionCreation") + + let partition = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1"], + type: .partition + ) + + #expect(partition.path == ["tenants", "tenant-1"]) + #expect(partition.isPartition) + } + + @Test("Partition traversal with full path") + func partitionTraversal() async throws { + let directoryLayer = makeDirectoryLayer(name: "partitionTraversal") + + // Create partition + let partition = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1"], + type: .partition + ) + + // Create subdirectory inside partition using full path + let orders = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1", "orders"] + ) + + // Verify the full path is preserved + #expect(orders.path == ["tenants", "tenant-1", "orders"]) + + // Verify the prefix is under the partition + #expect(orders.prefix.starts(with: partition.prefix)) + + // Create another subdirectory + let products = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1", "products"] + ) + + #expect(products.path == ["tenants", "tenant-1", "products"]) + #expect(products.prefix.starts(with: partition.prefix)) + + // Verify prefixes are different + #expect(orders.prefix != products.prefix) + } + + @Test("Cannot create partition inside partition") + func cannotCreatePartitionInPartition() async throws { + let directoryLayer = makeDirectoryLayer(name: "cannotCreatePartitionInPartition") + + // Create first partition + _ = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1"], + type: .partition + ) + + // Try to create nested partition + await #expect(throws: DirectoryError.self) { + _ = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1", "nested"], + type: .partition + ) + } + } + + // MARK: - Manual Prefix Operations + + @Test("Manual prefix collision detection") + func manualPrefixCollisionDetection() async throws { + let directoryLayer = makeDirectoryLayer(name: "manualPrefixCollisionDetection") + + let relativePrefix: FDB.Bytes = [0x01, 0x02, 0x03] + + // First directory with custom prefix (treated as relative) + let first = try await directoryLayer.create( + path: ["first"], + prefix: relativePrefix + ) + + // dir.prefix should be ABSOLUTE (contentSubspace.prefix + relativePrefix) + let testSubspace = Subspace(rootPrefix: "directory-layer-tests").subspace("manualPrefixCollisionDetection") + let expectedAbsolutePrefix = testSubspace.prefix + relativePrefix + #expect(first.prefix == expectedAbsolutePrefix) + + // Try to create second directory with same prefix - should detect collision + await #expect(throws: DirectoryError.self) { + _ = try await directoryLayer.create( + path: ["second"], + prefix: relativePrefix + ) + } + } + + @Test("Manual prefix overlap detection") + func manualPrefixOverlap() async throws { + let directoryLayer = makeDirectoryLayer(name: "manualPrefixOverlap") + + let relativePrefix1: FDB.Bytes = [0x01, 0x02] + let relativePrefix2: FDB.Bytes = [0x01, 0x02, 0x03] // Overlaps with prefix1 + + // First directory + _ = try await directoryLayer.create(path: ["first"], prefix: relativePrefix1) + + // Try to create with overlapping prefix - should detect collision + await #expect(throws: DirectoryError.self) { + _ = try await directoryLayer.create(path: ["second"], prefix: relativePrefix2) + } + } + + @Test("Manual prefix in metadata space") + func manualPrefixInMetadataSpace() async throws { + let directoryLayer = makeDirectoryLayer(name: "manualPrefixInMetadataSpace") + + // The nodeSubspace for this test uses subspace(0xFE), which is Tuple-encoded + // subspace(0xFE) adds Tuple(0xFE).encode(), not just [0xFE] + // We need to find what Tuple(0xFE) actually encodes to + + let testSubspace = Subspace(rootPrefix: "directory-layer-tests").subspace("manualPrefixInMetadataSpace") + let nodeSubspace = testSubspace.subspace(0xFE) + + // Extract the Tuple-encoded suffix + let nodeEncoding = Array(nodeSubspace.prefix.dropFirst(testSubspace.prefix.count)) + + // Use this as the conflicting relative prefix + let conflictingRelativePrefix = nodeEncoding + + await #expect(throws: DirectoryError.self) { + _ = try await directoryLayer.create(path: ["invalid"], prefix: conflictingRelativePrefix) + } + } + + // MARK: - Directory Operations + + @Test("List directories") + func listDirectories() async throws { + let directoryLayer = makeDirectoryLayer(name: "listDirectories") + + // Create multiple directories + _ = try await directoryLayer.createOrOpen(path: ["a"]) + _ = try await directoryLayer.createOrOpen(path: ["b"]) + _ = try await directoryLayer.createOrOpen(path: ["c"]) + + // List root + let dirs = try await directoryLayer.list(path: []) + #expect(Set(dirs) == Set(["a", "b", "c"])) + } + + @Test("List nested directories") + func listNestedDirectories() async throws { + let directoryLayer = makeDirectoryLayer(name: "listNestedDirectories") + + // Create nested structure + _ = try await directoryLayer.createOrOpen(path: ["parent", "child1"]) + _ = try await directoryLayer.createOrOpen(path: ["parent", "child2"]) + + // List parent's children + let children = try await directoryLayer.list(path: ["parent"]) + #expect(Set(children) == Set(["child1", "child2"])) + } + + @Test("Remove directory") + func removeDirectory() async throws { + let directoryLayer = makeDirectoryLayer(name: "removeDirectory") + + _ = try await directoryLayer.createOrOpen(path: ["test"]) + + // Verify exists + let exists = try await directoryLayer.exists(path: ["test"]) + #expect(exists) + + // Remove + try await directoryLayer.remove(path: ["test"]) + + // Verify removed + let existsAfter = try await directoryLayer.exists(path: ["test"]) + #expect(!existsAfter) + } + + @Test("Move directory") + func moveDirectory() async throws { + let directoryLayer = makeDirectoryLayer(name: "moveDirectory") + + // Create directory + let original = try await directoryLayer.createOrOpen(path: ["old"]) + + // Move + let moved = try await directoryLayer.move( + oldPath: ["old"], + newPath: ["new"] + ) + + #expect(moved.path == ["new"]) + #expect(moved.prefix == original.prefix) // Prefix stays the same + + // Verify old path doesn't exist + let oldExists = try await directoryLayer.exists(path: ["old"]) + #expect(!oldExists) + + // Verify new path exists + let newExists = try await directoryLayer.exists(path: ["new"]) + #expect(newExists) + } + + // MARK: - Data Isolation + + @Test("Data isolation between directories") + func dataIsolation() async throws { + let directoryLayer = makeDirectoryLayer(name: "dataIsolation") + + let dir1 = try await directoryLayer.createOrOpen(path: ["app1"]) + let dir2 = try await directoryLayer.createOrOpen(path: ["app2"]) + + // Write data to each directory + try await database.withTransaction { transaction in + let key1 = dir1.pack(Tuple("user:123")) + let key2 = dir2.pack(Tuple("user:123")) + + transaction.setValue([0x01], for: Array(key1)) + transaction.setValue([0x02], for: Array(key2)) + } + + // Read back and verify isolation + try await database.withTransaction { transaction in + let key1 = dir1.pack(Tuple("user:123")) + let key2 = dir2.pack(Tuple("user:123")) + + let value1 = try await transaction.getValue(for: Array(key1), snapshot: false) + let value2 = try await transaction.getValue(for: Array(key2), snapshot: false) + + #expect(value1 == [0x01] as FDB.Bytes) + #expect(value2 == [0x02] as FDB.Bytes) + } + } + + // MARK: - Layer Metadata + + @Test("Custom layer") + func customLayer() async throws { + let directoryLayer = makeDirectoryLayer(name: "customLayer") + + let custom = try await directoryLayer.createOrOpen( + path: ["custom"], + type: .custom("my-layer") + ) + + #expect(custom.type == .custom("my-layer")) + + // Reopen and verify layer + let reopened = try await directoryLayer.open(path: ["custom"]) + #expect(reopened.type == .custom("my-layer")) + } + + @Test("Layer mismatch") + func layerMismatch() async throws { + let directoryLayer = makeDirectoryLayer(name: "layerMismatch") + + _ = try await directoryLayer.createOrOpen( + path: ["test"], + type: .custom("layer1") + ) + + // Try to open with different layer + await #expect(throws: DirectoryError.self) { + _ = try await directoryLayer.createOrOpen( + path: ["test"], + type: .custom("layer2") + ) + } + } + + // MARK: - Cross-Language Compatibility + + @Test("Cross-language metadata structure") + func crossLanguageMetadataStructure() async throws { + let directoryLayer = makeDirectoryLayer(name: "crossLanguageMetadataStructure") + + // This test verifies that our metadata structure matches + // the official Python/Java/Go implementations + + let dir = try await directoryLayer.createOrOpen(path: ["test"]) + + // Verify the metadata keys exist with correct structure + try await database.withTransaction { transaction in + // nodeSubspace[parentPrefix][subdirs=0][childName] → childPrefix (RELATIVE) + // Use the actual nodeSubspace for this test + let testSubspace = Subspace(rootPrefix: "directory-layer-tests").subspace("crossLanguageMetadataStructure") + let nodeSubspace = testSubspace.subspace(0xFE) + + let subdirKey = nodeSubspace + .subspace(0) // subdirs = 0 (root directory's children) + .pack(Tuple("test")) + + let relativePrefixData = try await transaction.getValue( + for: Array(subdirKey), + snapshot: false + ) + #expect(relativePrefixData != nil) + + // Metadata stores RELATIVE prefix + // dir.prefix is ABSOLUTE prefix (contentSubspace.prefix + relativePrefix) + let expectedAbsolutePrefix = testSubspace.prefix + relativePrefixData! + #expect(Array(dir.prefix) == expectedAbsolutePrefix) + } + } + + // MARK: - Bug Detection Tests + + @Test("Deep nested directories stay within partition boundaries") + func deepNestedDirectoriesStayInPartition() async throws { + let directoryLayer = makeDirectoryLayer(name: "bugDeepPartitionNesting") + + // Create partition at ["tenants", "tenant-1"] + let partition = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1"], + type: .partition + ) + + // Create deeply nested directories inside partition + _ = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1", "orders"] + ) + + let historyDirectory = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1", "orders", "history"] + ) + + // BUG CHECK 1: All subdirectories should have prefixes under the partition + #expect( + historyDirectory.prefix.starts(with: partition.prefix), + "History directory should be inside partition prefix space" + ) + + // BUG CHECK 2: Verify we can list children correctly (functional test) + let ordersChildren = try await directoryLayer.list( + path: ["tenants", "tenant-1", "orders"] + ) + #expect( + ordersChildren.contains("history"), + "Should be able to list 'history' as child of 'orders'" + ) + + // BUG CHECK 3: Verify we can resolve the deep nested directory + let resolved = try await directoryLayer.open( + path: ["tenants", "tenant-1", "orders", "history"] + ) + #expect(resolved.path == ["tenants", "tenant-1", "orders", "history"]) + } + + @Test("Partition subdirectory operations maintain metadata integrity") + func partitionSubdirectoryOperationsMaintainMetadata() async throws { + let directoryLayer = makeDirectoryLayer(name: "bugPartitionSubdirectoryOperations") + + // Create partition + _ = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1"], + type: .partition + ) + + // Create subdirectory inside partition + _ = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1", "orders"] + ) + + // BUG CHECK 1: List partition's children should work correctly + let childrenBeforeNested = try await directoryLayer.list( + path: ["tenants", "tenant-1"] + ) + #expect(childrenBeforeNested.contains("orders"), "Should list 'orders' as child") + + // Create nested subdirectory + _ = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1", "orders", "items"] + ) + + // BUG CHECK 2: List orders' children should work + let ordersChildren = try await directoryLayer.list( + path: ["tenants", "tenant-1", "orders"] + ) + #expect(ordersChildren.contains("items"), "Should list 'items' as child of orders") + + // BUG CHECK 3: Move operation inside partition should work + let movedDirectory = try await directoryLayer.move( + oldPath: ["tenants", "tenant-1", "orders", "items"], + newPath: ["tenants", "tenant-1", "orders", "products"] + ) + #expect(movedDirectory.path == ["tenants", "tenant-1", "orders", "products"]) + + // Verify moved directory is listed correctly + let ordersChildrenAfterMove = try await directoryLayer.list( + path: ["tenants", "tenant-1", "orders"] + ) + #expect(ordersChildrenAfterMove.contains("products"), "Should list 'products' after move") + #expect(!ordersChildrenAfterMove.contains("items"), "Should not list old name 'items'") + + // BUG CHECK 4: Remove operation inside partition should work + try await directoryLayer.remove(path: ["tenants", "tenant-1", "orders", "products"]) + + let ordersChildrenAfterRemove = try await directoryLayer.list( + path: ["tenants", "tenant-1", "orders"] + ) + #expect(ordersChildrenAfterRemove.isEmpty, "Should have no children after removal") + } + + @Test("Root directory uses content prefix for data writes") + func rootDirectoryUsesContentPrefix() async throws { + let directoryLayer = makeDirectoryLayer(name: "bugRootDirectoryPrefix") + + // Get root directory by resolving empty path + let rootDirectory = try await database.withTransaction { transaction in + return try await directoryLayer.resolve(transaction: transaction, path: []) + } + + guard let rootDirectory = rootDirectory else { + throw DirectoryError.directoryNotFound(path: []) + } + + // BUG CHECK 1: Root directory prefix should NOT be metadata space (0xFE) + #expect( + rootDirectory.prefix != [0xFE], + "Root directory should not return metadata prefix 0xFE" + ) + + // BUG CHECK 2: Writing data to root directory should not corrupt metadata + try await database.withTransaction { transaction in + // Write some data using root directory + let dataKey = rootDirectory.pack(Tuple("test-data")) + transaction.setValue([0x01, 0x02, 0x03], for: Array(dataKey)) + } + + // Create a subdirectory after writing to root + let subDirectory = try await directoryLayer.createOrOpen(path: ["subdir"]) + + // BUG CHECK 3: Subdirectory creation should still work (metadata not corrupted) + #expect(subDirectory.path == ["subdir"]) + + // BUG CHECK 4: Verify we can still list root's subdirectories + // Note: list() with empty path is equivalent to listing root's children + let children = try await directoryLayer.list() + #expect(children.contains("subdir"), "Should list subdirectory after root data write") + + // BUG CHECK 5: Read back the data we wrote to root + let readData = try await database.withTransaction { transaction in + let dataKey = rootDirectory.pack(Tuple("test-data")) + return try await transaction.getValue(for: Array(dataKey), snapshot: false) + } + + #expect( + readData == [0x01, 0x02, 0x03], + "Should be able to read data written to root directory" + ) + + // BUG CHECK 6: Verify data didn't leak into metadata space + try await database.withTransaction { transaction in + let testSubspace = Subspace(rootPrefix: "directory-layer-tests").subspace("bugRootDirectoryPrefix") + let nodeSubspace = testSubspace.subspace(0xFE) + + // Check if data corrupted the metadata subspace + let metadataKey = nodeSubspace.pack(Tuple("test-data")) + let corruptedMetadata = try await transaction.getValue( + for: Array(metadataKey), + snapshot: false + ) + + #expect( + corruptedMetadata == nil, + "Data write should NOT corrupt metadata space" + ) + } + } + + @Test("Multi-level partition directories support all operations") + func multiLevelPartitionDirectoryOperations() async throws { + let directoryLayer = makeDirectoryLayer(name: "bugMultiLevelPartitionTraversal") + + // Create nested structure: app/tenants/tenant-1 (partition)/services/orders/items + _ = try await directoryLayer.createOrOpen(path: ["app"]) + _ = try await directoryLayer.createOrOpen(path: ["app", "tenants"]) + + let partition = try await directoryLayer.createOrOpen( + path: ["app", "tenants", "tenant-1"], + type: .partition + ) + + let servicesDirectory = try await directoryLayer.createOrOpen( + path: ["app", "tenants", "tenant-1", "services"] + ) + + let ordersDirectory = try await directoryLayer.createOrOpen( + path: ["app", "tenants", "tenant-1", "services", "orders"] + ) + + let itemsDirectory = try await directoryLayer.createOrOpen( + path: ["app", "tenants", "tenant-1", "services", "orders", "items"] + ) + + // BUG CHECK 1: All directories should be under partition prefix + #expect(servicesDirectory.prefix.starts(with: partition.prefix)) + #expect(ordersDirectory.prefix.starts(with: partition.prefix)) + #expect(itemsDirectory.prefix.starts(with: partition.prefix)) + + // BUG CHECK 2: List at various levels should work + let tenantChildren = try await directoryLayer.list( + path: ["app", "tenants", "tenant-1"] + ) + #expect(tenantChildren.contains("services")) + + let servicesChildren = try await directoryLayer.list( + path: ["app", "tenants", "tenant-1", "services"] + ) + #expect(servicesChildren.contains("orders")) + + let ordersChildren = try await directoryLayer.list( + path: ["app", "tenants", "tenant-1", "services", "orders"] + ) + #expect(ordersChildren.contains("items")) + + // BUG CHECK 3: Move deep nested directory + let movedDirectory = try await directoryLayer.move( + oldPath: ["app", "tenants", "tenant-1", "services", "orders", "items"], + newPath: ["app", "tenants", "tenant-1", "services", "orders", "products"] + ) + #expect(movedDirectory.path == ["app", "tenants", "tenant-1", "services", "orders", "products"]) + + // BUG CHECK 4: Remove and verify + try await directoryLayer.remove( + path: ["app", "tenants", "tenant-1", "services", "orders", "products"] + ) + + let ordersChildrenAfterRemove = try await directoryLayer.list( + path: ["app", "tenants", "tenant-1", "services", "orders"] + ) + #expect(ordersChildrenAfterRemove.isEmpty) + } +} From e9b304593396720805d967b66a62602e234c4947 Mon Sep 17 00:00:00 2001 From: 1amageek Date: Fri, 7 Nov 2025 21:00:16 +0900 Subject: [PATCH 4/4] Fix move() absolute prefix bug and partition root removal bug Fixes two HIGH priority bugs identified in code review: 1. move() now stores relative prefix in metadata instead of absolute - Prevents double-prefix on reopen - Ensures data remains accessible after move 2. removeInternal() now cleans up parent entry when removing partition root - Prevents dangling references in parent's subdirs - Ensures removed partition cannot be reopened Added regression tests: - movePrefixCorrectness: Verifies move preserves prefix through reopen - removePartitionRootCleansUpParent: Verifies parent cleanup Added write conflict range optimization extensions: - setNextWriteNoWriteConflictRange(): Exclude next write from conflict checking - addWriteConflictKey(): Add single key to write conflict range - addWriteConflictRange(): Add key range to write conflict range All 25 tests passing. --- .../Directory/DirectoryLayer.swift | 32 +++++++- Sources/FoundationDB/FoundationdDB.swift | 70 ++++++++++++++++ .../DirectoryLayerTests.swift | 79 +++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) diff --git a/Sources/FoundationDB/Directory/DirectoryLayer.swift b/Sources/FoundationDB/Directory/DirectoryLayer.swift index 9af2cda..f47744a 100644 --- a/Sources/FoundationDB/Directory/DirectoryLayer.swift +++ b/Sources/FoundationDB/Directory/DirectoryLayer.swift @@ -342,8 +342,18 @@ public final class DirectoryLayer: Sendable { let newSubdirectoryKey = newParentMetadata .subspace(Self.subdirs) .pack(Tuple(newDirectoryName)) + + // Convert absolute prefix to relative before storing in metadata. + // resolve() returns absolute prefix, but metadata stores relative prefix. + let relativePrefix: FDB.Bytes + if !contentSubspace.prefix.isEmpty && oldNode.prefix.starts(with: contentSubspace.prefix) { + relativePrefix = Array(oldNode.prefix.dropFirst(contentSubspace.prefix.count)) + } else { + relativePrefix = oldNode.prefix + } + transaction.setValue( - Array(oldNode.prefix), + Array(relativePrefix), for: Array(newSubdirectoryKey) ) @@ -388,6 +398,26 @@ public final class DirectoryLayer: Sendable { transaction: transaction, path: pathInPartition ) + + // When removing the partition root itself (pathInPartition is empty), + // clean up the parent's reference to this partition. + // The partition layer cleaned up its own metadata, but cannot access + // the parent layer's metadata. + if pathInPartition.isEmpty && !path.isEmpty { + let parentPath = Array(path.dropLast()) + let directoryName = path.last! + + guard let parentDirectory = try await self.resolve(transaction: transaction, path: parentPath) else { + return + } + + let parentMetadata = try await getMetadata(transaction: transaction, for: parentDirectory) + let subdirectoryKey = parentMetadata + .subspace(Self.subdirs) + .pack(Tuple(directoryName)) + transaction.clear(key: Array(subdirectoryKey)) + } + return } diff --git a/Sources/FoundationDB/FoundationdDB.swift b/Sources/FoundationDB/FoundationdDB.swift index 18d7e13..9590cd4 100644 --- a/Sources/FoundationDB/FoundationdDB.swift +++ b/Sources/FoundationDB/FoundationdDB.swift @@ -388,3 +388,73 @@ extension TransactionProtocol { try setOption(to: valueBytes, forOption: option) } } + +// MARK: - Write Conflict Range Optimization + +extension TransactionProtocol { + /// Exclude the next write operation from write conflict checking. + /// + /// This is useful when clearing old data that should not cause conflicts with other transactions. + /// The option applies only to the next write operation. + /// + /// **Python equivalent:** + /// ```python + /// tr.options.set_next_write_no_write_conflict_range() + /// ``` + /// + /// **Java equivalent:** + /// ```java + /// tr.options().setNextWriteNoWriteConflictRange(); + /// ``` + /// + /// - Throws: `FDBError` if the option cannot be set. + public func setNextWriteNoWriteConflictRange() throws { + try setOption(forOption: .nextWriteNoWriteConflictRange) + } + + /// Add a single key to the transaction's write conflict range. + /// + /// This is used to explicitly mark a key that should be checked for conflicts, + /// typically after using `setNextWriteNoWriteConflictRange()`. + /// + /// **Python equivalent:** + /// ```python + /// tr.add_write_conflict_key(key) + /// ``` + /// + /// **Java equivalent:** + /// ```java + /// tr.addWriteConflictKey(key); + /// ``` + /// + /// - Parameter key: The key to add to the write conflict range. + /// - Throws: `FDBError` if the operation fails. + public func addWriteConflictKey(_ key: FDB.Bytes) throws { + // Add a conflict range with begin=key, end=key+\x00 + var endKey = key + endKey.append(0x00) + try addConflictRange(beginKey: key, endKey: endKey, type: .write) + } + + /// Add a range to the transaction's write conflict range. + /// + /// This is used to explicitly mark a range that should be checked for conflicts. + /// + /// **Python equivalent:** + /// ```python + /// tr.add_write_conflict_range(begin_key, end_key) + /// ``` + /// + /// **Java equivalent:** + /// ```java + /// tr.addWriteConflictRange(beginKey, endKey); + /// ``` + /// + /// - Parameters: + /// - beginKey: The start of the range (inclusive). + /// - endKey: The end of the range (exclusive). + /// - Throws: `FDBError` if the operation fails. + public func addWriteConflictRange(beginKey: FDB.Bytes, endKey: FDB.Bytes) throws { + try addConflictRange(beginKey: beginKey, endKey: endKey, type: .write) + } +} diff --git a/Tests/FoundationDBTests/DirectoryLayerTests.swift b/Tests/FoundationDBTests/DirectoryLayerTests.swift index 01eb665..f075e84 100644 --- a/Tests/FoundationDBTests/DirectoryLayerTests.swift +++ b/Tests/FoundationDBTests/DirectoryLayerTests.swift @@ -657,4 +657,83 @@ struct DirectoryLayerTests { ) #expect(ordersChildrenAfterRemove.isEmpty) } + + // MARK: - Bug Fix Regression Tests + + @Test("Move preserves prefix correctness on reopen") + func movePrefixCorrectness() async throws { + let directoryLayer = makeDirectoryLayer(name: "movePrefixCorrectness") + + // Create a directory + let original = try await directoryLayer.createOrOpen(path: ["old", "dir"]) + let originalPrefix = original.prefix + + // Write some data + try await database.withTransaction { transaction in + let key = original.subspace.pack(Tuple("test")) + transaction.setValue([0x42], for: Array(key)) + } + + // Move the directory + let moved = try await directoryLayer.move( + oldPath: ["old", "dir"], + newPath: ["new", "dir"] + ) + + // Verify prefix is preserved (move doesn't change prefix) + #expect(moved.prefix == originalPrefix) + + // Verify reopening returns same prefix + let reopened = try await directoryLayer.open(path: ["new", "dir"]) + #expect( + reopened.prefix == originalPrefix, + "Reopened directory should have same prefix, not doubled" + ) + + // Verify data is still accessible + let dataExists = try await database.withTransaction { transaction in + let key = reopened.subspace.pack(Tuple("test")) + let value = try await transaction.getValue(for: Array(key), snapshot: false) + return value != nil + } + #expect(dataExists, "Data should be accessible after move and reopen") + } + + @Test("Remove partition root cleans up parent entry") + func removePartitionRootCleansUpParent() async throws { + let directoryLayer = makeDirectoryLayer(name: "removePartitionRoot") + + // Create a partition + _ = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1"], + type: .partition + ) + + // Create a subdirectory inside the partition + _ = try await directoryLayer.createOrOpen( + path: ["tenants", "tenant-1", "data"] + ) + + // List parent before removal + let tenantsBeforeRemove = try await directoryLayer.list(path: ["tenants"]) + #expect( + tenantsBeforeRemove.contains("tenant-1"), + "tenant-1 should exist before removal" + ) + + // Remove the partition root + try await directoryLayer.remove(path: ["tenants", "tenant-1"]) + + // Verify parent no longer lists the removed partition + let tenantsAfterRemove = try await directoryLayer.list(path: ["tenants"]) + #expect( + !tenantsAfterRemove.contains("tenant-1"), + "tenant-1 should be removed from parent's list" + ) + + // Verify opening the removed partition fails + await #expect(throws: DirectoryError.self) { + _ = try await directoryLayer.open(path: ["tenants", "tenant-1"]) + } + } }