Skip to content

Commit e66a8c3

Browse files
authored
Use Structured Concurrency (#255)
1 parent 29a19cc commit e66a8c3

40 files changed

+977
-422
lines changed

.editorconfig

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
root = true
2+
3+
[*]
4+
indent_style = space
5+
indent_size = 2
6+
tab_width = 2
7+
insert_final_newline = true
8+
trim_trailing_whitespace = true
9+
end_of_line = lf
10+
charset = utf-8
11+
spelling_language = en
12+
13+
[*.swift]
14+
indent_style = space
15+
indent_size = 4
16+
tab_width = 4
17+
insert_final_newline = true
18+
trim_trailing_whitespace = true
19+
end_of_line = lf
20+
charset = utf-8
21+
spelling_language = en

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ jobs:
1010
unit-tests:
1111
uses: vapor/ci/.github/workflows/run-unit-tests.yml@main
1212
with:
13+
with_release_mode_testing: true
1314
with_coverage: false
1415
with_tsan: false
1516
with_api_check: false
1617
with_deps_submission: true
17-
with_gh_codeql: false # Temporary, until the action actually works
1818

1919
cloudformation-lint:
2020
name: Check CloudFormation

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.DS_Store
22
/.build
3+
/.index-build
34
/Packages
45
/*.xcodeproj
56
xcuserdata/

Lambdas/GHHooks/Errors.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ enum Errors: Error, CustomStringConvertible, LocalizedError {
1717
case let .headerNotFound(name, headers):
1818
return "headerNotFound(name: \(name), headers: \(headers))"
1919
case let .multipleErrors(errors):
20-
return "multipleErrors(\(errors.map({ "\($0)" })))"
20+
return "multipleErrors(\(errors.map({ String(reflecting: $0) }).joined(separator: ";\n")))"
2121
}
2222
}
2323

Package.resolved

Lines changed: 20 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import PackageDescription
66
let package = Package(
77
name: "Penny",
88
platforms: [
9-
.macOS(.v14)
9+
.macOS(.v15)
1010
],
1111
dependencies: [
1212
.package(url: "https://github.com/apple/swift-nio.git", from: "2.57.0"),
@@ -22,6 +22,7 @@ let package = Package(
2222
.package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"),
2323
.package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"),
2424
.package(url: "https://github.com/gwynne/swift-semver.git", from: "1.0.0"),
25+
.package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"),
2526
.package(url: "https://github.com/DiscordBM/DiscordBM.git", branch: "main"),
2627
.package(url: "https://github.com/DiscordBM/DiscordLogger.git", from: "1.0.0-rc.2"),
2728
.package(
@@ -47,6 +48,7 @@ let package = Package(
4748
dependencies: [
4849
.product(name: "DiscordBM", package: "DiscordBM"),
4950
.product(name: "DiscordLogger", package: "DiscordLogger"),
51+
.product(name: "ServiceLifecycle", package: "swift-service-lifecycle"),
5052
.product(name: "AsyncHTTPClient", package: "async-http-client"),
5153
.product(name: "Markdown", package: "swift-markdown"),
5254
.product(name: "SotoS3", package: "soto"),
@@ -165,6 +167,7 @@ let package = Package(
165167
dependencies: [
166168
.product(name: "AsyncHTTPClient", package: "async-http-client"),
167169
.product(name: "Logging", package: "swift-log"),
170+
.product(name: "ServiceLifecycle", package: "swift-service-lifecycle"),
168171
.product(name: "DiscordBM", package: "DiscordBM"),
169172
.target(name: "Models"),
170173
],

Sources/Penny/BotStateManager.swift

Lines changed: 85 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import FoundationEssentials
55
import Foundation
66
#endif
77
import Logging
8+
import ServiceLifecycle
89

910
/**
1011
When we update Penny, AWS waits a few minutes before taking down the old Penny instance to
@@ -25,97 +26,138 @@ import Logging
2526
* If the old instance is too slow to make the process happen, the process is aborted and
2627
the new instance will start handling events without waiting more for the old instance.
2728
*/
28-
actor BotStateManager {
29-
29+
actor BotStateManager: Service {
3030
let id = Int(Date().timeIntervalSince1970)
31-
let services: HandlerContext.Services
31+
let context: HandlerContext
3232
let disableDuration: Duration
3333
let logger: Logger
34+
private var cachesPopulationContinuations: [CheckedContinuation<Void, Never>] = []
3435

3536
var canRespond = false
36-
var onStarted: (() async -> Void)?
3737

3838
init(
39-
services: HandlerContext.Services,
39+
context: HandlerContext,
4040
disabledDuration: Duration = .seconds(3 * 60)
4141
) {
42-
self.services = services
42+
self.context = context
4343
self.disableDuration = disabledDuration
4444
var logger = Logger(label: "BotStateManager")
4545
logger[metadataKey: "id"] = "\(self.id)"
4646
self.logger = logger
4747
}
4848

49-
func start(onStarted: @Sendable @escaping () async -> Void) async {
50-
self.onStarted = onStarted
51-
Task { await send(.shutdown) }
52-
cancelIfCachePopulationTakesTooLong()
49+
func run() async {
50+
switch Constants.deploymentEnvironment {
51+
case .local:
52+
break
53+
case .testing, .prod:
54+
self.context.backgroundProcessor.process {
55+
await self.cancelIfCachePopulationTakesTooLong()
56+
}
57+
self.context.backgroundProcessor.process {
58+
await self.send(.shutdown)
59+
}
60+
}
61+
62+
/// Wait indefinitely
63+
let (stream, _) = AsyncStream.makeStream(of: Void.self)
64+
await stream.first(where: { _ in true })
5365
}
5466

55-
private func cancelIfCachePopulationTakesTooLong() {
56-
Task {
57-
try await Task.sleep(for: .seconds(120))
58-
if !canRespond {
59-
await startAllowingResponses()
60-
logger.error("No CachesStorage-population was done in-time")
61-
}
67+
func addCachesPopulationContinuation(_ cont: CheckedContinuation<Void, Never>) {
68+
switch Constants.deploymentEnvironment {
69+
case .local: break
70+
case .testing, .prod:
71+
cont.resume()
72+
return
73+
}
74+
switch self.canRespond {
75+
case true:
76+
cont.resume()
77+
case false:
78+
self.cachesPopulationContinuations.append(cont)
79+
}
80+
}
81+
82+
private func cancelIfCachePopulationTakesTooLong() async {
83+
guard (try? await Task.sleep(for: .seconds(120))) != nil else {
84+
return /// Somewhere else cancelled the Task
85+
}
86+
if !canRespond {
87+
await startAllowingResponses()
88+
logger.error("No CachesStorage-population was done in-time")
6289
}
6390
}
6491

65-
func canRespond(to event: Gateway.Event) -> Bool {
66-
checkIfItsASignal(event: event)
92+
func canRespond(to event: Gateway.Event) async -> Bool {
93+
switch Constants.deploymentEnvironment {
94+
case .local: break
95+
case .testing, .prod:
96+
await checkIfItsASignal(event: event)
97+
}
6798
return canRespond
6899
}
69100

70-
private func checkIfItsASignal(event: Gateway.Event) {
101+
private func checkIfItsASignal(event: Gateway.Event) async {
71102
guard case let .messageCreate(message) = event.data,
72103
message.channel_id == Constants.Channels.botLogs.id,
73104
let author = message.author,
74105
author.id == Constants.botId,
75-
let otherId = message.content.split(whereSeparator: \.isWhitespace).last
106+
let otherId = message.content.split(whereSeparator: \.isWhitespace).last,
107+
otherId != "\(self.id)"
76108
else { return }
77-
if otherId == "\(self.id)" { return }
78109

79110
if StateManagerSignal.shutdown.isInMessage(message.content) {
80111
logger.trace("Received 'shutdown' signal")
81-
shutdown()
112+
self.context.backgroundProcessor.process {
113+
await self.shutdown()
114+
}
82115
} else if StateManagerSignal.didShutdown.isInMessage(message.content) {
83116
logger.trace("Received 'didShutdown' signal")
84-
populateCache()
117+
self.context.backgroundProcessor.process {
118+
await self.populateCache()
119+
}
85120
}
86121
}
87122

88-
private func shutdown() {
89-
Task {
90-
await services.cachesService.gatherCachedInfoAndSaveToRepository()
91-
await send(.didShutdown)
92-
self.canRespond = false
123+
private func shutdown() async {
124+
await context.cachesService.gatherCachedInfoAndSaveToRepository()
125+
await send(.didShutdown)
126+
self.canRespond = false
93127

94-
try await Task.sleep(for: disableDuration)
95-
await startAllowingResponses()
96-
logger.critical("AWS has not yet shutdown this instance of Penny! Why?!")
128+
guard (try? await Task.sleep(for: disableDuration)) != nil else {
129+
return /// Somewhere else cancelled the Task
97130
}
131+
132+
await startAllowingResponses()
133+
logger.critical("AWS has not yet shutdown this instance of Penny! Why?!")
98134
}
99135

100-
private func populateCache() {
101-
Task {
102-
if canRespond {
103-
logger.warning("Received a did-shutdown signal but Cache is already populated")
104-
} else {
105-
await services.cachesService.getCachedInfoFromRepositoryAndPopulateServices()
106-
await startAllowingResponses()
107-
}
136+
private func populateCache() async {
137+
if canRespond {
138+
logger.warning("Received a did-shutdown signal but Cache is already populated")
139+
} else {
140+
await context.cachesService.getCachedInfoFromRepositoryAndPopulateServices()
141+
await startAllowingResponses()
108142
}
109143
}
110144

111145
private func startAllowingResponses() async {
112-
canRespond = true
113-
await onStarted?()
146+
self.canRespond = true
147+
for continuation in self.cachesPopulationContinuations {
148+
continuation.resume()
149+
}
150+
self.cachesPopulationContinuations.removeAll()
114151
}
115152

116153
private func send(_ signal: StateManagerSignal) async {
154+
switch Constants.deploymentEnvironment {
155+
case .local: return
156+
case .testing, .prod: break
157+
}
158+
117159
let content = makeSignalMessage(text: signal.rawValue, id: self.id)
118-
await services.discordService.sendMessage(
160+
await context.discordService.sendMessage(
119161
channelId: Constants.Channels.botLogs.id,
120162
payload: .init(content: content)
121163
)

Sources/Penny/CommandsManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ struct CommandsManager {
77

88
func registerCommands() async {
99
let commands = makeCommands()
10-
await context.services.discordService.overwriteCommands(commands)
10+
await context.discordService.overwriteCommands(commands)
1111
}
1212

1313
private func makeCommands() -> [Payloads.ApplicationCommandCreate] {

Sources/Penny/Constants.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,42 @@ import FoundationEssentials
44
import Foundation
55
#endif
66
import DiscordBM
7+
import Logging
78

89
enum Constants {
10+
11+
enum DeploymentEnvironment {
12+
case testing
13+
case local
14+
case prod
15+
16+
init() {
17+
let value = ProcessInfo.processInfo.environment["DEPLOYMENT_ENVIRONMENT"]
18+
switch value?.lowercased() {
19+
case "testing": self = .testing
20+
case "local": self = .local
21+
case "prod": self = .prod
22+
default:
23+
Logger(label: "Environment.init").critical(
24+
"Invalid deployment environment env var provided", metadata: [
25+
"value": .string(value ?? "<null>")
26+
]
27+
)
28+
fatalError("""
29+
Invalid deployment environment env var provided: '\(value ?? "<null>")'.
30+
Set 'DEPLOYMENT_ENVIRONMENT' to 'local' for local developments.
31+
""")
32+
}
33+
}
34+
}
935
static func env(_ key: String) -> String {
1036
if let value = ProcessInfo.processInfo.environment[key] {
1137
return value
1238
} else {
1339
fatalError("""
1440
Set an environment value for key '\(key)'.
1541
In tests you usually can set dummy values.
42+
For a local run, 'BOT_TOKEN' is required.
1643
""")
1744
}
1845
}
@@ -24,6 +51,7 @@ enum Constants {
2451
static let apiBaseURL = env("API_BASE_URL")
2552
static let ghOAuthClientID = env("GH_OAUTH_CLIENT_ID")
2653
static let accountLinkOAuthPrivKey = env("ACCOUNT_LINKING_OAUTH_FLOW_PRIV_KEY")
54+
static let deploymentEnvironment = DeploymentEnvironment()
2755

2856
/// U+1F3FB EMOJI MODIFIER FITZPATRICK TYPE-1-2...TYPE-6
2957
static var emojiSkins: [String] {

0 commit comments

Comments
 (0)