diff --git a/.github/actions/consul-start/action.yml b/.github/actions/consul-start/action.yml index f627212..0aba542 100644 --- a/.github/actions/consul-start/action.yml +++ b/.github/actions/consul-start/action.yml @@ -11,3 +11,4 @@ runs: shell: bash run: | consul agent -dev -log-level=warn & + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..db31fb6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/workflows/swift" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml index c9f58cb..af8c2ca 100644 --- a/.github/workflows/lint-pr.yaml +++ b/.github/workflows/lint-pr.yaml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -27,11 +27,11 @@ jobs: style refactor perf - test + test build - ci - chore - revert + ci + chore + revert # Configure which scopes are allowed. scopes: | patch @@ -39,7 +39,7 @@ jobs: minor major # Configure that a scope must always be provided. - requireScope: false + requireScope: false # Configure which scopes are disallowed in PR titles. For instance by setting # the value below, `chore(release): ...` and `ci(e2e,release): ...` will be rejected. disallowScopes: | @@ -56,10 +56,10 @@ jobs: ignoreLabels: | bot ignore-semantic-pull-request - # For work-in-progress PRs you can typically use draft pull requests - # from GitHub. However, private repositories on the free plan don't have - # this option and therefore this action allows you to opt-in to using the - # special "[WIP]" prefix to indicate this state. This will avoid the + # For work-in-progress PRs you can typically use draft pull requests + # from GitHub. However, private repositories on the free plan don't have + # this option and therefore this action allows you to opt-in to using the + # special "[WIP]" prefix to indicate this state. This will avoid the # validation of the PR title and the pull request checks remain pending. # Note that a second check will be reported if this is enabled. - wip: true + wip: true \ No newline at end of file diff --git a/.github/workflows/semantic-release.yml b/.github/workflows/semantic-release.yml index 6050415..b4d0301 100644 --- a/.github/workflows/semantic-release.yml +++ b/.github/workflows/semantic-release.yml @@ -7,9 +7,9 @@ on: jobs: semantic-release: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -52,15 +52,15 @@ jobs: failTitle: false" > .releaserc.yml - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' - name: Install semantic-release run: | - npm install semantic-release@v24 conventional-changelog-conventionalcommits@v8 -D + npm install semantic-release@v25 conventional-changelog-conventionalcommits@v9 -D npm list - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx semantic-release + run: npx semantic-release \ No newline at end of file diff --git a/.github/workflows/swift-benchmark-delta.yml b/.github/workflows/swift-benchmark-delta.yml index 8797a1f..5a4f7fa 100644 --- a/.github/workflows/swift-benchmark-delta.yml +++ b/.github/workflows/swift-benchmark-delta.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: pull_request: branches: [ main ] - + jobs: benchmark-delta: timeout-minutes: 30 @@ -15,7 +15,7 @@ jobs: os: [ubuntu-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Homebrew Mac diff --git a/.github/workflows/swift-check-api-breaks.yml b/.github/workflows/swift-check-api-breaks.yml index ad23f94..d55bb61 100644 --- a/.github/workflows/swift-check-api-breaks.yml +++ b/.github/workflows/swift-check-api-breaks.yml @@ -10,9 +10,9 @@ jobs: runs-on: [ubuntu-latest] timeout-minutes: 30 - + steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Ubuntu deps @@ -33,4 +33,4 @@ jobs: run: swift package diagnose-api-breaking-changes origin/main --targets ${{ env.spmlibrarytarget }} - name: Analyze API breakage if: ${{ env.spmlibrarytarget }} - run: swift package diagnose-api-breaking-changes origin/main --targets ${{ env.spmlibrarytarget }} + run: swift package diagnose-api-breaking-changes origin/main --targets ${{ env.spmlibrarytarget }} \ No newline at end of file diff --git a/.github/workflows/swift-code-coverage.yml b/.github/workflows/swift-code-coverage.yml index f0fa0bf..576407a 100644 --- a/.github/workflows/swift-code-coverage.yml +++ b/.github/workflows/swift-code-coverage.yml @@ -11,14 +11,14 @@ jobs: runs-on: [ubuntu-24.04] timeout-minutes: 60 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Ubuntu deps if: ${{ runner.os == 'Linux' }} run: | sudo apt-get install -y libjemalloc-dev - - uses: swift-actions/setup-swift@next + - uses: swift-actions/setup-swift@v2 with: swift-version: "6" @@ -46,8 +46,8 @@ jobs: fi - name: Upload codecov - uses: codecov/codecov-action@v4 - with: + uses: codecov/codecov-action@v5 + with: token: ${{ secrets.CODECOV_REPO_TOKEN }} files: info.lcov - fail_ci_if_error: true + fail_ci_if_error: true \ No newline at end of file diff --git a/.github/workflows/swift-lint.yml b/.github/workflows/swift-lint.yml index 331f74c..22b1d1a 100644 --- a/.github/workflows/swift-lint.yml +++ b/.github/workflows/swift-lint.yml @@ -13,8 +13,8 @@ jobs: timeout-minutes: 60 runs-on: [ubuntu-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: GitHub Action for SwiftLint with --strict uses: norio-nomura/action-swiftlint@3.2.1 with: - args: --strict + args: --strict \ No newline at end of file diff --git a/.github/workflows/swift-linux-build.yml b/.github/workflows/swift-linux-build.yml index 96f2a65..f9ccdc0 100644 --- a/.github/workflows/swift-linux-build.yml +++ b/.github/workflows/swift-linux-build.yml @@ -14,7 +14,6 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] -# swift: [ "5.10", "6.0" ] runs-on: ${{ matrix.os }} @@ -24,7 +23,7 @@ jobs: with: swift-version: ${{ matrix.swift }} - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Start consul uses: ./.github/actions/consul-start @@ -43,5 +42,5 @@ jobs: - name: Run tests run: | if [ -d Tests ]; then - swift test --parallel - fi + swift test + fi \ No newline at end of file diff --git a/.github/workflows/swift-macos-build.yml b/.github/workflows/swift-macos-build.yml index eb4bb8c..2401747 100644 --- a/.github/workflows/swift-macos-build.yml +++ b/.github/workflows/swift-macos-build.yml @@ -6,19 +6,18 @@ on: branches: [ main ] pull_request: branches: [ main, next ] - + jobs: build-macos: timeout-minutes: 60 strategy: fail-fast: false matrix: - os: [macos-15] -# swift: [ "5.10", "6.0" ] + os: [macos-26] runs-on: ${{ matrix.os }} env: - DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer steps: - uses: swift-actions/setup-swift@v2 @@ -33,7 +32,7 @@ jobs: echo BENCHMARK_DISABLE_JEMALLOC=true >> $GITHUB_ENV brew install jemalloc - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Start consul uses: ./.github/actions/consul-start @@ -49,10 +48,12 @@ jobs: - name: Run tests run: | if [ -d Tests ]; then - swift test --parallel - fi - - name: Run tests (release) - run: | - if [ -d Tests ]; then - swift test -c release --parallel + swift test fi + # Disable release tests now as there's a bug in Swift Testing with release builds and argument parser + # and executable targets + # - name: Run tests (release) + # run: | + # if [ -d Tests ]; then + # swift test -c release + # fi \ No newline at end of file diff --git a/.github/workflows/swift-outdated-dependencies.yml b/.github/workflows/swift-outdated-dependencies.yml index 8ed989e..9fb723d 100644 --- a/.github/workflows/swift-outdated-dependencies.yml +++ b/.github/workflows/swift-outdated-dependencies.yml @@ -1,28 +1,28 @@ -name: Swift outdated dependencies +name: Swift outdated dependencies -on: +on: workflow_dispatch: schedule: - cron: '0 8 */100,1-7 * MON' # First Monday of the month - + jobs: spm-dep-check: runs-on: [ubuntu-latest] timeout-minutes: 60 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check Swift package dependencies id: spm-dep-check - uses: MarcoEidinger/swift-package-dependencies-check@2.5.0 + uses: MarcoEidinger/swift-package-dependencies-check@2.7.0 with: isMutating: true failWhenOutdated: false - name: Create Pull Request if: steps.spm-dep-check.outputs.outdatedDependencies == 'true' - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: commit-message: 'chore: update package dependencies' branch: updatePackageDepedencies delete-branch: true title: 'chore: update package dependencies' - body: ${{ steps.spm-dep-check.outputs.releaseNotes }} + body: ${{ steps.spm-dep-check.outputs.releaseNotes }} \ No newline at end of file diff --git a/.github/workflows/swift-sanitizer-address.yml b/.github/workflows/swift-sanitizer-address.yml index f811bce..923d5bb 100644 --- a/.github/workflows/swift-sanitizer-address.yml +++ b/.github/workflows/swift-sanitizer-address.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-15] + os: [macos-26] runs-on: ${{ matrix.os }} timeout-minutes: 60 @@ -29,7 +29,7 @@ jobs: run: | sudo apt-get install -y libjemalloc-dev - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Start consul uses: ./.github/actions/consul-start @@ -43,9 +43,11 @@ jobs: - name: Run address sanitizer run: swift test --sanitize=address - + - name: Clean before release build sanitizier run: swift package clean - - - name: Run address sanitizer on release build - run: swift test --sanitize=address -c release -Xswiftc -enable-testing + + # Disable release tests now as there's a bug in Swift Testing with release builds and argument parser + # and executable targets + # - name: Run address sanitizer on release build + # run: swift test --sanitize=address -c release \ No newline at end of file diff --git a/.github/workflows/swift-sanitizer-thread.yml b/.github/workflows/swift-sanitizer-thread.yml index 932fba9..a858eab 100644 --- a/.github/workflows/swift-sanitizer-thread.yml +++ b/.github/workflows/swift-sanitizer-thread.yml @@ -30,7 +30,7 @@ jobs: sudo apt-get install -y libjemalloc-dev echo BENCHMARK_DISABLE_JEMALLOC=true >> $GITHUB_ENV - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Start consul uses: ./.github/actions/consul-start @@ -44,9 +44,9 @@ jobs: - name: Run thread sanitizer run: swift test --sanitize=thread - + - name: Clean before release build sanitizier run: swift package clean - + - name: Run thread sanitizer on release build - run: swift test --sanitize=thread -c release -Xswiftc -enable-testing + run: swift test --sanitize=thread -c release -Xswiftc -enable-testing \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0f35633..ff04c18 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,4 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ +.vscode/ diff --git a/.swift-version b/.swift-version index f9ce5a9..0cda48a 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -5.10 +6.2 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..f61777e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "configurations": [ + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:package-frostflake}", + "name": "Debug flake", + "target": "flake", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug flake" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:package-frostflake}", + "name": "Release flake", + "target": "flake", + "configuration": "release", + "preLaunchTask": "swift: Build Release flake" + } + ] +} \ No newline at end of file diff --git a/Benchmarks/Benchmarks/Frostflake/FrostflakeBenchmark.swift b/Benchmarks/Benchmarks/Frostflake/FrostflakeBenchmark.swift index a537810..91be54d 100644 --- a/Benchmarks/Benchmarks/Frostflake/FrostflakeBenchmark.swift +++ b/Benchmarks/Benchmarks/Frostflake/FrostflakeBenchmark.swift @@ -9,7 +9,7 @@ import Benchmark import Frostflake -let benchmarks = { +let benchmarks: @Sendable () -> Void = { // Once during runtime setup can be done before registering benchmarks Benchmark.defaultConfiguration = .init(warmupIterations: 5, scalingFactor: .mega, @@ -17,7 +17,7 @@ let benchmarks = { maxIterations: Int(UInt16.max) - 5 - 1) Benchmark("Frostflake with locks") { benchmark in - let frostflakeFactory = Frostflake(generatorIdentifier: UInt16(benchmark.currentIteration), + let frostflakeFactory = Frostflake(generatorIdentifier: UInt64(benchmark.currentIteration), concurrentAccess: true) benchmark.startMeasurement() @@ -27,7 +27,7 @@ let benchmarks = { } Benchmark("Frostflake without locks") { benchmark in - let frostflakeFactory = Frostflake(generatorIdentifier: UInt16(benchmark.currentIteration), + let frostflakeFactory = Frostflake(generatorIdentifier: UInt64(benchmark.currentIteration), concurrentAccess: false) benchmark.startMeasurement() @@ -38,7 +38,7 @@ let benchmarks = { Benchmark("Frostflake descriptions", configuration: .init(scalingFactor: .kilo)) { benchmark in - let frostflakeFactory = Frostflake(generatorIdentifier: UInt16(benchmark.currentIteration)) + let frostflakeFactory = Frostflake(generatorIdentifier: UInt64(benchmark.currentIteration)) for _ in benchmark.scaledIterations { let frostflake = frostflakeFactory.generate() Benchmark.blackHole(frostflake.debugDescription) diff --git a/Benchmarks/Package.swift b/Benchmarks/Package.swift index 9e4fdf1..c593b0d 100644 --- a/Benchmarks/Package.swift +++ b/Benchmarks/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,25 +6,23 @@ import PackageDescription let package = Package( name: "Benchmarks", platforms: [ - .macOS(.v13) + .macOS(.v15) ], dependencies: [ .package(path: "../"), .package(url: "https://github.com/ordo-one/package-benchmark.git", from: "1.13.0") - // .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), - // .package(url: "https://github.com/apple/swift-system", from: "1.0.0") ], targets: [ .executableTarget( name: "FrostflakeBenchmark", dependencies: [ .product(name: "Frostflake", package: "package-frostflake"), - .product(name: "Benchmark", package: "package-benchmark"), - .product(name: "BenchmarkPlugin", package: "package-benchmark") - // .product(name: "ArgumentParser", package: "swift-argument-parser"), - // .product(name: "SystemPackage", package: "swift-system"), + .product(name: "Benchmark", package: "package-benchmark") ], - path: "Benchmarks/Frostflake" + path: "Benchmarks/Frostflake", + plugins: [ + .plugin(name: "BenchmarkPlugin", package: "package-benchmark") + ] ) ] ) diff --git a/Package.resolved b/Package.resolved index 229deae..36dfbae 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", - "version" : "1.4.0" + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" } }, { @@ -14,14 +14,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", - "version" : "1.3.0" + "revision" : "e977f65879f82b375a044c8837597f690c067da6", + "version" : "1.4.6" } }, { "identity" : "swift-docc-symbolkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-symbolkit", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", "state" : { "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system", "state" : { - "revision" : "f9266c85189c2751589a50ea5aec72799797e471", - "version" : "1.3.0" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } } ], diff --git a/Package.swift b/Package.swift index ee4a8bf..3e2c60e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,42 +1,25 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. -import class Foundation.ProcessInfo import PackageDescription -let externalDependencies: [String: Range] = [ - "https://github.com/apple/swift-argument-parser": .upToNextMajor(from: "1.0.0"), - "https://github.com/apple/swift-system": .upToNextMajor(from: "1.0.0"), - "https://github.com/apple/swift-docc-plugin": .upToNextMajor(from: "1.0.0") +let extraSettings: [SwiftSetting] = [ + .enableExperimentalFeature("SuppressedAssociatedTypes"), + .enableExperimentalFeature("LifetimeDependence"), + .enableExperimentalFeature("Lifetimes"), + .enableUpcomingFeature("LifetimeDependence"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("InternalImportsByDefault"), ] -let internalDependencies: [String: Range] = [:] - -func makeDependencies() -> [Package.Dependency] { - var dependencies: [Package.Dependency] = [] - dependencies.reserveCapacity(externalDependencies.count + internalDependencies.count) - - for extDep in externalDependencies { - dependencies.append(.package(url: extDep.key, extDep.value)) - } - - let localPath = ProcessInfo.processInfo.environment["LOCAL_PACKAGES_DIR"] - - for intDep in internalDependencies { - if let localPath { - dependencies.append(.package(name: "\(intDep.key)", path: "\(localPath)/\(intDep.key)")) - } else { - dependencies.append(.package(url: "https://github.com/ordo-one/\(intDep.key)", intDep.value)) - } - } - return dependencies -} - let package = Package( name: "package-frostflake", platforms: [ - .macOS(.v13), - .iOS(.v16) + .macOS(.v15), + .iOS(.v18) ], products: [ .library( @@ -48,12 +31,17 @@ let package = Package( targets: ["FrostflakeUtility"] ), ], - dependencies: makeDependencies(), + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-system", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + ], targets: [ // Main library target .target(name: "Frostflake", - path: "Sources/Frostflake"), - + path: "Sources/Frostflake", + swiftSettings: extraSettings + ), // Command line Frostflake generator .executableTarget( name: "FrostflakeUtility", @@ -61,13 +49,15 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system"), "Frostflake", - ] + ], + swiftSettings: extraSettings ), .testTarget( name: "FrostflakeTests", dependencies: [ "FrostflakeUtility", "Frostflake" - ] + ], + swiftSettings: extraSettings ) ] ) diff --git a/Sources/Frostflake/Frostflake.swift b/Sources/Frostflake/Frostflake.swift index 59532e6..7c4be0d 100644 --- a/Sources/Frostflake/Frostflake.swift +++ b/Sources/Frostflake/Frostflake.swift @@ -6,41 +6,81 @@ // // http://www.apache.org/licenses/LICENSE-2.0 +import Synchronization + /// Frostflake generator, we tried with an Actor but it was too slow. -public final class Frostflake { - public var currentSeconds: UInt32 - public var sequenceNumber: UInt32 - public let generatorIdentifier: UInt16 +public final class Frostflake: Sendable { + private struct MutableState: Sendable { + var currentSeconds: UInt32 + var sequenceNumber: UInt32 + } + + private final class StateBox: @unchecked Sendable { + var state: MutableState + init(_ state: MutableState) { self.state = state } + } + + private enum State: ~Copyable, Sendable { + case synchronized(Mutex) + case unsynchronized(StateBox) + } + + private let state: State + public let generatorIdentifier: UInt64 public let forcedTimeRegenerationInterval: UInt32 - private let lock: Lock? + + // Public accessors for state + public var currentSeconds: UInt32 { + switch state { + case .synchronized(let mutex): + mutex.withLock { $0.currentSeconds } + case .unsynchronized(let box): + box.state.currentSeconds + } + } + + public var sequenceNumber: UInt32 { + switch state { + case .synchronized(let mutex): + mutex.withLock { $0.sequenceNumber } + case .unsynchronized(let box): + box.state.sequenceNumber + } + } // Class variables and functions - private static var privateSharedGenerator: Frostflake? + private static let sharedGeneratorLock = Mutex(nil) /// Convenience static variable when using the same generator in many places /// The global generator identifier must be set using `setup(generatorIdentifier:)` before accessing /// this shared generator or we'll fatalError(). public static var sharedGenerator: Frostflake { - guard let generator = privateSharedGenerator else { - preconditionFailure("accessed sharedGenerator before calling setup") + sharedGeneratorLock.withLock { generator in + guard let generator else { + preconditionFailure("accessed sharedGenerator before calling setup") + } + return generator } - return generator } /// Setup may only be called a single time for a global shared generator identifier public static func setup(sharedGenerator: Frostflake) { - /// That check is very helpful for tests when `setup` function can be invoked several times from `setUp` XCTest function. - if privateSharedGenerator?.generatorIdentifier == sharedGenerator.generatorIdentifier { - return - } - if privateSharedGenerator != nil { - preconditionFailure("called setup multiple times") + sharedGeneratorLock.withLock { generator in + /// That check is very helpful for tests when `setup` function can be invoked several times from `setUp` XCTest function. + if generator?.generatorIdentifier == sharedGenerator.generatorIdentifier { + return + } + if generator != nil { + preconditionFailure("called setup multiple times") + } + generator = sharedGenerator } - privateSharedGenerator = sharedGenerator } public static func teardown() { - privateSharedGenerator = nil + sharedGeneratorLock.withLock { generator in + generator = nil + } } /// Convenience static variable when using the same generator in many places @@ -75,25 +115,31 @@ public final class Frostflake { /// - concurrentAccess: Specifies whether the generator can be accessed from multiple /// tasks/threads concurrently - if the generator is **only** used from a synchronized state /// like .eg. an Actor context, you can specify false here to avoid the internal locking overhead - public init(generatorIdentifier: UInt16, + public init(generatorIdentifier: UInt64, forcedTimeRegenerationInterval: UInt32 = defaultForcedTimeRegenerationInterval, concurrentAccess: Bool = true) { - assert(Self.validGeneratorIdentifierRange.contains(Int(generatorIdentifier)), + assert(Self.validGeneratorIdentifierRange.contains(generatorIdentifier), "Frostflake generatorIdentifier \(generatorIdentifier) used more than \(Self.generatorIdentifierBits) bits") assert((Self.sequenceNumberBits + Self.generatorIdentifierBits) == 32, "Frostflake sequenceNumberBits (\(Self.sequenceNumberBits)) + " + "generatorIdentifierBits (\(Self.generatorIdentifierBits)) != 32") + let currentSeconds = currentSecondsSinceEpoch() + let nanoSeconds = currentNanoSecondsInSecond() + + let initialState = MutableState( + currentSeconds: currentSeconds, + sequenceNumber: UInt32((nanoSeconds / 1_000) % 1_000_000) + ) + if concurrentAccess { - lock = Lock() + state = .synchronized(Mutex(initialState)) } else { - lock = nil + state = .unsynchronized(StateBox(initialState)) } - sequenceNumber = 0 self.generatorIdentifier = generatorIdentifier self.forcedTimeRegenerationInterval = forcedTimeRegenerationInterval - currentSeconds = currentSecondsSinceEpoch() } /// Generates a new Frostflake identifier for the generator @@ -107,15 +153,24 @@ public final class Frostflake { /// let frostflake2 = frostflakeFactory.generate() /// ``` public func generate() -> FrostflakeIdentifier { - lock?.lock() + switch state { + case .synchronized(let mutex): + mutex.withLock { state in + generateInternal(state: &state) + } + case .unsynchronized(let box): + generateInternal(state: &box.state) + } + } - assert(Self.allowedSequenceNumberRange.contains(Int(sequenceNumber)), "sequenceNumber ouf of allowed range") + private func generateInternal(state: inout MutableState) -> FrostflakeIdentifier { + assert(state.sequenceNumber < (1 << Self.sequenceNumberBits), "sequenceNumber out of allowed range") - sequenceNumber += 1 + state.sequenceNumber += 1 // Have we used all the sequence number bits, we need get a new base timestamp - if Self.allowedSequenceNumberRange.contains(Int(sequenceNumber)) == false { - assert(sequenceNumber == (1 << Self.sequenceNumberBits), "sequenceNumber != 1 << sequenceNumberBits") + if state.sequenceNumber >= (1 << Self.sequenceNumberBits) { + assert(state.sequenceNumber == (1 << Self.sequenceNumberBits), "sequenceNumber != 1 << sequenceNumberBits") let newCurrentSeconds = currentSecondsSinceEpoch() @@ -123,23 +178,21 @@ public final class Frostflake { // Currently we'll bail here - one could consider sleeping / retrying, but really synthetic problem. // Theoretically this could happen for NTP discrete timejumps back in time too, in which case // we'd rather abort and go down. - precondition(newCurrentSeconds > currentSeconds, "too many FrostflakeIdentifiers generated in one second") + precondition(newCurrentSeconds > state.currentSeconds, "too many FrostflakeIdentifiers generated in one second") - currentSeconds = newCurrentSeconds - sequenceNumber = 1 - } else if forcedTimeRegenerationInterval > 0, (sequenceNumber % forcedTimeRegenerationInterval) == 0 { + state.currentSeconds = newCurrentSeconds + state.sequenceNumber = 1 + } else if forcedTimeRegenerationInterval > 0, (state.sequenceNumber % forcedTimeRegenerationInterval) == 0 { let newCurrentSeconds = currentSecondsSinceEpoch() - if newCurrentSeconds > currentSeconds { - currentSeconds = newCurrentSeconds - sequenceNumber = 1 + if newCurrentSeconds > state.currentSeconds { + state.currentSeconds = newCurrentSeconds + state.sequenceNumber = 1 } } - var returnValue = UInt64(currentSeconds) << Self.secondsBits - returnValue += UInt64(sequenceNumber) << Self.generatorIdentifierBits - returnValue += UInt64(generatorIdentifier) - - lock?.unlock() + var returnValue = UInt64(state.currentSeconds) << Self.secondsBits + returnValue += UInt64(state.sequenceNumber) << Self.generatorIdentifierBits + returnValue += generatorIdentifier return .init(rawValue: returnValue) } diff --git a/Sources/Frostflake/FrostflakeDefinitions.swift b/Sources/Frostflake/FrostflakeDefinitions.swift index 9adc29d..638d317 100644 --- a/Sources/Frostflake/FrostflakeDefinitions.swift +++ b/Sources/Frostflake/FrostflakeDefinitions.swift @@ -25,13 +25,13 @@ public extension Frostflake { static let generatorIdentifierBits = 11 /// The range of valid generator identifiers - static let validGeneratorIdentifierRange = 0 ..< (1 << generatorIdentifierBits) + static let validGeneratorIdentifierRange: Range = 0 ..< (1 << generatorIdentifierBits) /// The range of valid sequence numbers static let allowedSequenceNumberRange = 0 ..< (1 << Frostflake.sequenceNumberBits) /// Convenience default manual generator identifier for the command line utility will pick the highest available identifier - static let defaultManualGeneratorIdentifier = (1 << generatorIdentifierBits) - 1 + static let defaultManualGeneratorIdentifier: UInt64 = (1 << generatorIdentifierBits) - 1 /// We will try to generate a new second timestamp every N generations (for low-flow components this will reset the /// timestamp a few times per day, for high-flow users it will cause a call to `gettimeofday()` needlessly instead.) diff --git a/Sources/Frostflake/FrostflakeHelpers.swift b/Sources/Frostflake/FrostflakeHelpers.swift index e305271..b3636e3 100644 --- a/Sources/Frostflake/FrostflakeHelpers.swift +++ b/Sources/Frostflake/FrostflakeHelpers.swift @@ -17,14 +17,23 @@ /// Get current seconds since UNIX epoch /// 32 bit number of seconds gives us ~136 years func currentSecondsSinceEpoch() -> UInt32 { + let currentTime = getCurrentTime() + return UInt32(currentTime.tv_sec) +} + +func currentNanoSecondsInSecond() -> Int { + let currentTime = getCurrentTime() + return currentTime.tv_nsec +} + +private func getCurrentTime() -> timespec { var currentTime = timespec() let result = clock_gettime(CLOCK_REALTIME, ¤tTime) guard result == 0 else { fatalError("Failed to get current time in clock_gettime(), errno = \(errno)") } - - return UInt32(currentTime.tv_sec) + return currentTime } // For tests diff --git a/Sources/Frostflake/FrostflakeIdentifier+Base58.swift b/Sources/Frostflake/FrostflakeIdentifier+Base58.swift index 592c9d4..f29d124 100644 --- a/Sources/Frostflake/FrostflakeIdentifier+Base58.swift +++ b/Sources/Frostflake/FrostflakeIdentifier+Base58.swift @@ -27,6 +27,8 @@ extension FrostflakeIdentifier { // Base58 Decoding public init?(base58: String) { + guard !base58.isEmpty else { return nil } + var value: UInt64 = 0 for character in base58.utf8 { diff --git a/Sources/Frostflake/Lock.swift b/Sources/Frostflake/Lock.swift deleted file mode 100644 index 9750d29..0000000 --- a/Sources/Frostflake/Lock.swift +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2022 Ordo One AB -// -// 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 - -// Adopted from SwiftNIO:s Lock, but changed to use os_unfair_lock on macOS -// and removed Windows lock support. This should be replaced with Swift 6 Mutex -// when older platform support is dropped - -#if canImport(Darwin) - import Darwin -#elseif canImport(Glibc) - import Glibc -#else - #error("Unsupported Platform") -#endif - -final class Lock { - #if os(macOS) - fileprivate let mutex = UnsafeMutablePointer.allocate(capacity: 1) - #else - fileprivate let mutex: UnsafeMutablePointer = - UnsafeMutablePointer.allocate(capacity: 1) - #endif - - /// Create a new lock. - init() { - #if os(macOS) - mutex.initialize(to: os_unfair_lock()) - #else - var attr = pthread_mutexattr_t() - pthread_mutexattr_init(&attr) - - let err = pthread_mutex_init(mutex, &attr) - precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") - #endif - } - - deinit { - #if os(macOS) - mutex.deinitialize(count: 1) - #else - let err = pthread_mutex_destroy(self.mutex) - precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") - #endif - mutex.deallocate() - } - - /// Acquire the lock. - /// - /// Whenever possible, consider using `withLock` instead of this method and - /// `unlock`, to simplify lock handling. - func lock() { - #if os(macOS) - os_unfair_lock_lock(mutex) - #else - let err = pthread_mutex_lock(mutex) - precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") - #endif - } - - /// Release the lock. - /// - /// Whenever possible, consider using `withLock` instead of this method and - /// `lock`, to simplify lock handling. - func unlock() { - #if os(macOS) - os_unfair_lock_unlock(mutex) - #else - let err = pthread_mutex_unlock(mutex) - precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") - #endif - } -} - -extension Lock: Lockable {} - -#if compiler(>=5.5) && canImport(_Concurrency) - extension Lock: Sendable {} -#endif - -/// Protocol any lock can implement. -public protocol Lockable { - /// Default initializer. - init() - - /// Acquire the lock. - func lock() - - /// Release the lock. - func unlock() -} - -public extension Lockable { - /// Acquire the lock for the duration of the given block. - /// - /// This convenience method should be preferred to `lock` and `unlock` in - /// most situations, as it ensures that the lock will be released regardless - /// of how `body` exits. - /// - /// - Parameter body: The block to execute while holding the lock. - /// - Returns: The value returned by the block. - @inlinable - @inline(__always) - func withLock(_ body: () throws -> T) rethrows -> T { - lock() - defer { - self.unlock() - } - return try body() - } - - // specialise Void return (for performance) - @inlinable - @inline(__always) - func withLockVoid(_ body: () throws -> Void) rethrows { - try withLock(body) - } -} diff --git a/Sources/FrostflakeUtility/FrostflakeUtility.swift b/Sources/FrostflakeUtility/FrostflakeUtility.swift index 682d6a9..580693f 100644 --- a/Sources/FrostflakeUtility/FrostflakeUtility.swift +++ b/Sources/FrostflakeUtility/FrostflakeUtility.swift @@ -6,8 +6,8 @@ // // http://www.apache.org/licenses/LICENSE-2.0 -import ArgumentParser -import Frostflake +public import ArgumentParser +public import Frostflake extension FrostflakeIdentifier: ExpressibleByArgument { public init?(argument: String) { @@ -26,7 +26,7 @@ extension FrostflakeIdentifier: ExpressibleByArgument { @main struct FrostflakeUtility: AsyncParsableCommand { @Option(help: "Specify generatorIdentifier to create a Frostflake with that generator id.") - var generatorIdentifier: Int = Frostflake.defaultManualGeneratorIdentifier + var generatorIdentifier: UInt64 = Frostflake.defaultManualGeneratorIdentifier @Option(help: "Decode a Frostflake timestamp by specifying a frostflake identifier") var identifier: FrostflakeIdentifier? @@ -41,7 +41,7 @@ struct FrostflakeUtility: AsyncParsableCommand { return } - let frostflakeFactory = Frostflake(generatorIdentifier: UInt16(generatorIdentifier), + let frostflakeFactory = Frostflake(generatorIdentifier: generatorIdentifier, concurrentAccess: false) print("\(frostflakeFactory.generate().base58)") } diff --git a/Tests/FrostflakeTests/FrostflakeTests.swift b/Tests/FrostflakeTests/FrostflakeTests.swift index a880f81..7d2d8a8 100644 --- a/Tests/FrostflakeTests/FrostflakeTests.swift +++ b/Tests/FrostflakeTests/FrostflakeTests.swift @@ -6,24 +6,21 @@ // // http://www.apache.org/licenses/LICENSE-2.0 -@testable import Frostflake +import Foundation +import Frostflake +import Testing -import XCTest - -final class FrostflakeTests: XCTestCase { - private let smallRangeTest = 1 ..< 1_000 - - override static func setUp() { - let frostflake = Frostflake(generatorIdentifier: 47) - Frostflake.setup(sharedGenerator: frostflake) - } +@Suite("Frostflake Tests") +struct FrostflakeTests { + private let smallRangeTest: Range = 1 ..< 1_000 // Verified using https://www.epochconverter.com as well manually - func testUnixEpochConversion() { + @Test("Unix epoch conversion produces correct date components") + func unixEpochConversion() { var unixEpoch = EpochDateTime.unixEpoch() unixEpoch.convert(timestamp: 1_653_051_594) // EpochDateTime(year: 2022, month: 5, day: 20, hour: 12, minute: 59, second: 54) - XCTAssert(unixEpoch.year == 2_022 && + #expect(unixEpoch.year == 2_022 && unixEpoch.month == 5 && unixEpoch.day == 20 && unixEpoch.hour == 12 && @@ -31,11 +28,12 @@ final class FrostflakeTests: XCTestCase { unixEpoch.second == 54, "Unix epoch conversion did not produce expected result") } - func testUnixEpochWithFutureDate() { + @Test("Unix epoch conversion handles future dates correctly") + func unixEpochWithFutureDate() { var unixEpoch = EpochDateTime.unixEpoch() unixEpoch.convert(timestamp: 19_912_223_655) // EpochDateTime(year: 2600, month: 12, day: 29, hour: 13, minute: 14, second: 15) - XCTAssert(unixEpoch.year == 2_600 && + #expect(unixEpoch.year == 2_600 && unixEpoch.month == 12 && unixEpoch.day == 29 && unixEpoch.hour == 13 && @@ -43,18 +41,13 @@ final class FrostflakeTests: XCTestCase { unixEpoch.second == 15, "Unix epoch conversion did not produce expected result") } - func testTestEpochWithFutureDate() { + @Test("Test epoch conversion handles future dates correctly") + func testEpochWithFutureDate() { var testEpoch = EpochDateTime.testEpoch() testEpoch.convert(timestamp: 1_653_061_201) // + 100 minutes // EpochDateTime(year: 2022, month: 5, day: 20, hour: 15, minute: 40, second: 1) - XCTAssertEqual(testEpoch.year, 2_022) - XCTAssertEqual(testEpoch.month, 5) - XCTAssertEqual(testEpoch.day, 20) - XCTAssertEqual(testEpoch.hour, 15) - XCTAssertEqual(testEpoch.minute, 40) - XCTAssertEqual(testEpoch.second, 1) - XCTAssert(testEpoch.year == 2_022 && + #expect(testEpoch.year == 2_022 && testEpoch.month == 5 && testEpoch.day == 20 && testEpoch.hour == 15 && @@ -62,7 +55,8 @@ final class FrostflakeTests: XCTestCase { testEpoch.second == 1, "Unix epoch conversion did not produce expected result") } - func testFrostflakeClassOutput() async { + @Test("Frostflake generates valid debug descriptions") + func frostflakeClassOutput() async { let frostflakeFactory = Frostflake(generatorIdentifier: 1_000) for _ in 0 ..< 10 { @@ -71,9 +65,10 @@ final class FrostflakeTests: XCTestCase { } } - func testFrostflake() async { + @Test("Frostflake generates unique identifiers across multiple generators") + func frostflake() async { for generatorId in smallRangeTest { - let frostflakeFactory = Frostflake(generatorIdentifier: UInt16(generatorId)) + let frostflakeFactory = Frostflake(generatorIdentifier: generatorId) for _ in smallRangeTest { blackHole(frostflakeFactory.generate()) @@ -81,9 +76,10 @@ final class FrostflakeTests: XCTestCase { } } - func testFrostflakeClassWithoutLocks() async { + @Test("Frostflake works correctly without concurrent access locks") + func frostflakeClassWithoutLocks() async { for generatorId in smallRangeTest { - let frostflakeFactory = Frostflake(generatorIdentifier: UInt16(generatorId), + let frostflakeFactory = Frostflake(generatorIdentifier: generatorId, concurrentAccess: false) for _ in smallRangeTest { @@ -92,10 +88,12 @@ final class FrostflakeTests: XCTestCase { } } - func testFrostflakeClassOverflowNextSecond() { + @Test("Frostflake handles sequence overflow by waiting for next second") + func frostflakeClassOverflowNextSecond() { let frostflakeFactory = Frostflake(generatorIdentifier: 0) + let remaining = Frostflake.allowedSequenceNumberRange.upperBound - Int(frostflakeFactory.sequenceNumber) - for _ in 1 ..< Frostflake.allowedSequenceNumberRange.upperBound { + for _ in 1 ..< remaining { blackHole(frostflakeFactory.generate()) } @@ -106,22 +104,23 @@ final class FrostflakeTests: XCTestCase { } } - func testFrostflakeSharedGenerator() { - for _ in smallRangeTest { - blackHole(Frostflake.generate()) - } - } - - func testFrostflakeSharedGeneratorWithCustomInit() { - for _ in smallRangeTest { - blackHole(FrostflakeIdentifier()) - } + // Regression test for unsynchronized state not being written back (sc-29118) + @Test("Unsynchronized generator persists sequence number between calls") + func unsynchronizedStatePersistence() { + let frostflakeFactory = Frostflake(generatorIdentifier: 1, concurrentAccess: false) + let first = frostflakeFactory.generate() + let second = frostflakeFactory.generate() + #expect(first.rawValue != second.rawValue, "Sequential generates must produce unique identifiers") + #expect(second.rawValue > first.rawValue, + "Sequence number must advance across calls") } // Regression test for sc-493 - func testIncorrectForcingSecondRegenerationInterval() { - let frostflakeFactory = Frostflake(generatorIdentifier: UInt16(100)) - for _ in 1 ..< Frostflake.allowedSequenceNumberRange.upperBound { + @Test("Sequence regeneration interval resets correctly after overflow") + func incorrectForcingSecondRegenerationInterval() { + let frostflakeFactory = Frostflake(generatorIdentifier: 100) + let remaining = Frostflake.allowedSequenceNumberRange.upperBound - Int(frostflakeFactory.sequenceNumber) + for _ in 1 ..< remaining { blackHole(frostflakeFactory.generate()) } sleep(1) diff --git a/Tests/FrostflakeTests/SharedGeneratorSuiteTrait.swift b/Tests/FrostflakeTests/SharedGeneratorSuiteTrait.swift new file mode 100644 index 0000000..96b7217 --- /dev/null +++ b/Tests/FrostflakeTests/SharedGeneratorSuiteTrait.swift @@ -0,0 +1,18 @@ +import Frostflake +import Testing + +struct SharedGeneratorSuiteTrait: SuiteTrait, TestScoping { + let isRecursive = false + + func provideScope(for test: Test, testCase: Test.Case?, performing function: @concurrent @Sendable () async throws -> Void) async throws { + let frostflake = Frostflake(generatorIdentifier: 47) + Frostflake.setup(sharedGenerator: frostflake) + try await function() + } +} + +extension SuiteTrait where Self == SharedGeneratorSuiteTrait { + static var sharedGenerator: Self { + Self() + } +} diff --git a/Tests/FrostflakeTests/SharedGeneratorTests.swift b/Tests/FrostflakeTests/SharedGeneratorTests.swift new file mode 100644 index 0000000..c6f6b7e --- /dev/null +++ b/Tests/FrostflakeTests/SharedGeneratorTests.swift @@ -0,0 +1,29 @@ +// Copyright 2002 Ordo One AB +// +// 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 + +import Frostflake +import Testing + +@Suite("Shared Generator Tests", .sharedGenerator) +struct SharedGeneratorTests { + private let smallRangeTest = 1 ..< 1_000 + + @Test("Shared generator produces valid identifiers") + func frostflakeSharedGenerator() { + for _ in smallRangeTest { + blackHole(Frostflake.generate()) + } + } + + @Test("FrostflakeIdentifier initializer uses shared generator") + func frostflakeSharedGeneratorWithCustomInit() { + for _ in smallRangeTest { + blackHole(FrostflakeIdentifier()) + } + } +}