Skip to content

Commit 4e31dfa

Browse files
authored
Implement preservable jobs and configurable jobs table name (#11)
Preserved jobs, if enabled, are marked with a `completed` state in the database. Turning preservation off if it was previously on does _not_ clear old jobs from the table; it just prevents new ones from being preserved. Configuring the jobs table name requires passing the same name (and optionally, space) to both the queue driver _and_ the migration. This is necessary because there's no way for the migration to see the driver's configuration. Closes #9. Closes #10.
1 parent bc47078 commit 4e31dfa

10 files changed

+232
-147
lines changed

.github/workflows/test.yml

+2-7
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,10 @@ jobs:
2727
fail-fast: false
2828
matrix:
2929
swift-image:
30-
- swift:5.8-jammy
3130
- swift:5.9-jammy
3231
- swift:5.10-noble
33-
- swiftlang/swift:nightly-6.0-jammy
32+
- swift:6.0-noble
3433
- swiftlang/swift:nightly-main-jammy
35-
include:
36-
- sanitize: '--sanitize=thread'
37-
- swift-image: swift:5.8-jammy
38-
sanitize: ''
3934
runs-on: ubuntu-latest
4035
container: ${{ matrix.swift-image }}
4136
services:
@@ -50,7 +45,7 @@ jobs:
5045
SANITIZE: ${{ matrix.sanitize }}
5146
POSTGRES_HOST: psql
5247
MYSQL_HOST: mysql
53-
run: SWIFT_DETERMINISTIC_HASHING=1 swift test ${SANITIZE} --enable-code-coverage
48+
run: SWIFT_DETERMINISTIC_HASHING=1 swift test --sanitize=thread --enable-code-coverage
5449
- name: Upload coverage data
5550
uses: vapor/[email protected]
5651
with:

Package.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
// swift-tools-version:5.8
1+
// swift-tools-version:5.9
22
import PackageDescription
33
import class Foundation.ProcessInfo
44

55
let package = Package(
66
name: "QueuesFluentDriver",
77
platforms: [
88
.macOS(.v10_15),
9+
.iOS(.v13),
10+
.watchOS(.v6),
11+
.tvOS(.v13),
912
],
1013
products: [
1114
.library(name: "QueuesFluentDriver", targets: ["QueuesFluentDriver"]),
@@ -53,6 +56,8 @@ let package = Package(
5356

5457
var swiftSettings: [SwiftSetting] { [
5558
.enableUpcomingFeature("ForwardTrailingClosures"),
59+
.enableUpcomingFeature("ExistentialAny"),
5660
.enableUpcomingFeature("ConciseMagicFile"),
5761
.enableUpcomingFeature("DisableOutwardActorInference"),
62+
.enableExperimentalFeature("StrictConcurrency=complete"),
5863
] }

[email protected]

-63
This file was deleted.

Sources/QueuesFluentDriver/FluentQueue.swift

+25-16
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@ public struct FluentQueue: AsyncQueue, Sendable {
88
// See `Queue.context`.
99
public let context: QueueContext
1010

11-
let sqlDb: any SQLDatabase
12-
11+
let sqlDB: any SQLDatabase
12+
let preservesCompletedJobs: Bool
13+
let jobsTable: SQLQualifiedTable
14+
1315
let _sqlLockingClause: NIOLockedValueBox<(any SQLExpression)?> = .init(nil) // needs a lock for the queue to be `Sendable`
1416

1517
// See `Queue.get(_:)`.
1618
public func get(_ id: JobIdentifier) async throws -> JobData {
17-
guard let job = try await self.sqlDb.select()
19+
guard let job = try await self.sqlDB.select()
1820
.columns("payload", "max_retry_count", "queue_name", "state", "job_name", "delay_until", "queued_at", "attempts", "updated_at")
19-
.from(JobModel.schema)
21+
.from(self.jobsTable)
2022
.where("id", .equal, id)
2123
.first(decoding: JobModel.self, keyDecodingStrategy: .convertFromSnakeCase)
2224
else {
@@ -28,7 +30,7 @@ public struct FluentQueue: AsyncQueue, Sendable {
2830

2931
// See `Queue.set(_:to:)`.
3032
public func set(_ id: JobIdentifier, to jobStorage: JobData) async throws {
31-
try await self.sqlDb.insert(into: JobModel.schema)
33+
try await self.sqlDB.insert(into: self.jobsTable)
3234
.columns("id", "queue_name", "job_name", "queued_at", "delay_until", "state", "max_retry_count", "attempts", "payload", "updated_at")
3335
.values(
3436
.bind(id),
@@ -48,14 +50,21 @@ public struct FluentQueue: AsyncQueue, Sendable {
4850

4951
// See `Queue.clear(_:)`.
5052
public func clear(_ id: JobIdentifier) async throws {
51-
try await self.sqlDb.delete(from: JobModel.schema)
52-
.where("id", .equal, id)
53-
.run()
53+
if self.preservesCompletedJobs {
54+
try await self.sqlDB.update(self.jobsTable)
55+
.set("state", to: .literal(StoredJobState.completed))
56+
.where("id", .equal, id)
57+
.run()
58+
} else {
59+
try await self.sqlDB.delete(from: self.jobsTable)
60+
.where("id", .equal, id)
61+
.run()
62+
}
5463
}
5564

5665
// See `Queue.push(_:)`.
5766
public func push(_ id: JobIdentifier) async throws {
58-
try await self.sqlDb.update(JobModel.schema)
67+
try await self.sqlDB.update(self.jobsTable)
5968
.set("state", to: .literal(StoredJobState.pending))
6069
.set("updated_at", to: .now())
6170
.where("id", .equal, id)
@@ -69,9 +78,9 @@ public struct FluentQueue: AsyncQueue, Sendable {
6978
// is purely synchronous, and `SQLDatabase.version` is not implemented in MySQLKit at the time
7079
// of this writing.
7180
if self._sqlLockingClause.withLockedValue({ $0 }) == nil {
72-
switch self.sqlDb.dialect.name {
81+
switch self.sqlDB.dialect.name {
7382
case "mysql":
74-
let version = try await self.sqlDb.select()
83+
let version = try await self.sqlDB.select()
7584
.column(.function("version"), as: "version")
7685
.first(decodingColumn: "version", as: String.self)! // always returns one row
7786
// This is a really lazy check and it knows it; we know MySQLNIO doesn't support versions older than 5.x.
@@ -87,7 +96,7 @@ public struct FluentQueue: AsyncQueue, Sendable {
8796

8897
let select = SQLSubquery.select { $0
8998
.column("id")
90-
.from(JobModel.schema)
99+
.from(self.jobsTable)
91100
.where("state", .equal, .literal(StoredJobState.pending))
92101
.where("queue_name", .equal, self.queueName)
93102
.where(.dateValue(.function("coalesce", .column("delay_until"), SQLNow())), .lessThanOrEqual, .now())
@@ -97,24 +106,24 @@ public struct FluentQueue: AsyncQueue, Sendable {
97106
.lockingClause(self._sqlLockingClause.withLockedValue { $0! }) // we've always set it by the time we get here
98107
}
99108

100-
if self.sqlDb.dialect.supportsReturning {
101-
return try await self.sqlDb.update(JobModel.schema)
109+
if self.sqlDB.dialect.supportsReturning {
110+
return try await self.sqlDB.update(self.jobsTable)
102111
.set("state", to: .literal(StoredJobState.processing))
103112
.set("updated_at", to: .now())
104113
.where("id", .equal, select)
105114
.returning("id")
106115
.first(decodingColumn: "id", as: String.self)
107116
.map(JobIdentifier.init(string:))
108117
} else {
109-
return try await self.sqlDb.transaction { transaction in
118+
return try await self.sqlDB.transaction { transaction in
110119
guard let id = try await transaction.raw("\(select)") // using raw() to make sure we run on the transaction connection
111120
.first(decodingColumn: "id", as: String.self)
112121
else {
113122
return nil
114123
}
115124

116125
try await transaction
117-
.update(JobModel.schema)
126+
.update(self.jobsTable)
118127
.set("state", to: .literal(StoredJobState.processing))
119128
.set("updated_at", to: .now())
120129
.where("id", .equal, id)

Sources/QueuesFluentDriver/FluentQueuesDriver.swift

+24-8
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,21 @@ import struct Queues.JobIdentifier
99
import struct Queues.JobData
1010

1111
public struct FluentQueuesDriver: QueuesDriver {
12-
let databaseId: DatabaseID?
12+
let databaseID: DatabaseID?
13+
let preservesCompletedJobs: Bool
14+
let jobsTableName: String
15+
let jobsTableSpace: String?
1316

14-
init(on databaseId: DatabaseID? = nil) {
15-
self.databaseId = databaseId
17+
init(
18+
on databaseID: DatabaseID? = nil,
19+
preserveCompletedJobs: Bool = false,
20+
jobsTableName: String = "_jobs_meta",
21+
jobsTableSpace: String? = nil
22+
) {
23+
self.databaseID = databaseID
24+
self.preservesCompletedJobs = preserveCompletedJobs
25+
self.jobsTableName = jobsTableName
26+
self.jobsTableSpace = jobsTableSpace
1627
}
1728

1829
public func makeQueue(with context: QueueContext) -> any Queue {
@@ -21,16 +32,21 @@ public struct FluentQueuesDriver: QueuesDriver {
2132
///
2233
/// `Fluent.Databases.database(_:logger:on:)` never returns nil; its optionality is an API mistake.
2334
/// If a nonexistent `DatabaseID` is requested, it triggers a `fatalError()`.
24-
let baseDb = context
35+
let baseDB = context
2536
.application
2637
.databases
27-
.database(self.databaseId, logger: context.logger, on: context.eventLoop)!
28-
29-
guard let sqlDb = baseDb as? any SQLDatabase else {
38+
.database(self.databaseID, logger: context.logger, on: context.eventLoop)!
39+
40+
guard let sqlDB = baseDB as? any SQLDatabase else {
3041
return FailingQueue(failure: QueuesFluentError.unsupportedDatabase, context: context)
3142
}
3243

33-
return FluentQueue(context: context, sqlDb: sqlDb)
44+
return FluentQueue(
45+
context: context,
46+
sqlDB: sqlDB,
47+
preservesCompletedJobs: self.preservesCompletedJobs,
48+
jobsTable: .init(self.jobsTableName, space: self.jobsTableSpace)
49+
)
3450
}
3551

3652
public func shutdown() {}

Sources/QueuesFluentDriver/JobModel.swift

+5-3
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ enum StoredJobState: String, Codable, CaseIterable {
1111

1212
/// Job is in progress.
1313
case processing
14+
15+
/// Job is completed.
16+
///
17+
/// > Note: This state is only used if the driver is configured to preserve completed jobs.
18+
case completed
1419
}
1520

1621
/// Encapsulates a job's metadata and `JobData`.
1722
struct JobModel: Codable, Sendable {
18-
/// The name of the model's table.
19-
static let schema = "_jobs_meta"
20-
2123
/// The job identifier. Corresponds directly to a `JobIdentifier`.
2224
let id: String?
2325

Sources/QueuesFluentDriver/JobModelMigrate.swift

+37-31
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,34 @@
1-
import protocol SQLKit.SQLDatabase
2-
import enum SQLKit.SQLColumnConstraintAlgorithm
3-
import enum SQLKit.SQLDataType
4-
import enum SQLKit.SQLLiteral
5-
import struct SQLKit.SQLRaw
1+
import SQLKit
62

73
public struct JobModelMigration: AsyncSQLMigration {
4+
private let jobsTableString: String
5+
private let jobsTable: SQLQualifiedTable
6+
87
/// Public initializer.
9-
public init() {}
10-
8+
public init(
9+
jobsTableName: String = "_jobs_meta",
10+
jobsTableSpace: String? = nil
11+
) {
12+
self.jobsTableString = "\(jobsTableSpace.map { "\($0)_" } ?? "")\(jobsTableName)"
13+
self.jobsTable = .init(jobsTableName, space: jobsTableSpace)
14+
}
15+
1116
// See `AsyncSQLMigration.prepare(on:)`.
1217
public func prepare(on database: any SQLDatabase) async throws {
13-
let stateEnumType: String
14-
18+
let stateEnumType: any SQLExpression
19+
1520
switch database.dialect.enumSyntax {
1621
case .typeName:
17-
stateEnumType = "\(JobModel.schema)_storedjobstatus"
18-
try await database.create(enum: stateEnumType)
19-
.value("pending")
20-
.value("processing")
21-
.run()
22+
stateEnumType = .identifier("\(self.jobsTableString)_storedjobstatus")
23+
var builder = database.create(enum: stateEnumType)
24+
builder = StoredJobState.allCases.reduce(builder, { $0.value($1.rawValue) })
25+
try await builder.run()
2226
case .inline:
23-
stateEnumType = "enum('\(StoredJobState.allCases.map(\.rawValue).joined(separator: "','"))')"
27+
// This is technically a misuse of SQLFunction, but it produces the correct syntax
28+
stateEnumType = .function("enum", StoredJobState.allCases.map { .literal($0.rawValue) })
2429
default:
25-
stateEnumType = "varchar(16)"
30+
// This is technically a misuse of SQLFunction, but it produces the correct syntax
31+
stateEnumType = .function("varchar", .literal(16))
2632
}
2733

2834
/// This whole pile of nonsense is only here because of
@@ -39,20 +45,20 @@ public struct JobModelMigration: AsyncSQLMigration {
3945
autoTimestampConstraints = []
4046
}
4147

42-
try await database.create(table: JobModel.schema)
43-
.column("id", type: .text, .primaryKey(autoIncrement: false))
44-
.column("queue_name", type: .text, .notNull)
45-
.column("job_name", type: .text, .notNull)
46-
.column("queued_at", type: manualTimestampType, .notNull)
47-
.column("delay_until", type: manualTimestampType, .default(SQLLiteral.null))
48-
.column("state", type: .custom(SQLRaw(stateEnumType)), .notNull)
49-
.column("max_retry_count", type: .int, .notNull)
50-
.column("attempts", type: .int, .notNull)
51-
.column("payload", type: .blob, .notNull)
52-
.column("updated_at", type: .timestamp, autoTimestampConstraints)
48+
try await database.create(table: self.jobsTable)
49+
.column("id", type: .text, .primaryKey(autoIncrement: false))
50+
.column("queue_name", type: .text, .notNull)
51+
.column("job_name", type: .text, .notNull)
52+
.column("queued_at", type: manualTimestampType, .notNull)
53+
.column("delay_until", type: manualTimestampType, .default(SQLLiteral.null))
54+
.column("state", type: .custom(stateEnumType), .notNull)
55+
.column("max_retry_count", type: .int, .notNull)
56+
.column("attempts", type: .int, .notNull)
57+
.column("payload", type: .blob, .notNull)
58+
.column("updated_at", type: .timestamp, autoTimestampConstraints)
5359
.run()
54-
try await database.create(index: "i_\(JobModel.schema)_state_queue_delayUntil")
55-
.on(JobModel.schema)
60+
try await database.create(index: "i_\(self.jobsTableString)_state_queue_delayUntil")
61+
.on(self.jobsTable)
5662
.column("state")
5763
.column("queue_name")
5864
.column("delay_until")
@@ -61,10 +67,10 @@ public struct JobModelMigration: AsyncSQLMigration {
6167

6268
// See `AsyncSQLMigration.revert(on:)`.
6369
public func revert(on database: any SQLDatabase) async throws {
64-
try await database.drop(table: JobModel.schema).run()
70+
try await database.drop(table: self.jobsTable).run()
6571
switch database.dialect.enumSyntax {
6672
case .typeName:
67-
try await database.drop(enum: "\(JobModel.schema)_storedjobstatus").run()
73+
try await database.drop(enum: "\(self.jobsTableString)_storedjobstatus").run()
6874
default:
6975
break
7076
}

0 commit comments

Comments
 (0)