Skip to content

Commit 8effcf1

Browse files
authored
Add migration to support users of old versions (#18)
There is now a new migration, `JobModelOldFormatMigration`, which can be used in place of `JobModelMigration` by users who have an existing job metadata table from a 1.x or 2.x version of the driver. This migration will upgrade old tables to the new format without any loss of data. See the README and the comments in the code for more details. Additional changes: - Swift 5.10 is now the required minimum version. - The code is now compliant with `MemberImportVisibility`. - For MySQL users, the main migration will now use `DATETIME(6)` instead of `DATETIME` so that timestamps have subsecond precision. The `updated_at` column is now also a `DATETIME(6)` instead of a `TIMESTAMP`. Existing users do not need to make any changes to their tables or add any new migrations; the existing table layout continues to work as it did before. Only new users and users who change their tables manually will see this. - For non-MySQL users, the `updated_at` column is now `NOT NULL`. Again, existing users do not need to take any action; everything continues to work as before. - The CI has been updated for Swift 6.1 and now checks Linux SDK compatibility. - A lingering `Sendable` warning has been fixed.
1 parent bc0b474 commit 8effcf1

File tree

8 files changed

+298
-26
lines changed

8 files changed

+298
-26
lines changed

.github/workflows/test.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ jobs:
2727
fail-fast: false
2828
matrix:
2929
swift-image:
30-
- swift:5.9-jammy
3130
- swift:5.10-noble
3231
- swift:6.0-noble
32+
- swift:6.1-noble
3333
- swiftlang/swift:nightly-main-jammy
3434
runs-on: ubuntu-latest
3535
container: ${{ matrix.swift-image }}
@@ -99,3 +99,15 @@ jobs:
9999
uses: vapor/[email protected]
100100
with:
101101
codecov_token: ${{ secrets.CODECOV_TOKEN || '' }}
102+
103+
musl:
104+
runs-on: ubuntu-latest
105+
container: swift:6.1-noble
106+
timeout-minutes: 30
107+
steps:
108+
- name: Check out code
109+
uses: actions/checkout@v4
110+
- name: Install SDK
111+
run: swift sdk install https://download.swift.org/swift-6.1-release/static-sdk/swift-6.1-RELEASE/swift-6.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum 111c6f7d280a651208b8c74c0521dd99365d785c1976a6e23162f55f65379ac6
112+
- name: Build
113+
run: swift build --swift-sdk x86_64-swift-linux-musl

Package.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// swift-tools-version:5.9
1+
// swift-tools-version:5.10
22
import PackageDescription
33

44
let package = Package(
@@ -58,5 +58,6 @@ var swiftSettings: [SwiftSetting] { [
5858
.enableUpcomingFeature("ExistentialAny"),
5959
.enableUpcomingFeature("ConciseMagicFile"),
6060
.enableUpcomingFeature("DisableOutwardActorInference"),
61+
.enableUpcomingFeature("MemberImportVisibility"),
6162
.enableExperimentalFeature("StrictConcurrency=complete"),
6263
] }

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ This package includes a migration to create the database table which holds job m
5959
app.migrations.add(JobModelMigration())
6060
```
6161

62+
If you were previously a user of the 1.x or 2.x releases of this driver and have an existing job metadata table in the old data format, you can use `JobModelOldFormatMigration` instead to transparently upgrade the old table to the new format:
63+
64+
```swift
65+
app.migrations.add(JobModelOldFormatMigration())
66+
```
67+
68+
> [!IMPORTANT]
69+
> Use only one or the other of the two migrations; do _not_ use both, and do not change which one you use once one of them has been run.
70+
6271
Finally, load the `QueuesFluentDriver` driver:
6372
```swift
6473
app.queues.use(.fluent())
@@ -102,19 +111,22 @@ func configure(_ app: Application) async throws {
102111
103112
### Changing the name and location of the jobs table
104113

105-
By default, the jobs table is created in the default space (e.g. the current schema - usually `public` - in PostgreSQL, or the current database in MySQL and SQLite) and has the name `_jobs_meta`. The table name and space may be configured, using the `jobsTableName` and `jobsTableSpace` parameters respectively. If the `JobModelMigration` is in use (recommended), the same name and space must be passed to both its initializer and the driver for the migration to work correctly.
114+
By default, the jobs table is created in the default space (e.g. the current schema - usually `public` - in PostgreSQL, or the current database in MySQL and SQLite) and has the name `_jobs_meta`. The table name and space may be configured, using the `jobsTableName` and `jobsTableSpace` parameters respectively. If `JobModelMigration` or `JobModelOldFormatMigration` are in use (as is recommended), the same name and space must be passed to both its initializer and the driver for the migration to work correctly.
106115

107116
Example:
108117

109118
```swift
110119
func configure(_ app: Application) async throws {
111120
app.migrations.add(JobModelMigration(jobsTableName: "_my_jobs", jobsTableSpace: "not_public"))
121+
// OR
122+
app.migrations.add(JobModelOldFormatMigration(jobsTableName: "_my_jobs", jobsTableSpace: "not_public"))
123+
112124
app.queues.use(.fluent(jobsTableName: "_my_jobs", jobsTableSpace: "not_public"))
113125
}
114126
```
115127

116128
> [!NOTE]
117-
> When the `JobModelMigration` is used with PostgreSQL, the table name is used as a prefix for the enumeration type created to represent job states in the database, and the enumeration type is created in the same space as the table.
129+
> When `JobModelMigration` or `JobModelOldFormatMigration` are used with PostgreSQL, the table name is used as a prefix for the enumeration type created to represent job states in the database, and the enumeration type is created in the same space as the table.
118130
119131
## Caveats
120132

Sources/QueuesFluentDriver/Documentation.docc/Documentation.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ This package includes a migration to create the database table which holds job m
6262
app.migrations.add(JobModelMigration())
6363
```
6464

65+
If you were previously a user of the 1.x or 2.x releases of this driver and have an existing job metadata table in the old data format, you can use `JobModelOldFormatMigration` instead to transparently upgrade the old table to the new format:
66+
67+
```swift
68+
app.migrations.add(JobModelOldFormatMigration())
69+
```
70+
71+
> Important: Use only one or the other of the two migrations; do _not_ use both, and do not change which one you use once one of them has been run.
72+
6573
Finally, load the `QueuesFluentDriver` driver:
6674
```swift
6775
app.queues.use(.fluent())
@@ -104,18 +112,21 @@ func configure(_ app: Application) async throws {
104112
105113
### Changing the name and location of the jobs table
106114

107-
By default, the jobs table is created in the default space (e.g. the current schema - usually `public` - in PostgreSQL, or the current database in MySQL and SQLite) and has the name `_jobs_meta`. The table name and space may be configured, using the `jobsTableName` and `jobsTableSpace` parameters respectively. If the `JobModelMigration` is in use (recommended), the same name and space must be passed to both its initializer and the driver for the migration to work correctly.
115+
By default, the jobs table is created in the default space (e.g. the current schema - usually `public` - in PostgreSQL, or the current database in MySQL and SQLite) and has the name `_jobs_meta`. The table name and space may be configured, using the `jobsTableName` and `jobsTableSpace` parameters respectively. If `JobModelMigration` or `JobModelOldFormatMigration` are in use (as is recommended), the same name and space must be passed to both its initializer and the driver for the migration to work correctly.
108116

109117
Example:
110118

111119
```swift
112120
func configure(_ app: Application) async throws {
113121
app.migrations.add(JobModelMigration(jobsTableName: "_my_jobs", jobsTableSpace: "not_public"))
122+
// OR
123+
app.migrations.add(JobModelOldFormatMigration(jobsTableName: "_my_jobs", jobsTableSpace: "not_public"))
124+
114125
app.queues.use(.fluent(jobsTableName: "_my_jobs", jobsTableSpace: "not_public"))
115126
}
116127
```
117128

118-
> Note: When the `JobModelMigration` is used with PostgreSQL, the table name is used as a prefix for the enumeration type created to represent job states in the database, and the enumeration type is created in the same space as the table.
129+
> Note: When `JobModelMigration` or `JobModelOldFormatMigration` are used with PostgreSQL, the table name is used as a prefix for the enumeration type created to represent job states in the database, and the enumeration type is created in the same space as the table.
119130
120131
## Caveats
121132

Sources/QueuesFluentDriver/JobModelMigrate.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import SQLKit
2+
import FluentKit
23

4+
/// A migration to create the job metadata table and any associated objects, such as enumeration types and indexes.
5+
///
6+
/// This migration is guaranteed to be compatible with MySQL 5.7+, PostgreSQL, and SQLite.
37
public struct JobModelMigration: AsyncSQLMigration {
48
private let jobsTable: SQLQualifiedTable
59
private let jobsTableIndexName: String
@@ -30,22 +34,20 @@ public struct JobModelMigration: AsyncSQLMigration {
3034
case .inline:
3135
actualStateEnumType = .custom(SQLEnumDataType(cases: StoredJobState.allCases.map { .literal($0.rawValue) }))
3236
default:
33-
// This is technically a misuse of SQLFunction, but it produces the correct syntax
34-
actualStateEnumType = .custom(.function("varchar", .literal(16)))
37+
actualStateEnumType = .custom(SQLRaw("varchar(16)"))
3538
}
3639

3740
/// This whole pile of nonsense is only here because of
3841
/// https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_explicit_defaults_for_timestamp
39-
/// In short, I'm making things work in MySQL 5.7 as a favor to a colleague.
4042
let manualTimestampType: SQLDataType, autoTimestampConstraints: [SQLColumnConstraintAlgorithm]
4143

4244
switch database.dialect.name {
4345
case "mysql":
44-
manualTimestampType = .custom(SQLRaw("DATETIME"))
46+
manualTimestampType = .custom(SQLRaw("datetime(6)")) // this is what `.datetime` translates to when using Fluent+MySQL
4547
autoTimestampConstraints = [.custom(SQLLiteral.null), .default(SQLLiteral.null)]
4648
default:
4749
manualTimestampType = .timestamp
48-
autoTimestampConstraints = []
50+
autoTimestampConstraints = [.notNull]
4951
}
5052

5153
try await database.create(table: self.jobsTable)
@@ -58,7 +60,7 @@ public struct JobModelMigration: AsyncSQLMigration {
5860
.column("max_retry_count", type: .int, .notNull)
5961
.column("attempts", type: .int, .notNull)
6062
.column("payload", type: .blob, .notNull)
61-
.column("updated_at", type: .timestamp, autoTimestampConstraints)
63+
.column("updated_at", type: manualTimestampType, autoTimestampConstraints)
6264
.run()
6365
try await database.create(index: self.jobsTableIndexName)
6466
.on(self.jobsTable)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import FluentKit
2+
import Logging
3+
import SQLKit
4+
5+
/// A migration to upgrade the data from the old 1.x and 2.x versions of this driver to the current version.
6+
///
7+
/// This migration is compatible with all known released versions of the driver. It is _not_ compatible with any
8+
/// of the 3.x-beta tags. It is known to be compatible with MySQL 8.0+, PostgreSQL 11+, and SQLite 3.38.0+, and to be
9+
/// _incompatible_ with MySQL 5.7 and earlier.
10+
///
11+
/// Once run, this migration is not reversible. See discussion in ``JobModelOldFormatMigration/revert(on:)-3xv3q`` for
12+
/// more details. This migration should be used **_in place of_** ``JobModelMigration``, not in addition to it. Using
13+
/// both migrations will cause database errors. If an error occurs during migration, a reasonable attempt is made to
14+
/// restore everything to its original state. Even under extreme conditions, the original data is guaranteed to remain
15+
/// intact until the migration is succesfully completed; the original data is never modified, and is deleted only after
16+
/// everything has finished without errors.
17+
///
18+
/// > Note: The `payload` format used by the MySQL-specific logic is a bit bizarre; instead of the plain binary string
19+
/// > used for the other databases, MySQL's version is Base64-encoded and double-quoted. This is an artifact of the
20+
/// > missing conformance of `Data` to `MySQLDataConvertible` in `MySQLNIO`, a bug that cannot be fixed at the present
21+
/// > time without causing problematic behavioral changes.
22+
public struct JobModelOldFormatMigration: AsyncSQLMigration {
23+
private let jobsTableName: String
24+
private let jobsTableSpace: String?
25+
26+
/// Public initializer.
27+
public init(
28+
jobsTableName: String = "_jobs_meta",
29+
jobsTableSpace: String? = nil
30+
) {
31+
self.jobsTableName = jobsTableName
32+
self.jobsTableSpace = jobsTableSpace
33+
}
34+
35+
// See `AsyncSQLMigration.prepare(on:)`.
36+
public func prepare(on database: any SQLDatabase) async throws {
37+
/// Return a `SQLQueryString` which extracts the field with the given name from the old-format "data" JSON as the given type.
38+
func dataGet(_ name: String, as type: String) -> SQLQueryString {
39+
switch database.dialect.name {
40+
case "postgresql": "((convert_from(\"data\", 'UTF8')::jsonb)->>\(literal: name))::\(unsafeRaw: type == "double" ? "double precision" : type)"
41+
case "mysql": "json_value(convert(data USING utf8mb4), \(literal: "$.\(name)") RETURNING \(unsafeRaw: type == "text" ? "CHAR" : (type == "double" ? "DOUBLE" : "SIGNED")))"
42+
case "sqlite": "data->>\(literal: "$.\(name)")"
43+
default: ""
44+
}
45+
}
46+
/// Return a `SQLQueryString` which extracts the timestamp field with the given name from the old-format "data" JSON as a UNIX
47+
/// timestamp, compensating for the difference between the UNIX epoch and Date's reference date (978,307,200 seconds).
48+
func dataTimestamp(_ name: String) -> SQLQueryString {
49+
switch database.dialect.name {
50+
case "postgresql": "to_timestamp(\(dataGet(name, as: "double")) + 978307200.0)"
51+
case "mysql": "from_unixtime(\(dataGet(name, as: "double")) + 978307200.0)"
52+
case "sqlite": "\(dataGet(name, as: "double")) + 978307200.0"
53+
default: ""
54+
}
55+
}
56+
/// Return a `SQLQueryString` which extracts the payload from the old-format "data" JSON, converting the original array of one-byte
57+
/// integers to the database's appropriate binary representation (`bytea` for Postgres, `BINARY` collation with Base64 encoding and
58+
/// surrounding quotes for MySQL (don't ask...), `BLOB` affinity for SQLite).
59+
func dataPayload() -> SQLQueryString {
60+
switch database.dialect.name {
61+
case "postgresql": #"coalesce((SELECT decode(string_agg(lpad(to_hex(b::int), 2, '0'), ''), 'hex') FROM jsonb_array_elements_text((convert_from("data", 'UTF8')::jsonb)->'payload') AS a(b)), '\x')"#
62+
case "mysql": #"coalesce((SELECT /*+SET_VAR(group_concat_max_len=1048576)*/ concat('"',to_base64(group_concat(char(b) SEPARATOR '')),'"') FROM json_table(convert(data USING utf8mb4), '$.payload[*]' COLUMNS (b INT PATH '$')) t), X'')"#
63+
case "sqlite": #"coalesce((SELECT unhex(group_concat(format('%02x',b.value), '')) FROM json_each(data, '$.payload') as b), '')"#
64+
default: ""
65+
}
66+
}
67+
68+
// Make sure that we keep the old table in the same space when we move it aside.
69+
let tempTable = SQLQualifiedTable("_temp_old_\(self.jobsTableName)", space: self.jobsTableSpace)
70+
let jobsTable = SQLQualifiedTable(self.jobsTableName, space: self.jobsTableSpace)
71+
let enumType = SQLQualifiedTable("\(self.jobsTableName)_storedjobstatus", space: self.jobsTableSpace)
72+
73+
// 1. Rename the existing table so we can create the new format in its place.
74+
try await database.alter(table: jobsTable).rename(to: tempTable).run()
75+
76+
do {
77+
// 2. Run the "real" migration to create the correct table structure and any associated objects.
78+
try await JobModelMigration(jobsTableName: self.jobsTableName, jobsTableSpace: self.jobsTableSpace).prepare(on: database)
79+
80+
// 3. Migrate the data from the old table.
81+
try await database.insert(into: jobsTable)
82+
.columns("id", "queue_name", "job_name", "queued_at", "delay_until", "state", "max_retry_count", "attempts", "payload", "updated_at")
83+
.select { $0
84+
.column("job_id")
85+
.column("queue")
86+
.column(.function("coalesce", dataGet("jobName", as: "text"), .literal("")))
87+
.column(.function("coalesce", dataTimestamp("queuedAt"), .identifier("created_at")))
88+
.column(dataTimestamp("delayUntil"))
89+
.column(database.dialect.name == "postgresql" ? "state::\(enumType)" as SQLQueryString : "state")
90+
.column(.function("coalesce", dataGet("maxRetryCount", as: "bigint"), .literal(0)))
91+
.column(.function("coalesce", dataGet("attempts", as: "bigint"), .literal(0)))
92+
.column(dataPayload())
93+
.column("updated_at")
94+
.from(tempTable)
95+
}
96+
.run()
97+
} catch {
98+
// Attempt to clean up after ourselves by deleting the new table and moving the old one back into place.
99+
try? await database.drop(table: jobsTable).run()
100+
try? await database.alter(table: tempTable).rename(to: jobsTable).run()
101+
throw error
102+
}
103+
104+
// 4. Drop the old table.
105+
try await database.drop(table: tempTable).run()
106+
}
107+
108+
// See `AsyncSQLMigration.revert(on:)`.
109+
public func revert(on database: any SQLDatabase) async throws {
110+
/// This migration technically can be reverted, if one is willing to consider the values of the original
111+
/// `id`, `created_at`, and `deleted_at` columns spurious, and therefore disposable. However, it would be
112+
/// a good deal of work - in particular, turning the binary payload back into a JSON byte array would
113+
/// even more involved than the frontways conversion, and the utility of doing so is insufficient to
114+
/// justify the effort unless this feature ends up being commonly requested. We could call through to
115+
/// ``JobModelMigration``'s revert, but it seems best to err on the side of caution for this migration.
116+
117+
// TODO: Should we throw an error instead of logging an easily-missed message?
118+
database.logger.warning("Reverting the \(self.name) migration is not implemented; your job metadata table is unchanged!")
119+
}
120+
}

Sources/QueuesFluentDriver/SQLKit+Convenience.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ extension SQLDatabase {
112112
return fluentSelf.transaction { fluentTransaction in closure(fluentTransaction as! any SQLDatabase) }
113113
}
114114

115-
func transaction<T>(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> T) async throws -> T {
115+
func transaction<T: Sendable>(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> T) async throws -> T {
116116
guard let fluentSelf = self as? any Database else { fatalError("Cannot use `SQLDatabase.transaction(_:)` on a non-Fluent database.") }
117117

118118
return try await fluentSelf.transaction { fluentTransaction in try await closure(fluentTransaction as! any SQLDatabase) }
@@ -150,3 +150,10 @@ extension AsyncSQLMigration {
150150
try await self.revert(on: database as! any SQLDatabase)
151151
}
152152
}
153+
154+
/// This extension covers a gap in the `SQLAlterTableBuilder` API.
155+
extension SQLDatabase {
156+
func alter(table: some SQLExpression) -> SQLAlterTableBuilder {
157+
.init(.init(name: table), on: self)
158+
}
159+
}

0 commit comments

Comments
 (0)