Skip to content

Commit bdfff3e

Browse files
Merge pull request #3582 from SwiftPackageIndex/store-CurrentReferenceCache-in-redis
Store current reference cache in redis
2 parents c6ef0b3 + 1e40561 commit bdfff3e

File tree

9 files changed

+228
-67
lines changed

9 files changed

+228
-67
lines changed

Package.resolved

Lines changed: 1 addition & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ let package = Package(
2727
.library(name: "S3Store", targets: ["S3Store"]),
2828
],
2929
dependencies: [
30-
.package(url: "https://github.com/0xLeif/Cache.git", from: "2.1.0"),
3130
.package(url: "https://github.com/JohnSundell/Ink.git", from: "0.5.1"),
3231
.package(url: "https://github.com/swift-server/swift-prometheus.git", from: "1.0.0"),
3332
.package(url: "https://github.com/SwiftPackageIndex/Plot.git", branch: "main"),
@@ -63,7 +62,6 @@ let package = Package(
6362
.product(name: "SPIManifest", package: "SPIManifest"),
6463
.product(name: "SemanticVersion", package: "SemanticVersion"),
6564
.product(name: "SwiftSoup", package: "SwiftSoup"),
66-
.product(name: "Cache", package: "cache"),
6765
.product(name: "CanonicalPackageURL", package: "CanonicalPackageURL"),
6866
.product(name: "CustomDump", package: "swift-custom-dump"),
6967
.product(name: "Dependencies", package: "swift-dependencies"),

Sources/App/Core/CurrentReferenceCache.swift

Lines changed: 0 additions & 32 deletions
This file was deleted.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Dependencies
16+
import DependenciesMacros
17+
18+
19+
@DependencyClient
20+
struct CurrentReferenceCacheClient {
21+
var set: @Sendable (_ owner: String, _ repository: String, _ reference: String?) async -> Void
22+
var get: @Sendable (_ owner: String, _ repository: String) async -> String?
23+
}
24+
25+
26+
extension CurrentReferenceCacheClient: DependencyKey {
27+
static let timeToLive: Duration = .seconds(5*60)
28+
29+
static var liveValue: CurrentReferenceCacheClient {
30+
.init(
31+
set: { owner, repository, reference async in
32+
@Dependency(\.redis) var redis
33+
await redis.set(key: getKey(owner: owner, repository: repository),
34+
value: reference,
35+
expiresIn: timeToLive)
36+
},
37+
get: { owner, repository in
38+
@Dependency(\.redis) var redis
39+
return await redis.get(key: getKey(owner: owner, repository: repository))
40+
}
41+
)
42+
}
43+
44+
static func getKey(owner: String, repository: String) -> String {
45+
"\(owner)/\(repository)".lowercased()
46+
}
47+
}
48+
49+
50+
extension CurrentReferenceCacheClient: TestDependencyKey {
51+
static var testValue: Self { Self() }
52+
}
53+
54+
55+
extension DependencyValues {
56+
var currentReferenceCache: CurrentReferenceCacheClient {
57+
get { self[CurrentReferenceCacheClient.self] }
58+
set { self[CurrentReferenceCacheClient.self] = newValue }
59+
}
60+
}
61+
62+
63+
#if DEBUG
64+
extension CurrentReferenceCacheClient {
65+
static var disabled: Self {
66+
.init(set: { _, _, _ in }, get: { _, _ in nil })
67+
}
68+
69+
static var inMemory: Self {
70+
.init(
71+
set: { owner, repository, reference in
72+
_cache.withValue {
73+
$0[getKey(owner: owner, repository: repository)] = reference
74+
}
75+
},
76+
get: { owner, repository in
77+
_cache.withValue {
78+
$0[getKey(owner: owner, repository: repository)]
79+
}
80+
}
81+
)
82+
}
83+
84+
nonisolated(unsafe) static var _cache = LockIsolated<[String: String]>([:])
85+
}
86+
#endif

Sources/App/Core/Dependencies/EnvironmentClient.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ struct EnvironmentClient {
3939
var collectionSigningCertificateChain: @Sendable () -> [URL] = { XCTFail("collectionSigningCertificateChain"); return [] }
4040
var collectionSigningPrivateKey: @Sendable () -> Data?
4141
var current: @Sendable () -> Environment = { XCTFail("current"); return .development }
42-
var currentReferenceCache: @Sendable () -> CurrentReferenceCache?
4342
var dbId: @Sendable () -> String?
4443
var mastodonCredentials: @Sendable () -> Mastodon.Credentials?
4544
var random: @Sendable (_ range: ClosedRange<Double>) -> Double = { XCTFail("random"); return Double.random(in: $0) }
@@ -102,7 +101,6 @@ extension EnvironmentClient: DependencyKey {
102101
Environment.get("COLLECTION_SIGNING_PRIVATE_KEY").map { Data($0.utf8) }
103102
},
104103
current: { (try? Environment.detect()) ?? .development },
105-
currentReferenceCache: { .live },
106104
dbId: { Environment.get("DATABASE_ID") },
107105
mastodonCredentials: {
108106
Environment.get("MASTODON_ACCESS_TOKEN")
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import NIOCore
16+
@preconcurrency import RediStack
17+
import Dependencies
18+
import DependenciesMacros
19+
20+
21+
@DependencyClient
22+
struct RedisClient {
23+
var set: @Sendable (_ key: String, _ value: String?, Duration?) async -> Void
24+
var get: @Sendable (_ key: String) async -> String?
25+
}
26+
27+
28+
extension RedisClient {
29+
func set(key: String, value: String?, expiresIn: Duration? = nil) async {
30+
await set(key: key, value: value, expiresIn)
31+
}
32+
}
33+
34+
35+
extension RedisClient: DependencyKey {
36+
static var liveValue: RedisClient {
37+
.init(
38+
set: { key, value, expiresIn in
39+
await Redis.shared?.set(key: key, value: value, expiresIn: expiresIn)
40+
},
41+
get: { key in await Redis.shared?.get(key: key) }
42+
)
43+
}
44+
}
45+
46+
47+
extension RedisClient: TestDependencyKey {
48+
static var testValue: Self { Self() }
49+
}
50+
51+
52+
extension DependencyValues {
53+
var redis: RedisClient {
54+
get { self[RedisClient.self] }
55+
set { self[RedisClient.self] = newValue }
56+
}
57+
}
58+
59+
60+
#if DEBUG
61+
extension RedisClient {
62+
static var disabled: Self {
63+
.init(set: { _, _, _ in }, get: { _ in nil })
64+
}
65+
}
66+
#endif
67+
68+
69+
private actor Redis {
70+
var client: RediStack.RedisClient
71+
static private var task: Task<Redis?, Never>?
72+
73+
static var shared: Redis? {
74+
get async {
75+
if let task {
76+
return await task.value
77+
}
78+
let task = Task<Redis?, Never> {
79+
var attemptsLeft = maxConnectionAttempts
80+
while attemptsLeft > 0 {
81+
do {
82+
return try await Redis()
83+
} catch {
84+
attemptsLeft -= 1
85+
Current.logger().warning("Redis connection failed, \(attemptsLeft) attempts left. Error: \(error)")
86+
try? await Task.sleep(for: .milliseconds(500))
87+
}
88+
}
89+
return nil
90+
}
91+
self.task = task
92+
return await task.value
93+
}
94+
}
95+
96+
private init() async throws {
97+
let connection = RedisConnection.make(
98+
configuration: try .init(hostname: Redis.hostname),
99+
boundEventLoop: NIOSingletons.posixEventLoopGroup.any()
100+
)
101+
self.client = try await connection.get()
102+
}
103+
104+
// This hostname has to match the redir service name in app.yml.
105+
static let hostname = "redis"
106+
static let maxConnectionAttempts = 3
107+
108+
func set(key: String, value: String?, expiresIn: Duration?) async -> Void {
109+
if let value {
110+
let buffer = ByteBuffer(string: value)
111+
let value = RESPValue.bulkString(buffer)
112+
if let expiresIn {
113+
let ttl = Int(expiresIn.components.seconds)
114+
try? await client.setex(.init(key), to: value, expirationInSeconds: ttl).get()
115+
} else {
116+
try? await client.set(.init(key), to: value).get()
117+
}
118+
} else {
119+
_ = try? await client.delete([.init(key)]).get()
120+
}
121+
}
122+
123+
func get(key: String) async -> String? {
124+
return try? await client.get(.init(key)).map(\.string).get()
125+
}
126+
}
127+

Sources/App/configure.swift

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
import Fluent
1616
import FluentPostgresDriver
17-
import Redis
1817
import Vapor
1918

2019

@@ -78,13 +77,6 @@ public func configure(_ app: Application) async throws -> String {
7877
sqlLogLevel: .debug),
7978
as: .psql)
8079

81-
// Setup Redis connection
82-
do {
83-
app.redis.configuration = try RedisConfiguration(hostname: "redis")
84-
} catch {
85-
app.logger.warning("Failed to configure Redis, caching disabled. Error: \(error)")
86-
}
87-
8880
do { // Migration 001 - schema 1.0
8981
app.migrations.add(CreatePackage())
9082
app.migrations.add(CreateRepository())

Sources/App/routes+documentation.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,15 @@ extension Request {
178178

179179
let docVersion = try await { () -> DocVersion in
180180
if reference == String.current {
181-
if let ref = environment.currentReferenceCache()?[owner: owner, repository: repository] {
181+
@Dependency(\.currentReferenceCache) var currentReferenceCache
182+
if let ref = await currentReferenceCache.get(owner: owner, repository: repository) {
182183
return .current(referencing: ref)
183184
}
184185

185186
guard let params = try await DocumentationTarget.query(on: db, owner: owner, repository: repository)?.internal
186187
else { throw Abort(.notFound) }
187188

188-
environment.currentReferenceCache()?[owner: owner, repository: repository] = "\(params.docVersion)"
189+
await currentReferenceCache.set(owner: owner, repository: repository, reference: "\(params.docVersion)")
189190
return .current(referencing: "\(params.docVersion)")
190191
} else {
191192
return .reference(reference)

0 commit comments

Comments
 (0)