diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index 36f6ca6..dbce922 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -11,4 +11,4 @@ jobs: with: package_name: redis modules: Redis - pathsToInvalidate: /redis \ No newline at end of file + pathsToInvalidate: /redis/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f1fbde..1016b93 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,13 +17,36 @@ jobs: api-breakage: if: ${{ !(github.event.pull_request.draft || false) }} runs-on: ubuntu-latest - container: swift:5.8-jammy + container: swift:jammy steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: { 'fetch-depth': 0 } - - name: Run API breakage check action - uses: vapor/ci/.github/actions/ci-swift-check-api-breakage@reusable-workflows + - name: Run API breakage check + run: | + git config --global --add safe.directory "${GITHUB_WORKSPACE}" + swift package diagnose-api-breaking-changes origin/main + + # gh-codeql: + # if: ${{ !(github.event.pull_request.draft || false) }} + # runs-on: ubuntu-latest + # permissions: { actions: write, contents: read, security-events: write } + # timeout-minutes: 30 + # steps: + # - name: Install latest Swift toolchain + # uses: vapor/swiftly-action@v0.1 + # with: { toolchain: latest } + # - name: Check out code + # uses: actions/checkout@v4 + # - name: Fix Git configuration + # run: 'git config --global --add safe.directory "${GITHUB_WORKSPACE}"' + # - name: Initialize CodeQL + # uses: github/codeql-action/init@v3 + # with: { languages: swift } + # - name: Perform build + # run: swift build + # - name: Run CodeQL analyze + # uses: github/codeql-action/analyze@v3 linux-unit: if: ${{ !(github.event.pull_request.draft || false) }} @@ -31,10 +54,10 @@ jobs: fail-fast: false matrix: container: - - swift:5.6-focal - - swift:5.7-jammy - - swift:5.8-jammy - - swiftlang/swift:nightly-5.9-jammy + - swift:5.8-focal + - swift:5.9-jammy + - swift:5.10-jammy + - swiftlang/swift:nightly-6.0-jammy - swiftlang/swift:nightly-main-jammy redis: - redis:6 @@ -47,22 +70,11 @@ jobs: redis-2: image: ${{ matrix.redis }} steps: - - name: Save Redis version to env - run: | - echo REDIS_VERSION='${{ matrix.redis }}' >> $GITHUB_ENV - - name: Display versions - shell: bash - run: | - if [[ '${{ contains(matrix.container, 'nightly') }}' == 'true' ]]; then - SWIFT_PLATFORM="$(source /etc/os-release && echo "${ID}${VERSION_ID}")" SWIFT_VERSION="$(cat /.swift_tag)" - printf 'SWIFT_PLATFORM=%s\nSWIFT_VERSION=%s\n' "${SWIFT_PLATFORM}" "${SWIFT_VERSION}" >>"${GITHUB_ENV}" - fi - printf 'OS: %s\nTag: %s\nVersion:\n' "${SWIFT_PLATFORM}-${RUNNER_ARCH}" "${SWIFT_VERSION}" && swift --version - name: Check out package - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Run unit tests with Thread Sanitizer and coverage run: swift test --sanitize=thread --enable-code-coverage - - name: Submit coverage report to Codecov.io - uses: vapor/swift-codecov-action@v0.2 + - name: Upload coverage data + uses: vapor/swift-codecov-action@v0.3 with: - cc_env_vars: 'SWIFT_VERSION,SWIFT_PLATFORM,RUNNER_OS,RUNNER_ARCH,REDIS_VERSION' + codecov_token: ${{ secrets.CODECOV_TOKEN }} diff --git a/Package.swift b/Package.swift index 6baeb22..c8e48d0 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.8 import PackageDescription let package = Package( @@ -14,7 +14,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/swift-server/RediStack.git", from: "1.4.1"), - .package(url: "https://github.com/vapor/vapor.git", from: "4.77.1"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.100.0"), ], targets: [ .target( diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 0000000..0f4a065 --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,37 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "redis", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library(name: "Redis", targets: ["Redis"]) + ], + dependencies: [ + .package(url: "https://github.com/swift-server/RediStack.git", from: "1.4.1"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.100.0"), + ], + targets: [ + .target( + name: "Redis", + dependencies: [ + .product(name: "RediStack", package: "RediStack"), + .product(name: "Vapor", package: "vapor"), + ], + swiftSettings: [.enableExperimentalFeature("StrictConcurrency=complete")] + ), + .testTarget( + name: "RedisTests", + dependencies: [ + .target(name: "Redis"), + .product(name: "XCTVapor", package: "vapor"), + ], + swiftSettings: [.enableExperimentalFeature("StrictConcurrency=complete")] + ) + ] +) diff --git a/Sources/Redis/Application.Redis+PubSub.swift b/Sources/Redis/Application.Redis+PubSub.swift index 47dec74..0474164 100644 --- a/Sources/Redis/Application.Redis+PubSub.swift +++ b/Sources/Redis/Application.Redis+PubSub.swift @@ -1,9 +1,9 @@ import Vapor -import RediStack +@preconcurrency import RediStack extension Application.Redis { private struct PubSubKey: StorageKey, LockKey { - typealias Value = [RedisID: RedisClient] + typealias Value = [RedisID: RedisClient & Sendable] } var pubsubClient: RedisClient { diff --git a/Sources/Redis/Redis+Cache.swift b/Sources/Redis/Redis+Cache.swift index dee2d26..4251829 100644 --- a/Sources/Redis/Redis+Cache.swift +++ b/Sources/Redis/Redis+Cache.swift @@ -1,6 +1,6 @@ import Vapor import Foundation -import RediStack +@preconcurrency import RediStack import NIOCore // MARK: RedisCacheCoder @@ -40,6 +40,11 @@ extension Application.Caches { /// A cache configured for a given Redis ID and using the provided encoder and decoder. public func redis(_ id: RedisID = .default, encoder: E, decoder: D) -> Cache { + RedisCache(encoder: FakeSendable(value: encoder), decoder: FakeSendable(value: decoder), client: self.application.redis(id)) + } + + /// A cache configured for a given Redis ID and using the provided encoder and decoder wrapped as FakeSendable. + func redis(_ id: RedisID = .default, encoder: FakeSendable, decoder: FakeSendable) -> Cache { RedisCache(encoder: encoder, decoder: decoder, client: self.application.redis(id)) } } @@ -59,20 +64,25 @@ extension Application.Caches.Provider { /// Configures the application cache to use the given Redis ID and the provided encoder and decoder. public static func redis(_ id: RedisID = .default, encoder: E, decoder: D) -> Self { - .init { $0.caches.use { $0.caches.redis(id, encoder: encoder, decoder: decoder) } } + let wrappedEncoder = FakeSendable(value: encoder) + let wrappedDecoder = FakeSendable(value: decoder) + return .init { $0.caches.use { $0.caches.redis(id, encoder: wrappedEncoder, decoder: wrappedDecoder) } } } } // MARK: - Redis cache driver +/// A wrapper to silence `Sendable` warnings for `JSONDecoder` and `JSONEncoder` when not on macOS. +struct FakeSendable: @unchecked Sendable { let value: T } + /// `Cache` driver for storing cache data in Redis, using a provided encoder and decoder to serialize and deserialize values respectively. -private struct RedisCache: Cache { - let encoder: CacheEncoder - let decoder: CacheDecoder +private struct RedisCache: Cache, Sendable { + let encoder: FakeSendable + let decoder: FakeSendable let client: RedisClient func get(_ key: String, as type: T.Type) -> EventLoopFuture { - self.client.get(RedisKey(key), as: CacheDecoder.Input.self).optionalFlatMapThrowing { try self.decoder.decode(T.self, from: $0) } + self.client.get(RedisKey(key), as: CacheDecoder.Input.self).optionalFlatMapThrowing { try self.decoder.value.decode(T.self, from: $0) } } func set(_ key: String, to value: T?, expiresIn expirationTime: CacheExpirationTime?) -> EventLoopFuture { @@ -81,7 +91,7 @@ private struct RedisCache Void)? + public var onUnexpectedConnectionClose: (@Sendable (RedisConnection) -> Void)? + @preconcurrency public init( maximumConnectionCount: RedisConnectionPoolSize = .maximumActiveConnections(2), minimumConnectionCount: Int = 0, connectionBackoffFactor: Float32 = 2, initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), connectionRetryTimeout: TimeAmount? = nil, - onUnexpectedConnectionClose: ((RedisConnection) -> Void)? = nil + onUnexpectedConnectionClose: (@Sendable (RedisConnection) -> Void)? = nil ) { self.maximumConnectionCount = maximumConnectionCount self.minimumConnectionCount = minimumConnectionCount diff --git a/Sources/Redis/RedisID.swift b/Sources/Redis/RedisID.swift index 7b19677..e742828 100644 --- a/Sources/Redis/RedisID.swift +++ b/Sources/Redis/RedisID.swift @@ -16,7 +16,8 @@ public struct RedisID: Hashable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation, CustomStringConvertible, - Comparable { + Comparable, + Sendable { public let rawValue: String diff --git a/Sources/Redis/RedisStorage.swift b/Sources/Redis/RedisStorage.swift index d0091bd..2d5e452 100644 --- a/Sources/Redis/RedisStorage.swift +++ b/Sources/Redis/RedisStorage.swift @@ -3,7 +3,7 @@ import NIOConcurrencyHelpers import NIOCore import NIOPosix import NIOSSL -import RediStack +@preconcurrency import RediStack extension Application { private struct RedisStorageKey: StorageKey { @@ -21,38 +21,38 @@ extension Application { } } -final class RedisStorage { - private var lock: NIOLock - private var configurations: [RedisID: RedisConfiguration] - fileprivate var pools: [PoolKey: RedisConnectionPool] { - willSet { - guard pools.isEmpty else { - fatalError("Modifying connection pools after application has booted is not supported") +final class RedisStorage: Sendable { + fileprivate struct StorageBox: Sendable { + var configurations: [RedisID: RedisConfiguration] + var pools: [PoolKey: RedisConnectionPool] { + willSet { + guard self.pools.isEmpty else { + fatalError("Modifying connection pools after application has booted is not supported") + } } } } + private let box: NIOLockedValueBox init() { - self.configurations = [:] - self.pools = [:] - self.lock = .init() + self.box = .init(.init(configurations: [:], pools: [:])) } func use(_ redisConfiguration: RedisConfiguration, as id: RedisID = .default) { - self.configurations[id] = redisConfiguration + self.box.withLockedValue { $0.configurations[id] = redisConfiguration } } func configuration(for id: RedisID = .default) -> RedisConfiguration? { - self.configurations[id] + self.box.withLockedValue { $0.configurations[id] } } func ids() -> Set { - Set(self.configurations.keys) + Set(self.box.withLockedValue { $0.configurations.keys }) } func pool(for eventLoop: EventLoop, id redisID: RedisID) -> RedisConnectionPool { let key = PoolKey(eventLoopKey: eventLoop.key, redisID: redisID) - guard let pool = pools[key] else { + guard let pool = self.box.withLockedValue({ $0.pools[key] }) else { fatalError("No redis found for id \(redisID), or the app may not have finished booting. Also, the eventLoop must be from Application's EventLoopGroup.") } return pool @@ -69,54 +69,48 @@ extension RedisStorage { } func didBoot(_ application: Application) throws { - self.redisStorage.lock.lock() - defer { - self.redisStorage.lock.unlock() - } var newPools: [PoolKey: RedisConnectionPool] = [:] for eventLoop in application.eventLoopGroup.makeIterator() { - for (redisID, configuration) in redisStorage.configurations { - - let newKey: PoolKey = PoolKey(eventLoopKey: eventLoop.key, redisID: redisID) - - let redisTLSClient: ClientBootstrap? = { - guard let tlsConfig = configuration.tlsConfiguration, - let tlsHost = configuration.tlsHostname else { return nil } - - return ClientBootstrap(group: eventLoop) - .channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) - .channelInitializer { channel in - do { - let sslContext = try NIOSSLContext(configuration: tlsConfig) - return EventLoopFuture.andAllSucceed([ - channel.pipeline.addHandler(try NIOSSLClientHandler(context: sslContext, - serverHostname: tlsHost)), - channel.pipeline.addBaseRedisHandlers() - ], on: channel.eventLoop) - } catch { - return channel.eventLoop.makeFailedFuture(error) + redisStorage.box.withLockedValue { storageBox in + for (redisID, configuration) in storageBox.configurations { + + let newKey: PoolKey = PoolKey(eventLoopKey: eventLoop.key, redisID: redisID) + + let redisTLSClient: ClientBootstrap? = { + guard let tlsConfig = configuration.tlsConfiguration, + let tlsHost = configuration.tlsHostname else { return nil } + + return ClientBootstrap(group: eventLoop) + .channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .channelInitializer { channel in + do { + let sslContext = try NIOSSLContext(configuration: tlsConfig) + return EventLoopFuture.andAllSucceed([ + channel.pipeline.addHandler(try NIOSSLClientHandler(context: sslContext, + serverHostname: tlsHost)), + channel.pipeline.addBaseRedisHandlers() + ], on: channel.eventLoop) + } catch { + return channel.eventLoop.makeFailedFuture(error) + } } - } - }() + }() - let newPool = RedisConnectionPool( - configuration: .init(configuration, defaultLogger: application.logger, customClient: redisTLSClient), - boundEventLoop: eventLoop) + let newPool = RedisConnectionPool( + configuration: .init(configuration, defaultLogger: application.logger, customClient: redisTLSClient), + boundEventLoop: eventLoop) - newPools[newKey] = newPool + newPools[newKey] = newPool + } } } - self.redisStorage.pools = newPools + self.redisStorage.box.withLockedValue { $0.pools = newPools } } /// Close each connection pool func shutdown(_ application: Application) { - self.redisStorage.lock.lock() - defer { - self.redisStorage.lock.unlock() - } - let shutdownFuture: EventLoopFuture = redisStorage.pools.values.map { pool in + let shutdownFuture: EventLoopFuture = redisStorage.box.withLockedValue { $0.pools.values }.map { pool in let promise = pool.eventLoop.makePromise(of: Void.self) pool.close(promise: promise) return promise.futureResult @@ -128,6 +122,20 @@ extension RedisStorage { application.logger.error("Error shutting down redis connection pools, possibly because the pool never connected to the Redis server: \(error)") } } + + func shutdownAsync(_ application: Application) async { + let shutdownFuture: EventLoopFuture = redisStorage.box.withLockedValue { $0.pools.values }.map { pool in + let promise = pool.eventLoop.makePromise(of: Void.self) + pool.close(promise: promise) + return promise.futureResult + }.flatten(on: application.eventLoopGroup.next()) + + do { + try await shutdownFuture.get() + } catch { + application.logger.error("Error shutting down redis connection pools, possibly because the pool never connected to the Redis server: \(error)") + } + } } } diff --git a/Tests/RedisTests/RedisTests.swift b/Tests/RedisTests/RedisTests.swift index 8b3ff9d..e5a7df7 100644 --- a/Tests/RedisTests/RedisTests.swift +++ b/Tests/RedisTests/RedisTests.swift @@ -2,7 +2,7 @@ import Redis import Vapor import Logging import XCTVapor -import RediStack +@preconcurrency import RediStack import XCTest extension String {