From 04d6e28fd3819917dc7b806e5ec1ae370a442159 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Wed, 6 Aug 2025 14:14:42 +0100 Subject: [PATCH 1/6] Valkey --- .github/workflows/ci.yml | 10 +- .github/workflows/nightly.yml | 8 +- .spi.yml | 2 +- Package.swift | 18 +- README.md | 36 ++-- Sources/HummingbirdRedis/Deprecations.swift | 26 --- Sources/HummingbirdRedis/Persist+Redis.swift | 71 -------- Sources/HummingbirdRedis/Redis+Codable.swift | 66 ------- .../HummingbirdRedis/RedisConfiguration.swift | 128 -------------- .../RedisConnectionPoolService.swift | 166 ------------------ .../HummingbirdValkey/Persist+Valkey.swift | 80 +++++++++ Tests/HummingbirdRedisTests/RedisTests.swift | 81 --------- .../PersistTests.swift | 29 ++- 13 files changed, 139 insertions(+), 582 deletions(-) delete mode 100644 Sources/HummingbirdRedis/Deprecations.swift delete mode 100644 Sources/HummingbirdRedis/Persist+Redis.swift delete mode 100644 Sources/HummingbirdRedis/Redis+Codable.swift delete mode 100644 Sources/HummingbirdRedis/RedisConfiguration.swift delete mode 100644 Sources/HummingbirdRedis/RedisConnectionPoolService.swift create mode 100644 Sources/HummingbirdValkey/Persist+Valkey.swift delete mode 100644 Tests/HummingbirdRedisTests/RedisTests.swift rename Tests/{HummingbirdRedisTests => HummingbirdValkeyTests}/PersistTests.swift (91%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac42aec..b9b15fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: true env: - REDIS_HOSTNAME: redis + VALKEY_HOSTNAME: valkey jobs: linux: runs-on: ubuntu-latest @@ -25,11 +25,11 @@ jobs: container: image: ${{ matrix.image }} services: - redis: - image: redis + valkey: + image: valkey/valkey ports: - 6379:6379 - options: --entrypoint redis-server + options: --entrypoint valkey-server steps: - name: Checkout @@ -40,7 +40,7 @@ jobs: - name: Convert coverage files run: | llvm-cov export -format="lcov" \ - .build/debug/hummingbird-redisPackageTests.xctest \ + .build/debug/hummingbird-valkeyPackageTests.xctest \ -ignore-filename-regex="\/Tests\/" \ -ignore-filename-regex="\/Benchmarks\/" \ -instr-profile .build/debug/codecov/default.profdata > info.lcov diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index fd0cd52..c2c8bf4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: env: - REDIS_HOSTNAME: redis + VALKEY_HOSTNAME: valkey jobs: linux: runs-on: ubuntu-latest @@ -15,11 +15,11 @@ jobs: container: image: swiftlang/swift:${{ matrix.image }} services: - redis: - image: redis + valkey: + image: valkey/valkey ports: - 6379:6379 - options: --entrypoint redis-server + options: --entrypoint valkey-server steps: - name: Checkout diff --git a/.spi.yml b/.spi.yml index 3e0f91a..6e3a49a 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,3 +1,3 @@ version: 1 external_links: - documentation: "https://docs.hummingbird.codes/2.0/documentation/hummingbirdredis" + documentation: "https://docs.hummingbird.codes/2.0/documentation/hummingbirdvalkey" diff --git a/Package.swift b/Package.swift index d491428..0c640c3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,30 +1,30 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( - name: "hummingbird-redis", - platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)], + name: "hummingbird-valkey", + platforms: [.macOS(.v15), .iOS(.v18), .tvOS(.v18)], products: [ - .library(name: "HummingbirdRedis", targets: ["HummingbirdRedis"]) + .library(name: "HummingbirdValkey", targets: ["HummingbirdValkey"]) ], dependencies: [ .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.5.0"), - .package(url: "https://github.com/swift-server/RediStack.git", from: "1.4.0"), + .package(url: "https://github.com/valkey-io/valkey-swift.git", from: "0.1.0"), ], targets: [ .target( - name: "HummingbirdRedis", + name: "HummingbirdValkey", dependencies: [ .product(name: "Hummingbird", package: "hummingbird"), - .product(name: "RediStack", package: "RediStack"), + .product(name: "Valkey", package: "valkey-swift"), ] ), .testTarget( - name: "HummingbirdRedisTests", + name: "HummingbirdValkeyTests", dependencies: [ - .byName(name: "HummingbirdRedis"), + .byName(name: "HummingbirdValkey"), .product(name: "Hummingbird", package: "hummingbird"), .product(name: "HummingbirdTesting", package: "hummingbird"), ] diff --git a/README.md b/README.md index cfb2173..07b4bc9 100644 --- a/README.md +++ b/README.md @@ -8,47 +8,51 @@ - - + +

-# Hummingbird Redis Interface +# Hummingbird Valkey/Redis Interface -Redis is an open source, in-memory data structure store, used as a database, cache, and message broker. +Valkey is an open source, in-memory data structure store, used as a database, cache, and message broker. -This is the Hummingbird interface to [RediStack library](https://gitlab.com/mordil/RediStack.git) a Swift driver for Redis. +This is the Hummingbird interface to the [valkey-swift library](https://github.com/valkey-io/valkey-swift.git) a Swift driver for Valkey/Redis. Currently HummingbirdValkey consists of driver for the Hummingbird persist framework. ## Usage ```swift import Hummingbird -import HummingbirdRedis +import HummingbirdValkey -let redis = try RedisConnectionPoolService( - .init(hostname: redisHostname, port: 6379), - logger: Logger(label: "Redis") -) +let valkey = ValkeyClient(.hostname(valkeyHostname, port: 6379), logger: Logger(label: "Valkey")) +let persist = ValkeyPersistDriver(client: valkeyClient) -// create router and add a single GET /redis route +// create router and add a GET /valkey/{key} and PUT /valkey/{key} routes let router = Router() -router.get("redis") { request, _ -> String in - let info = try await redis.send(command: "INFO").get() - return String(describing: info) +router.get("valkey/{key}") { request, context -> String? in + let key = try context.parameters.require("key") + return try await persist.get(key: .init(key), as: String.self) +} +router.put("valkey/{key}") { request, context in + let key = try context.parameters.require("key") + let value = try request.uri.queryParameters.require("value") + try await persist.set(key: key, value: value) + return HTTPResponse.Status.ok } // create application using router var app = Application( router: router, configuration: .init(address: .hostname("127.0.0.1", port: 8080)) ) -app.addServices(redis) +app.addServices(valkey) // run hummingbird application try await app.runService() ``` ## Documentation -Reference documentation for HummingbirdRedis can be found [here](https://docs.hummingbird.codes/2.0/documentation/hummingbirdredis) +Reference documentation for HummingbirdValkey can be found [here](https://docs.hummingbird.codes/2.0/documentation/hummingbirdvalkey) diff --git a/Sources/HummingbirdRedis/Deprecations.swift b/Sources/HummingbirdRedis/Deprecations.swift deleted file mode 100644 index 07f93ec..0000000 --- a/Sources/HummingbirdRedis/Deprecations.swift +++ /dev/null @@ -1,26 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2024 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -// Below is a list of unavailable symbols with the "HB" prefix. These are available -// temporarily to ease transition from the old symbols that included the "HB" -// prefix to the new ones. -// -// This file will be removed before we do a 2.0 release - -@_documentation(visibility: internal) @available(*, unavailable, renamed: "RedisConfiguration") -public typealias HBRedisConfiguration = RedisConfiguration -@_documentation(visibility: internal) @available(*, unavailable, renamed: "RedisConnectionPoolService") -public typealias HBRedisConnectionPoolService = RedisConnectionPoolService -@_documentation(visibility: internal) @available(*, unavailable, renamed: "RedisPersistDriver") -public typealias HBRedisPersistDriver = RedisPersistDriver diff --git a/Sources/HummingbirdRedis/Persist+Redis.swift b/Sources/HummingbirdRedis/Persist+Redis.swift deleted file mode 100644 index c2ea6f3..0000000 --- a/Sources/HummingbirdRedis/Persist+Redis.swift +++ /dev/null @@ -1,71 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2021-2022 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Hummingbird -@preconcurrency import RediStack - -/// Redis driver for persist system for storing persistent cross request key/value pairs -public struct RedisPersistDriver: PersistDriver { - let redisConnectionPool: RedisConnectionPoolService - - public init(redisConnectionPoolService: RedisConnectionPoolService) { - self.redisConnectionPool = redisConnectionPoolService - } - - /// create new key with value. If key already exist throw `PersistError.duplicate` error - public func create(key: String, value: some Codable, expires: Duration?) async throws { - let expiration: RedisSetCommandExpiration? = expires.map { .seconds(Int($0.components.seconds)) } - let result = try await self.redisConnectionPool.set(.init(key), toJSON: value, onCondition: .keyDoesNotExist, expiration: expiration) - switch result { - case .ok: - return - case .conditionNotMet: - throw PersistError.duplicate - } - } - - /// set value for key. If value already exists overwrite it - public func set(key: String, value: some Codable, expires: Duration?) async throws { - if let expires { - let expiration = Int(expires.components.seconds) - _ = try await self.redisConnectionPool.set( - .init(key), - toJSON: value, - onCondition: .none, - expiration: .seconds(expiration) - ) - } else { - _ = try await self.redisConnectionPool.set( - .init(key), - toJSON: value, - onCondition: .none, - expiration: .keepExisting - ) - } - } - - /// get value for key - public func get(key: String, as object: Object.Type) async throws -> Object? { - do { - return try await self.redisConnectionPool.get(.init(key), asJSON: object) - } catch is DecodingError { - throw PersistError.invalidConversion - } - } - - /// remove key - public func remove(key: String) async throws { - _ = try await self.redisConnectionPool.delete(.init(key)).get() - } -} diff --git a/Sources/HummingbirdRedis/Redis+Codable.swift b/Sources/HummingbirdRedis/Redis+Codable.swift deleted file mode 100644 index 392233f..0000000 --- a/Sources/HummingbirdRedis/Redis+Codable.swift +++ /dev/null @@ -1,66 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2021-2021 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Foundation -import NIO -@preconcurrency import RediStack - -extension RedisClient { - /// Decodes the value associated with this keyfrom JSON. - public func get(_ key: RedisKey, asJSON type: D.Type) async throws -> D? { - guard let data = try await self.get(key, as: Data.self).get() else { return nil } - return try JSONDecoder().decode(D.self, from: data) - } - - /// Sets the value stored in the key provided, overwriting the previous value. - /// - /// Any previous expiration set on the key is discarded if the SET operation was successful. - /// - /// - Important: Regardless of the type of value stored at the key, it will be overwritten to a string value. - /// - /// [https://redis.io/commands/set](https://redis.io/commands/set) - /// - Parameters: - /// - key: The key to use to uniquely identify this value. - /// - value: The value to set the key to. - @inlinable - public func set(_ key: RedisKey, toJSON value: some Encodable) async throws { - try await self.set(key, to: JSONEncoder().encode(value)).get() - } - - /// Sets the key to the provided value with options to control how it is set. - /// - /// [https://redis.io/commands/set](https://redis.io/commands/set) - /// - Important: Regardless of the type of data stored at the key, it will be overwritten to a "string" data type. - /// - /// ie. If the key is a reference to a Sorted Set, its value will be overwritten to be a "string" data type. - /// - /// - Parameters: - /// - key: The key to use to uniquely identify this value. - /// - value: The value to set the key to. - /// - condition: The condition under which the key should be set. - /// - expiration: The expiration to use when setting the key. No expiration is set if `nil`. - /// - Returns: A `NIO.EventLoopFuture` indicating the result of the operation; - /// `.ok` if the operation was successful and `.conditionNotMet` if the specified `condition` was not met. - /// - /// If the condition `.none` was used, then the result value will always be `.ok`. - @_disfavoredOverload - public func set( - _ key: RedisKey, - toJSON value: some Encodable, - onCondition condition: RedisSetCommandCondition = .none, - expiration: RedisSetCommandExpiration? = nil - ) async throws -> RedisSetCommandResult { - try await self.set(key, to: JSONEncoder().encode(value), onCondition: condition, expiration: expiration).get() - } -} diff --git a/Sources/HummingbirdRedis/RedisConfiguration.swift b/Sources/HummingbirdRedis/RedisConfiguration.swift deleted file mode 100644 index 1ccce6b..0000000 --- a/Sources/HummingbirdRedis/RedisConfiguration.swift +++ /dev/null @@ -1,128 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2021-2021 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Hummingbird -import Logging -import NIOCore -@preconcurrency import RediStack - -import struct Foundation.URL - -// Based of the Vapor redis configuration that can be found -// here https://github.com/vapor/redis/blob/master/Sources/Redis/RedisConfiguration.swift - -public struct RedisConfiguration { - public typealias ValidationError = RedisConnection.Configuration.ValidationError - - public var serverAddresses: [SocketAddress] - public var password: String? - public var database: Int? - public var pool: PoolOptions - - public struct PoolOptions { - public var maximumConnectionCount: RedisConnectionPoolSize - public var minimumConnectionCount: Int - public var connectionBackoffFactor: Float32 - public var initialConnectionBackoffDelay: TimeAmount - public var connectionRetryTimeout: TimeAmount? - - public init( - maximumConnectionCount: RedisConnectionPoolSize = .maximumActiveConnections(2), - minimumConnectionCount: Int = 0, - connectionBackoffFactor: Float32 = 2, - initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), - connectionRetryTimeout: TimeAmount? = nil - ) { - self.maximumConnectionCount = maximumConnectionCount - self.minimumConnectionCount = minimumConnectionCount - self.connectionBackoffFactor = connectionBackoffFactor - self.initialConnectionBackoffDelay = initialConnectionBackoffDelay - self.connectionRetryTimeout = connectionRetryTimeout - } - } - - public init(url string: String, pool: PoolOptions = .init()) throws { - guard let url = URL(string: string) else { throw ValidationError.invalidURLString } - try self.init(url: url, pool: pool) - } - - public init(url: URL, pool: PoolOptions = .init()) throws { - guard - let scheme = url.scheme, - !scheme.isEmpty - else { throw ValidationError.missingURLScheme } - guard scheme == "redis" else { throw ValidationError.invalidURLScheme } - guard let host = url.host, !host.isEmpty else { throw ValidationError.missingURLHost } - - try self.init( - hostname: host, - port: url.port ?? RedisConnection.Configuration.defaultPort, - password: url.password, - database: Int(url.lastPathComponent), - pool: pool - ) - } - - public init( - hostname: String, - port: Int = RedisConnection.Configuration.defaultPort, - password: String? = nil, - database: Int? = nil, - pool: PoolOptions = .init() - ) throws { - if database != nil, database! < 0 { throw ValidationError.outOfBoundsDatabaseID } - - try self.init( - serverAddresses: [.makeAddressResolvingHost(hostname, port: port)], - password: password, - database: database, - pool: pool - ) - } - - public init( - serverAddresses: [SocketAddress], - password: String? = nil, - database: Int? = nil, - pool: PoolOptions = .init() - ) throws { - self.serverAddresses = serverAddresses - self.password = password - self.database = database - self.pool = pool - } -} - -extension RedisConnectionPool.Configuration { - init( - _ config: RedisConfiguration, - logger: Logger - ) { - self.init( - initialServerConnectionAddresses: config.serverAddresses, - maximumConnectionCount: config.pool.maximumConnectionCount, - connectionFactoryConfiguration: .init( - connectionInitialDatabase: config.database, - connectionPassword: config.password, - connectionDefaultLogger: logger, - tcpClient: nil - ), - minimumConnectionCount: config.pool.minimumConnectionCount, - connectionBackoffFactor: config.pool.connectionBackoffFactor, - initialConnectionBackoffDelay: config.pool.initialConnectionBackoffDelay, - connectionRetryTimeout: config.pool.connectionRetryTimeout, - poolDefaultLogger: logger - ) - } -} diff --git a/Sources/HummingbirdRedis/RedisConnectionPoolService.swift b/Sources/HummingbirdRedis/RedisConnectionPoolService.swift deleted file mode 100644 index 599c3d2..0000000 --- a/Sources/HummingbirdRedis/RedisConnectionPoolService.swift +++ /dev/null @@ -1,166 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2021-2023 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Foundation -import Hummingbird -import Logging -import NIOCore -@preconcurrency import RediStack -import ServiceLifecycle - -/// Wrapper for RedisConnectionPool that conforms to ServiceLifecycle Service -public struct RedisConnectionPoolService: Service, @unchecked Sendable { - /// Initialize RedisConnectionPoolService - public init( - _ config: RedisConfiguration, - eventLoopGroupProvider: EventLoopGroupProvider = .singleton, - logger: Logger - ) { - let configuration: RedisConnectionPool.Configuration = .init(config, logger: logger) - let eventLoop = eventLoopGroupProvider.eventLoopGroup.any() - self.pool = .init(configuration: configuration, boundEventLoop: eventLoop) - } - - public let pool: RedisConnectionPool - - @inlinable - public func run() async throws { - /// Ignore cancellation error - try? await gracefulShutdown() - try await self.close() - } - - /// Starts the connection pool. - /// - /// This method is safe to call multiple times. - /// - Parameter logger: An optional logger to use for any log statements generated while starting up the pool. - /// If one is not provided, the pool will use its default logger. - @inlinable - public func activate(logger: Logger? = nil) { - self.pool.activate(logger: logger) - } - - /// Closes all connections in the pool and deactivates the pool from creating new connections. - /// - /// This method is safe to call multiple times. - @inlinable - public func close() async throws { - let promise = self.eventLoop.makePromise(of: Void.self) - self.pool.close(promise: promise) - return try await promise.futureResult.get() - } -} - -extension RedisConnectionPoolService { - /// A unique identifer to represent this connection. - @inlinable - public var id: UUID { self.pool.id } - /// The count of connections that are active and available for use. - @inlinable - public var availableConnectionCount: Int { self.pool.availableConnectionCount } - /// The number of connections that have been handed out and are in active use. - @inlinable - public var leasedConnectionCount: Int { self.pool.leasedConnectionCount } - /// Provides limited exclusive access to a connection to be used in a user-defined specialized closure of operations. - /// - Warning: Attempting to create PubSub subscriptions with connections leased in the closure will result in a failed `NIO.EventLoopFuture`. - /// - /// `RedisConnectionPool` manages PubSub state and requires exclusive control over creating PubSub subscriptions. - /// - Important: This connection **MUST NOT** be stored outside of the closure. It is only available exclusively within the closure. - /// - /// All operations should be done inside the closure as chained `NIO.EventLoopFuture` callbacks. - /// - /// For example: - /// ```swift - /// let countFuture = pool.leaseConnection { - /// let client = $0.logging(to: myLogger) - /// return client.authorize(with: userPassword) - /// .flatMap { connection.select(database: userDatabase) } - /// .flatMap { connection.increment(counterKey) } - /// } - /// ``` - /// - Warning: Some commands change the state of the connection that are not tracked client-side, - /// and will not be automatically reset when the connection is returned to the pool. - /// - /// When the connection is reused from the pool, it will retain this state and may affect future commands executed with it. - /// - /// For example, if `select(database:)` is used, all future commands made with this connection will be against the selected database. - /// - /// To protect against future issues, make sure the final commands executed are to reset the connection to it's previous known state. - /// - Parameter operation: A closure that receives exclusive access to the provided `RedisConnection` for the lifetime of the closure for specialized Redis command chains. - /// - Returns: A `NIO.EventLoopFuture` that resolves the value of the `NIO.EventLoopFuture` in the provided closure operation. - @inlinable - public func leaseConnection(_ operation: @escaping (RedisConnection) -> EventLoopFuture) -> EventLoopFuture { - self.pool.leaseConnection(operation) - } - - /// Updates the list of valid connection addresses. - /// - Warning: This will replace any previously set list of addresses. - /// - Note: This does not invalidate existing connections: as long as those connections continue to stay up, they will be kept by - /// this client. - /// - /// However, no new connections will be made to any endpoint that is not in `newAddresses`. - /// - Parameters: - /// - newAddresses: The new addresses to connect to in future connections. - /// - logger: An optional logger to use for any log statements generated while updating the target addresses. - /// If one is not provided, the pool will use its default logger. - @inlinable - public func updateConnectionAddresses(_ newAddresses: [SocketAddress], logger: Logger? = nil) { - self.pool.updateConnectionAddresses(newAddresses) - } -} - -extension RedisConnectionPoolService: RedisClient { - @inlinable - public var eventLoop: NIOCore.EventLoop { self.pool.eventLoop } - - @inlinable - public func send(command: String, with arguments: [RediStack.RESPValue]) -> NIOCore.EventLoopFuture { - self.pool.send(command: command, with: arguments) - } - - @inlinable - public func logging(to logger: Logging.Logger) -> RediStack.RedisClient { - self.pool.logging(to: logger) - } - - @inlinable - public func subscribe( - to channels: [RedisChannelName], - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? - ) -> EventLoopFuture { - self.pool.subscribe(to: channels, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) - } - - @inlinable - public func psubscribe( - to patterns: [String], - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? - ) -> EventLoopFuture { - self.pool.psubscribe(to: patterns, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) - } - - @inlinable - public func unsubscribe(from channels: [RediStack.RedisChannelName]) -> NIOCore.EventLoopFuture { - self.pool.unsubscribe(from: channels) - } - - @inlinable - public func punsubscribe(from patterns: [String]) -> NIOCore.EventLoopFuture { - self.pool.punsubscribe(from: patterns) - } -} diff --git a/Sources/HummingbirdValkey/Persist+Valkey.swift b/Sources/HummingbirdValkey/Persist+Valkey.swift new file mode 100644 index 0000000..0d593c1 --- /dev/null +++ b/Sources/HummingbirdValkey/Persist+Valkey.swift @@ -0,0 +1,80 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Hummingbird server framework project +// +// Copyright (c) 2021-2022 the Hummingbird authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Hummingbird +import Valkey + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// Valkey/Redis driver for persist system for storing persistent cross request key/value pairs +public struct ValkeyPersistDriver: PersistDriver { + let valkey: Client + + public init(client: Client) { + self.valkey = client + } + + /// create new key with value. If key already exist throw `PersistError.duplicate` error + public func create(key: String, value: some Codable, expires: Duration?) async throws { + let expiration: SET.Expiration? = expires.map { .milliseconds(Int($0 / .milliseconds(1))) } + let jsonBuffer = try ByteBuffer(bytes: JSONEncoder().encode(value)) + if try await self.valkey.set(.init(key), value: jsonBuffer, condition: .nx, expiration: expiration) != nil { + return + } else { + throw PersistError.duplicate + } + } + + /// set value for key. If value already exists overwrite it + public func set(key: String, value: some Codable, expires: Duration?) async throws { + let jsonBuffer = try ByteBuffer(bytes: JSONEncoder().encode(value)) + if let expires { + let expiration = SET.Expiration.milliseconds(Int(expires / .milliseconds(1))) + _ = try await self.valkey.set( + .init(key), + value: jsonBuffer, + condition: .none, + expiration: expiration + ) + } else { + _ = try await self.valkey.set( + .init(key), + value: jsonBuffer, + condition: .none, + expiration: .keepttl + ) + } + } + + /// get value for key + public func get(key: String, as object: Object.Type) async throws -> Object? { + do { + if let value = try await self.valkey.get(.init(key)) { + return try JSONDecoder().decode(Object.self, from: value) + } + return nil + } catch is DecodingError { + throw PersistError.invalidConversion + } + } + + /// remove key + public func remove(key: String) async throws { + _ = try await self.valkey.del(keys: [.init(key)]) + } +} diff --git a/Tests/HummingbirdRedisTests/RedisTests.swift b/Tests/HummingbirdRedisTests/RedisTests.swift deleted file mode 100644 index 982ab26..0000000 --- a/Tests/HummingbirdRedisTests/RedisTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2021-2021 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Hummingbird -import HummingbirdTesting -import Logging -import NIOPosix -@preconcurrency import RediStack -import XCTest - -@testable import HummingbirdRedis - -final class HummingbirdRedisTests: XCTestCase { - static let env = Environment() - static let redisHostname = env.get("REDIS_HOSTNAME") ?? "localhost" - - func testConnectionPoolService() async throws { - let redis = try RedisConnectionPoolService( - .init(hostname: Self.redisHostname, port: 6379), - logger: Logger(label: "Redis") - ) - - let info = try await redis.send(command: "INFO").get() - XCTAssertEqual(info.string?.contains("redis_version"), true) - - try await redis.close() - } - - func testSubscribe() async throws { - let expectation = XCTestExpectation(description: "Waiting on subscription") - expectation.expectedFulfillmentCount = 1 - let redis = try RedisConnectionPoolService( - .init(hostname: Self.redisHostname, port: 6379), - logger: Logger(label: "Redis") - ) - let redis2 = try RedisConnectionPoolService( - .init(hostname: Self.redisHostname, port: 6379), - logger: Logger(label: "Redis") - ) - - _ = try await redis.subscribe(to: ["channel"]) { _, value in - XCTAssertEqual(value, .init(from: "hello")) - expectation.fulfill() - }.get() - _ = try await redis2.publish("hello", to: "channel").get() - await fulfillment(of: [expectation], timeout: 5) - _ = try await redis.unsubscribe(from: ["channel"]).get() - try await redis.close() - try await redis2.close() - } - - func testRouteHandler() async throws { - let redis = try RedisConnectionPoolService( - .init(hostname: Self.redisHostname, port: 6379), - logger: Logger(label: "Redis") - ) - let router = Router() - router.get("redis") { _, _ in - try await redis.send(command: "INFO").map(\.description).get() - } - var app = Application(responder: router.buildResponder()) - app.addServices(redis) - try await app.test(.live) { client in - try await client.execute(uri: "/redis", method: .get) { response in - var body = try XCTUnwrap(response.body) - XCTAssertEqual(body.readString(length: body.readableBytes)?.contains("redis_version"), true) - } - } - } -} diff --git a/Tests/HummingbirdRedisTests/PersistTests.swift b/Tests/HummingbirdValkeyTests/PersistTests.swift similarity index 91% rename from Tests/HummingbirdRedisTests/PersistTests.swift rename to Tests/HummingbirdValkeyTests/PersistTests.swift index 53f6e44..e19504d 100644 --- a/Tests/HummingbirdRedisTests/PersistTests.swift +++ b/Tests/HummingbirdValkeyTests/PersistTests.swift @@ -13,22 +13,33 @@ //===----------------------------------------------------------------------===// import Hummingbird -import HummingbirdRedis import HummingbirdTesting +import HummingbirdValkey import Logging -@preconcurrency import RediStack +import Valkey import XCTest final class PersistTests: XCTestCase { - static let redisHostname = Environment().get("REDIS_HOSTNAME") ?? "localhost" + static let valkeyHostname = Environment().get("VALKEY_HOSTNAME") ?? "localhost" func createApplication(_ updateRouter: (Router, PersistDriver) -> Void = { _, _ in }) throws -> some ApplicationProtocol { let router = Router() - let redisConnectionPool = try RedisConnectionPoolService( - .init(hostname: Self.redisHostname, port: 6379), - logger: Logger(label: "Redis") - ) - let persist = RedisPersistDriver(redisConnectionPoolService: redisConnectionPool) + var logger = Logger(label: "Valkey") + logger.logLevel = .debug + let valkeyClient = ValkeyClient(.hostname(Self.valkeyHostname, port: 6379), logger: logger) + let persist = ValkeyPersistDriver(client: valkeyClient) + + router.get("valkey/{key}") { request, context -> String? in + let key = try context.parameters.require("key") + return try await persist.get(key: .init(key), as: String.self) + } + + router.put("valkey/{key}") { request, context in + let key = try context.parameters.require("key") + let value = try request.uri.queryParameters.require("value") + try await persist.set(key: key, value: value) + return HTTPResponse.Status.ok + } router.put("/persist/:tag") { request, context -> HTTPResponse.Status in let buffer = try await request.body.collect(upTo: .max) @@ -54,7 +65,7 @@ final class PersistTests: XCTestCase { } updateRouter(router, persist) var app = Application(responder: router.buildResponder()) - app.addServices(redisConnectionPool, persist) + app.addServices(valkeyClient, persist) return app } From 9f94d0dd85517193de14a270979b7ac6ac9d4487 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Wed, 6 Aug 2025 14:23:38 +0100 Subject: [PATCH 2/6] 2025 --- Sources/HummingbirdValkey/Persist+Valkey.swift | 2 +- Tests/HummingbirdValkeyTests/PersistTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/HummingbirdValkey/Persist+Valkey.swift b/Sources/HummingbirdValkey/Persist+Valkey.swift index 0d593c1..28a04ce 100644 --- a/Sources/HummingbirdValkey/Persist+Valkey.swift +++ b/Sources/HummingbirdValkey/Persist+Valkey.swift @@ -2,7 +2,7 @@ // // This source file is part of the Hummingbird server framework project // -// Copyright (c) 2021-2022 the Hummingbird authors +// Copyright (c) 2021-2025 the Hummingbird authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information diff --git a/Tests/HummingbirdValkeyTests/PersistTests.swift b/Tests/HummingbirdValkeyTests/PersistTests.swift index e19504d..29f613e 100644 --- a/Tests/HummingbirdValkeyTests/PersistTests.swift +++ b/Tests/HummingbirdValkeyTests/PersistTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the Hummingbird server framework project // -// Copyright (c) 2021-2021 the Hummingbird authors +// Copyright (c) 2021-2025 the Hummingbird authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information From 1cfcfd62f854082a67a7a3bf9276891bebf38a60 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Wed, 6 Aug 2025 14:36:38 +0100 Subject: [PATCH 3/6] Update CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9b15fb..0cbba0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 15 strategy: matrix: - image: ["swift:5.10", "swift:6.0", "swift:6.1", "swiftlang/swift:nightly-6.2-noble"] + image: ["swift:6.1", "swiftlang/swift:nightly-6.2-noble"] container: image: ${{ matrix.image }} services: From fe44e95b17ad1861e7d6b20fbbfc94cf8269a845 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Thu, 11 Sep 2025 17:00:21 +0100 Subject: [PATCH 4/6] No point converting to ByteBuffer when you can use Data --- Package.swift | 2 +- Sources/HummingbirdValkey/Persist+Valkey.swift | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Package.swift b/Package.swift index 0c640c3..935f585 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.5.0"), - .package(url: "https://github.com/valkey-io/valkey-swift.git", from: "0.1.0"), + .package(url: "https://github.com/valkey-io/valkey-swift.git", from: "0.2.0"), ], targets: [ .target( diff --git a/Sources/HummingbirdValkey/Persist+Valkey.swift b/Sources/HummingbirdValkey/Persist+Valkey.swift index 28a04ce..daa34d6 100644 --- a/Sources/HummingbirdValkey/Persist+Valkey.swift +++ b/Sources/HummingbirdValkey/Persist+Valkey.swift @@ -31,8 +31,8 @@ public struct ValkeyPersistDriver: Pers /// create new key with value. If key already exist throw `PersistError.duplicate` error public func create(key: String, value: some Codable, expires: Duration?) async throws { - let expiration: SET.Expiration? = expires.map { .milliseconds(Int($0 / .milliseconds(1))) } - let jsonBuffer = try ByteBuffer(bytes: JSONEncoder().encode(value)) + let expiration: SET.Expiration? = expires.map { .milliseconds(Int($0 / .milliseconds(1))) } + let jsonBuffer = try JSONEncoder().encode(value) if try await self.valkey.set(.init(key), value: jsonBuffer, condition: .nx, expiration: expiration) != nil { return } else { @@ -42,14 +42,13 @@ public struct ValkeyPersistDriver: Pers /// set value for key. If value already exists overwrite it public func set(key: String, value: some Codable, expires: Duration?) async throws { - let jsonBuffer = try ByteBuffer(bytes: JSONEncoder().encode(value)) + let jsonBuffer = try JSONEncoder().encode(value) if let expires { - let expiration = SET.Expiration.milliseconds(Int(expires / .milliseconds(1))) _ = try await self.valkey.set( .init(key), value: jsonBuffer, condition: .none, - expiration: expiration + expiration: .milliseconds(Int(expires / .milliseconds(1))) ) } else { _ = try await self.valkey.set( From f4ab0e6a231451d8bfed03df43df6bec4a5ac178 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Fri, 12 Sep 2025 10:54:18 +0100 Subject: [PATCH 5/6] Add JSONDecoder ByteBuffer --- .../Codable+ByteBuffer.swift | 124 ++++++++++++++++++ .../HummingbirdValkey/Persist+Valkey.swift | 2 +- 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 Sources/HummingbirdValkey/Codable+ByteBuffer.swift diff --git a/Sources/HummingbirdValkey/Codable+ByteBuffer.swift b/Sources/HummingbirdValkey/Codable+ByteBuffer.swift new file mode 100644 index 0000000..e25926c --- /dev/null +++ b/Sources/HummingbirdValkey/Codable+ByteBuffer.swift @@ -0,0 +1,124 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2019-2021 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +extension ByteBuffer { + /// Controls how bytes are transferred between `ByteBuffer` and other storage types. + @usableFromInline + enum _ByteTransferStrategy: Sendable { + /// Force a copy of the bytes. + case copy + + /// Do not copy the bytes if at all possible. + case noCopy + + /// Use a heuristic to decide whether to copy the bytes or not. + case automatic + } + + /// Return `length` bytes starting at `index` and return the result as `Data`. This will not change the reader index. + /// The selected bytes must be readable or else `nil` will be returned. + /// + /// - Parameters: + /// - index: The starting index of the bytes of interest into the `ByteBuffer` + /// - length: The number of bytes of interest + /// - byteTransferStrategy: Controls how to transfer the bytes. See `ByteTransferStrategy` for an explanation + /// of the options. + /// - Returns: A `Data` value containing the bytes of interest or `nil` if the selected bytes are not readable. + @usableFromInline + func _getData(at index: Int, length: Int, byteTransferStrategy: _ByteTransferStrategy) -> Data? { + let index = index - self.readerIndex + guard index >= 0 && length >= 0 && index <= self.readableBytes - length else { + return nil + } + let doCopy: Bool + switch byteTransferStrategy { + case .copy: + doCopy = true + case .noCopy: + doCopy = false + case .automatic: + doCopy = length <= 256 * 1024 + } + + return self.withUnsafeReadableBytesWithStorageManagement { ptr, storageRef in + if doCopy { + return Data( + bytes: UnsafeMutableRawPointer(mutating: ptr.baseAddress!.advanced(by: index)), + count: Int(length) + ) + } else { + let storage = storageRef.takeUnretainedValue() + return Data( + bytesNoCopy: UnsafeMutableRawPointer(mutating: ptr.baseAddress!.advanced(by: index)), + count: Int(length), + deallocator: .custom { _, _ in withExtendedLifetime(storage) {} } + ) + } + } + } + + /// Attempts to decode the `length` bytes from `index` using the `JSONDecoder` `decoder` as `T`. + /// + /// - Parameters: + /// - type: The type type that is attempted to be decoded. + /// - decoder: The `JSONDecoder` that is used for the decoding. + /// - index: The index of the first byte to decode. + /// - length: The number of bytes to decode. + /// - Returns: The decoded value if successful or `nil` if there are not enough readable bytes available. + @usableFromInline + func _getJSONDecodable( + _ type: T.Type, + decoder: JSONDecoder = JSONDecoder(), + at index: Int, + length: Int + ) throws -> T? { + guard let data = self._getData(at: index, length: length, byteTransferStrategy: .noCopy) else { + return nil + } + return try decoder.decode(T.self, from: data) + } +} + +extension JSONDecoder { + /// Returns a value of the type you specify, decoded from a JSON object inside the readable bytes of a `ByteBuffer`. + /// + /// If the `ByteBuffer` does not contain valid JSON, this method throws the + /// `DecodingError.dataCorrupted(_:)` error. If a value within the JSON + /// fails to decode, this method throws the corresponding error. + /// + /// - Note: The provided `ByteBuffer` remains unchanged, neither the `readerIndex` nor the `writerIndex` will move. + /// If you would like the `readerIndex` to move, consider using `ByteBuffer.readJSONDecodable(_:length:)`. + /// + /// - Parameters: + /// - type: The type of the value to decode from the supplied JSON object. + /// - buffer: The `ByteBuffer` that contains JSON object to decode. + /// - Returns: The decoded object. + @usableFromInline + func _decode(_ type: T.Type, from buffer: ByteBuffer) throws -> T { + try buffer.getJSONDecodable( + T.self, + decoder: self, + at: buffer.readerIndex, + length: buffer.readableBytes + )! // must work, enough readable bytes// must work, enough readable bytes + } +} diff --git a/Sources/HummingbirdValkey/Persist+Valkey.swift b/Sources/HummingbirdValkey/Persist+Valkey.swift index daa34d6..c356683 100644 --- a/Sources/HummingbirdValkey/Persist+Valkey.swift +++ b/Sources/HummingbirdValkey/Persist+Valkey.swift @@ -64,7 +64,7 @@ public struct ValkeyPersistDriver: Pers public func get(key: String, as object: Object.Type) async throws -> Object? { do { if let value = try await self.valkey.get(.init(key)) { - return try JSONDecoder().decode(Object.self, from: value) + return try JSONDecoder()._decode(Object.self, from: value) } return nil } catch is DecodingError { From a21817e48d164da509e3d20538b37d4ef119a6a8 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Fri, 12 Sep 2025 14:58:58 +0000 Subject: [PATCH 6/6] Fix linux, remove products from Package.swift --- Notice.txt | 20 +++++++++++++++++++ Package.swift | 13 +++++++++--- .../Codable+ByteBuffer.swift | 2 +- .../HummingbirdValkey/Persist+Valkey.swift | 1 + .../HummingbirdValkeyTests/PersistTests.swift | 1 + 5 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 Notice.txt diff --git a/Notice.txt b/Notice.txt new file mode 100644 index 0000000..aeb43d6 --- /dev/null +++ b/Notice.txt @@ -0,0 +1,20 @@ +##===----------------------------------------------------------------------===## +## +## This source file is part of the Hummingbird server framework project +## +## Copyright (c) 2021-2025 the Hummingbird authors +## Licensed under Apache License v2.0 +## +## See LICENSE.txt for license information +## See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors +## +## SPDX-License-Identifier: Apache-2.0 +## +##===----------------------------------------------------------------------===## + +This product contains ByteBuffer Codable support from apple/swift-nio + +* LICENSE (Apache License 2.0): + * https://github.com/apple/swift-nio/blob/main/LICENSE.txt +* HOMEPAGE + * https://github.com/apple/swift-nio \ No newline at end of file diff --git a/Package.swift b/Package.swift index 935f585..e7e0580 100644 --- a/Package.swift +++ b/Package.swift @@ -3,9 +3,14 @@ import PackageDescription +let defaultSwiftSettings: [SwiftSetting] = + [ + .swiftLanguageMode(.v6), + .enableExperimentalFeature("AvailabilityMacro=hbValkey 1.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"), + ] + let package = Package( name: "hummingbird-valkey", - platforms: [.macOS(.v15), .iOS(.v18), .tvOS(.v18)], products: [ .library(name: "HummingbirdValkey", targets: ["HummingbirdValkey"]) ], @@ -19,7 +24,8 @@ let package = Package( dependencies: [ .product(name: "Hummingbird", package: "hummingbird"), .product(name: "Valkey", package: "valkey-swift"), - ] + ], + swiftSettings: defaultSwiftSettings ), .testTarget( name: "HummingbirdValkeyTests", @@ -27,7 +33,8 @@ let package = Package( .byName(name: "HummingbirdValkey"), .product(name: "Hummingbird", package: "hummingbird"), .product(name: "HummingbirdTesting", package: "hummingbird"), - ] + ], + swiftSettings: defaultSwiftSettings ), ] ) diff --git a/Sources/HummingbirdValkey/Codable+ByteBuffer.swift b/Sources/HummingbirdValkey/Codable+ByteBuffer.swift index e25926c..f499ad3 100644 --- a/Sources/HummingbirdValkey/Codable+ByteBuffer.swift +++ b/Sources/HummingbirdValkey/Codable+ByteBuffer.swift @@ -114,7 +114,7 @@ extension JSONDecoder { /// - Returns: The decoded object. @usableFromInline func _decode(_ type: T.Type, from buffer: ByteBuffer) throws -> T { - try buffer.getJSONDecodable( + try buffer._getJSONDecodable( T.self, decoder: self, at: buffer.readerIndex, diff --git a/Sources/HummingbirdValkey/Persist+Valkey.swift b/Sources/HummingbirdValkey/Persist+Valkey.swift index c356683..d892a65 100644 --- a/Sources/HummingbirdValkey/Persist+Valkey.swift +++ b/Sources/HummingbirdValkey/Persist+Valkey.swift @@ -22,6 +22,7 @@ import Foundation #endif /// Valkey/Redis driver for persist system for storing persistent cross request key/value pairs +@available(hbValkey 1.0, *) public struct ValkeyPersistDriver: PersistDriver { let valkey: Client diff --git a/Tests/HummingbirdValkeyTests/PersistTests.swift b/Tests/HummingbirdValkeyTests/PersistTests.swift index 29f613e..8dec15a 100644 --- a/Tests/HummingbirdValkeyTests/PersistTests.swift +++ b/Tests/HummingbirdValkeyTests/PersistTests.swift @@ -19,6 +19,7 @@ import Logging import Valkey import XCTest +@available(hbValkey 1.0, *) final class PersistTests: XCTestCase { static let valkeyHostname = Environment().get("VALKEY_HOSTNAME") ?? "localhost"