-
-
Notifications
You must be signed in to change notification settings - Fork 50
Store current reference cache in redis #3582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1db50f2
65f8335
87bdbf8
a95aba9
1c5ca6d
1aa5b21
d8f22ca
fbcb232
a61478a
3edf3e5
90e3a50
1a975b2
519b256
8efbace
6ba350c
8eead6b
1e40561
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
import Dependencies | ||
import DependenciesMacros | ||
|
||
|
||
@DependencyClient | ||
struct CurrentReferenceCacheClient { | ||
var set: @Sendable (_ owner: String, _ repository: String, _ reference: String?) async -> Void | ||
var get: @Sendable (_ owner: String, _ repository: String) async -> String? | ||
} | ||
|
||
|
||
extension CurrentReferenceCacheClient: DependencyKey { | ||
static let timeToLive: Duration = .seconds(5*60) | ||
|
||
static var liveValue: CurrentReferenceCacheClient { | ||
.init( | ||
set: { owner, repository, reference async in | ||
@Dependency(\.redis) var redis | ||
await redis.set(key: getKey(owner: owner, repository: repository), | ||
value: reference, | ||
expiresIn: timeToLive) | ||
}, | ||
get: { owner, repository in | ||
@Dependency(\.redis) var redis | ||
return await redis.get(key: getKey(owner: owner, repository: repository)) | ||
} | ||
) | ||
} | ||
|
||
static func getKey(owner: String, repository: String) -> String { | ||
"\(owner)/\(repository)".lowercased() | ||
} | ||
} | ||
|
||
|
||
extension CurrentReferenceCacheClient: TestDependencyKey { | ||
static var testValue: Self { Self() } | ||
} | ||
|
||
|
||
extension DependencyValues { | ||
var currentReferenceCache: CurrentReferenceCacheClient { | ||
get { self[CurrentReferenceCacheClient.self] } | ||
set { self[CurrentReferenceCacheClient.self] = newValue } | ||
} | ||
} | ||
|
||
|
||
#if DEBUG | ||
extension CurrentReferenceCacheClient { | ||
static var disabled: Self { | ||
.init(set: { _, _, _ in }, get: { _, _ in nil }) | ||
} | ||
|
||
static var inMemory: Self { | ||
.init( | ||
set: { owner, repository, reference in | ||
_cache.withValue { | ||
$0[getKey(owner: owner, repository: repository)] = reference | ||
} | ||
}, | ||
get: { owner, repository in | ||
_cache.withValue { | ||
$0[getKey(owner: owner, repository: repository)] | ||
} | ||
} | ||
) | ||
} | ||
|
||
nonisolated(unsafe) static var _cache = LockIsolated<[String: String]>([:]) | ||
} | ||
#endif |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
import NIOCore | ||
@preconcurrency import RediStack | ||
import Dependencies | ||
import DependenciesMacros | ||
|
||
|
||
@DependencyClient | ||
struct RedisClient { | ||
var set: @Sendable (_ key: String, _ value: String?, Duration?) async -> Void | ||
var get: @Sendable (_ key: String) async -> String? | ||
heckj marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
|
||
extension RedisClient { | ||
func set(key: String, value: String?, expiresIn: Duration? = nil) async { | ||
await set(key: key, value: value, expiresIn) | ||
} | ||
} | ||
|
||
|
||
extension RedisClient: DependencyKey { | ||
static var liveValue: RedisClient { | ||
.init( | ||
set: { key, value, expiresIn in | ||
await Redis.shared?.set(key: key, value: value, expiresIn: expiresIn) | ||
}, | ||
get: { key in await Redis.shared?.get(key: key) } | ||
) | ||
} | ||
} | ||
|
||
|
||
extension RedisClient: TestDependencyKey { | ||
static var testValue: Self { Self() } | ||
} | ||
|
||
|
||
extension DependencyValues { | ||
var redis: RedisClient { | ||
get { self[RedisClient.self] } | ||
set { self[RedisClient.self] = newValue } | ||
} | ||
} | ||
|
||
|
||
#if DEBUG | ||
extension RedisClient { | ||
static var disabled: Self { | ||
.init(set: { _, _, _ in }, get: { _ in nil }) | ||
} | ||
} | ||
#endif | ||
|
||
|
||
private actor Redis { | ||
var client: RediStack.RedisClient | ||
static private var task: Task<Redis?, Never>? | ||
|
||
static var shared: Redis? { | ||
get async { | ||
if let task { | ||
return await task.value | ||
} | ||
let task = Task<Redis?, Never> { | ||
var attemptsLeft = maxConnectionAttempts | ||
while attemptsLeft > 0 { | ||
do { | ||
return try await Redis() | ||
} catch { | ||
attemptsLeft -= 1 | ||
Current.logger().warning("Redis connection failed, \(attemptsLeft) attempts left. Error: \(error)") | ||
try? await Task.sleep(for: .milliseconds(500)) | ||
} | ||
} | ||
return nil | ||
} | ||
self.task = task | ||
return await task.value | ||
} | ||
} | ||
|
||
private init() async throws { | ||
let connection = RedisConnection.make( | ||
configuration: try .init(hostname: Redis.hostname), | ||
boundEventLoop: NIOSingletons.posixEventLoopGroup.any() | ||
) | ||
self.client = try await connection.get() | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please give this whole In particular, Unfortunately, there's not much I can see us do around this in terms of testing other than verifying that writing to and reading from Redis works on |
||
|
||
// This hostname has to match the redir service name in app.yml. | ||
static let hostname = "redis" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could make this configurable but there's not much point as it's not really dependent on anything, just a static name defined in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was the only bit that I thought we might want to tweak and make into an environment variable, primarily so that a purely local development setup could reference LOCALHOST:6379 and had redis running in a container. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've simply set |
||
static let maxConnectionAttempts = 3 | ||
|
||
func set(key: String, value: String?, expiresIn: Duration?) async -> Void { | ||
if let value { | ||
let buffer = ByteBuffer(string: value) | ||
let value = RESPValue.bulkString(buffer) | ||
if let expiresIn { | ||
let ttl = Int(expiresIn.components.seconds) | ||
try? await client.setex(.init(key), to: value, expirationInSeconds: ttl).get() | ||
} else { | ||
try? await client.set(.init(key), to: value).get() | ||
} | ||
} else { | ||
_ = try? await client.delete([.init(key)]).get() | ||
} | ||
} | ||
|
||
func get(key: String) async -> String? { | ||
return try? await client.get(.init(key)).map(\.string).get() | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@heckj , this is how you'd interface with Redis once this lands. We wouldn't be using
app.redis
at all but rather the privateRedis
actor via theRedisClient
dependency, replacing your PR #3580.The only thing this doesn't cover is setup for local use of Redis, i.e. during development. It should be easy to add though, either by making the hostname configurable or just setting a
redis
alias in/etc/hosts
on the local machine. The latter is certainly easiest and fastest to get going.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correction, I just realised I misread your PR #3580. I was focused on the part
but really the point of it is to add a bringup for a local Redis and the hostname config.