├── .gitignore ├── Package.swift ├── README.md ├── Tests └── QueuesDatabaseHooksTests │ └── DatabaseHooksTests.swift └── Sources └── QueuesDatabaseHooks ├── QueuesDatabaseNotificationHook.swift ├── QueueDatabaseEntry+Query.swift └── QueueDatabaseEntry.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | /Package.resolved 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "queues-database-hooks", 6 | platforms: [ 7 | .macOS(.v12) 8 | ], 9 | products: [ 10 | .library( 11 | name: "QueuesDatabaseHooks", 12 | targets: ["QueuesDatabaseHooks"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/vapor/queues.git", from: "1.5.0"), 16 | .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.7.0"), 17 | .package(url: "https://github.com/vapor/sql-kit.git", from: "3.7.0"), 18 | 19 | // Test-only dependencies 20 | .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"), 21 | .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), 22 | .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"), 23 | ], 24 | targets: [ 25 | .target( 26 | name: "QueuesDatabaseHooks", 27 | dependencies: [ 28 | .product(name: "FluentKit", package: "fluent-kit"), 29 | .product(name: "SQLKit", package: "sql-kit"), 30 | .product(name: "Queues", package: "queues") 31 | ]), 32 | .testTarget( 33 | name: "QueuesDatabaseHooksTests", 34 | dependencies: [ 35 | .target(name: "QueuesDatabaseHooks"), 36 | .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), 37 | .product(name: "XCTQueues", package: "queues"), 38 | .product(name: "XCTVapor", package: "vapor"), 39 | .product(name: "Fluent", package: "fluent"), 40 | ]), 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QueuesDatabaseHooks 2 | 3 | This package adds database success and failure tracking for all dequeued jobs. 4 | 5 | ## Installation 6 | Use the SPM string to easily include the dependendency in your Package.swift file. 7 | 8 | ```swift 9 | .package(url: "https://github.com/vapor-community/queues-database-hooks.git", from: ...) 10 | ``` 11 | 12 | After you have the package installed, getting started is easy: 13 | 14 | ```swift 15 | app.migrations.add(QueueDatabaseEntryMigration()) 16 | app.queues.add(QueuesDatabaseNotificationHook.default(db: app.db)) 17 | ``` 18 | 19 | And that's all! Your app will now start tracking job data in your specified database. 20 | 21 | ## Configuring the error handler 22 | By default, the package will attempt to transform `Error`s into `String`s via the `localizedDescription` property. You can pass in a closure when initializing the `QueuesDatabaseNotificationHook` object to specify how to transform errors. You can also pass in a closure for transforming the notification data (if, for example, you only want to save the payload data for certain job names.) 23 | 24 | ```swift 25 | let dbHook = QueuesDatabaseNotificationHook(db: db) { error -> (String) in 26 | // Do something here with `error` that returns a string 27 | } payloadClosure: { data -> (NotificationJobData) in 28 | return data 29 | } 30 | app.queues.add(dbHook) 31 | ``` 32 | 33 | ## What can I do with this data? 34 | Out of the box, there's nothing built in to analyze the data that is captured. You can think of this as a "bring your own frontend" project - now that you have the data from your queue jobs you can do whatever you like with it. 35 | 36 | That being said, there are some community projects built on top of the data that surface insights and create dashboards: 37 | 38 | 1. https://github.com/gotranseo/queues-dash 39 | -------------------------------------------------------------------------------- /Tests/QueuesDatabaseHooksTests/DatabaseHooksTests.swift: -------------------------------------------------------------------------------- 1 | import QueuesDatabaseHooks 2 | import Queues 3 | import XCTQueues 4 | import XCTVapor 5 | import Fluent 6 | import FluentSQLiteDriver 7 | import XCTest 8 | 9 | final class QueuesDatabaseHooksTests: XCTestCase { 10 | var app: Application! 11 | 12 | override func setUpWithError() throws { 13 | self.app = Application(.testing) 14 | self.app.databases.use(.sqlite(.memory, maxConnectionsPerEventLoop: 1), as: .sqlite) 15 | self.app.migrations.add(QueueDatabaseEntryMigration()) 16 | try self.app.migrator.setupIfNeeded().wait() 17 | try self.app.migrator.prepareBatch().wait() 18 | self.app.queues.use(.test) 19 | self.app.queues.add(QueuesDatabaseNotificationHook(db: self.app.db, errorClosure: { $0.localizedDescription }, payloadClosure: { $0 })) 20 | } 21 | 22 | override func tearDownWithError() throws { 23 | self.app.shutdown() 24 | } 25 | 26 | func testJobLifecycle() throws { 27 | struct Foo: Job { 28 | struct Payload: Codable {} 29 | 30 | func dequeue(_ context: QueueContext, _: Payload) -> EventLoopFuture { 31 | context.eventLoop.makeSucceededVoidFuture() 32 | } 33 | 34 | func error(_ context: QueueContext, _: Error, _: Payload) -> EventLoopFuture { 35 | context.eventLoop.makeSucceededVoidFuture() 36 | } 37 | } 38 | 39 | self.app.queues.add(Foo()) 40 | self.app.get("foo") { $0.queue.dispatch(Foo.self, .init()).map { _ in "done" } } 41 | try app.testable().test(.GET, "/foo") { XCTAssertEqual($0.body.string, "done") } 42 | 43 | let entries1 = try QueueDatabaseEntry.query(on: self.app.db).all().wait() 44 | XCTAssertEqual(entries1.count, 1) 45 | XCTAssertEqual(entries1.first?.status, .queued) 46 | XCTAssertNil(entries1.first?.dequeuedAt) 47 | XCTAssertNil(entries1.first?.completedAt) 48 | XCTAssertNil(entries1.first?.errorString) 49 | 50 | try app.queues.queue.worker.run().wait() 51 | 52 | let entries2 = try QueueDatabaseEntry.query(on: self.app.db).all().wait() 53 | XCTAssertEqual(entries2.count, 1) 54 | XCTAssertEqual(entries2.first?.status, .success) 55 | XCTAssertNotNil(entries2.first?.dequeuedAt) 56 | XCTAssertNotNil(entries2.first?.completedAt) 57 | XCTAssertNil(entries2.first?.errorString) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/QueuesDatabaseHooks/QueuesDatabaseNotificationHook.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Queues 3 | import FluentKit 4 | 5 | /// A `NotificationHook` that can be added to the queues package to track the status of all successful and failed jobs 6 | public struct QueuesDatabaseNotificationHook: JobEventDelegate { 7 | /// Transforms an `Error` to a `String` to store in the database for a failing job. 8 | private let errorClosure: (Error) -> (String) 9 | 10 | /// A hook which allows modifying (such as redacting) the payload stored in the database for a dispatched job. 11 | private let payloadClosure: (JobEventData) -> (JobEventData) 12 | 13 | /// The database in which job information is stored. 14 | public let database: Database 15 | 16 | /// Creates a default `QueuesDatabaseNotificationHook` 17 | /// - Returns: A `QueuesDatabaseNotificationHook` notification hook handler 18 | public static func `default`(db: Database) -> QueuesDatabaseNotificationHook { 19 | return .init(db: db) { error -> (String) in 20 | return error.localizedDescription 21 | } payloadClosure: { data -> (JobEventData) in 22 | return data 23 | } 24 | } 25 | 26 | /// Create a new `QueuesNotificationHook`. 27 | /// 28 | /// - Parameters: 29 | /// - db: The database in which job information should be stored. 30 | /// - errorClosure: A closure to turn an `Error` into a `String` that is stored in the database for a failed job. 31 | /// - payloadClosure: A closure which allows editing or removing the job payload which is saved to the database. 32 | public init(db: Database, errorClosure: @escaping (Error) -> (String), payloadClosure: @escaping (JobEventData) -> (JobEventData)) { 33 | self.database = db 34 | self.errorClosure = errorClosure 35 | self.payloadClosure = payloadClosure 36 | } 37 | 38 | /// Called when the job is first dispatched to a queue. 39 | /// - Parameters: 40 | /// - job: The `JobEventData` associated with the job. 41 | /// - eventLoop: The `EventLoop` of the queue the job was dispatched to. 42 | public func dispatched(job: JobEventData, eventLoop: EventLoop) -> EventLoopFuture { 43 | let data = payloadClosure(job) 44 | 45 | return QueueDatabaseEntry.recordDispatch( 46 | jobId: data.id, 47 | jobName: data.jobName, 48 | queueName: data.queueName, 49 | payload: Data(data.payload), 50 | maxRetryCount: data.maxRetryCount, 51 | delayUntil: data.delayUntil, 52 | dispatchTimestamp: data.queuedAt, 53 | on: self.database 54 | ).map { 55 | self.database.logger.info("\(job.id) - Added route to database") 56 | } 57 | } 58 | 59 | /// Called when the job is dequeued 60 | /// - Parameters: 61 | /// - jobId: The id of the Job 62 | /// - eventLoop: The eventLoop 63 | public func didDequeue(jobId: String, eventLoop: EventLoop) -> EventLoopFuture { 64 | self.database.logger.info("\(jobId) - Updating to status of running") 65 | 66 | return QueueDatabaseEntry.recordDequeue( 67 | jobId: jobId, 68 | dequeueTimestamp: Date(), 69 | on: self.database 70 | ).map { 71 | self.database.logger.info("\(jobId) - Done updating to status of running") 72 | } 73 | } 74 | 75 | /// Called when the job succeeds 76 | /// - Parameters: 77 | /// - jobId: The id of the Job 78 | /// - eventLoop: The eventLoop 79 | public func success(jobId: String, eventLoop: EventLoop) -> EventLoopFuture { 80 | self.database.logger.info("\(jobId) - Updating to status of success") 81 | 82 | return QueueDatabaseEntry.recordCompletion( 83 | jobId: jobId, 84 | completionTimestamp: Date(), 85 | errorString: nil, 86 | on: self.database 87 | ).map { 88 | self.database.logger.info("\(jobId) - Done updating to status of success") 89 | } 90 | } 91 | 92 | /// Called when the job returns an error 93 | /// - Parameters: 94 | /// - jobId: The id of the Job 95 | /// - error: The error that caused the job to fail 96 | /// - eventLoop: The eventLoop 97 | public func error(jobId: String, error: Error, eventLoop: EventLoop) -> EventLoopFuture { 98 | self.database.logger.info("\(jobId) - Updating to status of error") 99 | 100 | return QueueDatabaseEntry.recordCompletion( 101 | jobId: jobId, 102 | completionTimestamp: Date(), 103 | errorString: errorClosure(error), 104 | on: self.database 105 | ).map { 106 | self.database.logger.info("\(jobId) - Done updating to status of error") 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/QueuesDatabaseHooks/QueueDatabaseEntry+Query.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import FluentKit 3 | import SQLKit 4 | import Vapor 5 | 6 | public extension QueueDatabaseEntry { 7 | 8 | /// Returns the current counts of queued and running jobs. 9 | /// - Parameters: 10 | /// - db: The Database to run the query on. 11 | /// - Returns: A future whose result is a structure containing the counts of queued and running jobs at the time of 12 | /// the query. 13 | static func getStatusOfCurrentJobs(db: Database) -> EventLoopFuture { 14 | if let sql = db as? SQLDatabase { 15 | /// This syntax is compatible with all supported SQL databases at the time of this writing. 16 | return sql.raw(""" 17 | SELECT 18 | COALESCE( 19 | SUM( 20 | CASE WHEN \(ident: "status") = \(literal: 0) THEN \(literal: 1) ELSE \(literal: 0) END 21 | ),\(literal: 0) 22 | ) AS \(ident: "queuedCount"), 23 | COALESCE( 24 | SUM( 25 | CASE WHEN \(ident: "status") = \(literal: 1) THEN \(literal: 1) ELSE \(literal: 0) END 26 | ),\(literal: 0) 27 | ) AS \(ident: "runningCount") 28 | FROM 29 | \(ident: QueueDatabaseEntry.schema) 30 | """) 31 | .first(decoding: CurrentJobsStatusResponse.self).unwrap(or: Abort(.badRequest, reason: "Could not get data for status")) 32 | } else { 33 | return QueueDatabaseEntry.query(on: db).filter(\.$status == .queued).count(\.$id).flatMap { queuedCount in 34 | QueueDatabaseEntry.query(on: db).filter(\.$status == .running).count(\.$id).map { runningCount in 35 | .init(queuedCount: queuedCount, runningCount: runningCount) 36 | } 37 | } 38 | } 39 | } 40 | 41 | /// Retrieves data about jobs that ran successfully over the specified time period. 42 | /// 43 | /// - Parameters: 44 | /// - db: The Database to run the query on. 45 | /// - hours: The maximum age in hours for a job to be considered. For example, a value of `1` will retrieve data 46 | /// only for jobs whose completion date is within the past hour. Negative numbers and `0` are illegal inputs. 47 | /// - Returns: A future whose result is a structure containing the count of completed jobs and the percentage of 48 | /// those jobs which completed successfully within the past given number of hours, as of the time of the query. 49 | static func getCompletedJobsForTimePeriod(db: Database, hours: Int) -> EventLoopFuture { 50 | precondition(hours > 0, "Can not request job data for jobs in the future.") 51 | 52 | let deadline = Calendar.current.date(byAdding: .hour, value: -hours, to: Date(), wrappingComponents: false) 53 | 54 | if let sql = db as? SQLDatabase { 55 | return sql.raw(""" 56 | SELECT 57 | COUNT(\(ident: "id")) AS \(ident: "completedJobs"), 58 | COALESCE( 59 | SUM( 60 | CASE WHEN \(ident: "status") = \(literal: Int(2)) THEN \(literal: Int(1)) ELSE \(literal: Int(0)) END 61 | ) / COUNT(\(ident: "id")), 62 | \(bind: Double(1)) 63 | ) 64 | AS \(ident: "percentSuccess") 65 | FROM 66 | \(ident: QueueDatabaseEntry.schema) 67 | WHERE 68 | \(ident: "completedAt")>=\(bind: deadline) 69 | """) 70 | .first(decoding: CompletedJobStatusResponse.self) 71 | .unwrap(or: Abort(.badRequest, reason: "Could not get data for status")) 72 | } else { 73 | return QueueDatabaseEntry.query(on: db).filter(\.$completedAt >= deadline).count(\.$id).flatMap { completedJobs in 74 | QueueDatabaseEntry.query(on: db).filter(\.$completedAt >= deadline).filter(\.$status == .success).count(\.$id).map { successfulJobs in 75 | .init(completedJobs: completedJobs, percentSuccess: Double(successfulJobs) / Double(completedJobs)) 76 | } 77 | } 78 | } 79 | } 80 | 81 | /// Retrieves data about the how quickly jobs ran and how long they waited to be run. 82 | /// 83 | /// - Warning: At the time of this writing, due to limitations of Fluent, this query may run _very_ slowly if used 84 | /// with a NoSQL database driver like Mongo, depending on the number of jobs recorded in the database and the 85 | /// given filtering parameters. 86 | /// 87 | /// - Parameters: 88 | /// - db: The Database to run the query on. 89 | /// - hours: The maximum age in hours for a job to be considered. For example, a value of `1` will retrieve data 90 | /// only for jobs whose completion date is within the past hour. Negative numbers and `0` are illegal inputs. 91 | /// - jobName: The name of the job to filter on, if any 92 | /// - Returns: A future whose result is a structure containing the average number of seconds it took the specified 93 | /// jobs to run and that the specified jobs spent queued before running within the past given number of hours, as 94 | /// of the time of the query. 95 | static func getTimingDataForJobs(db: Database, hours: Int, jobName: String? = nil) -> EventLoopFuture { 96 | precondition(hours > 0, "Can not request job data for jobs in the future.") 97 | 98 | let deadline = Calendar.current.date(byAdding: .hour, value: -hours, to: Date(), wrappingComponents: false) 99 | 100 | if let sql = db as? SQLDatabase { 101 | let jobNameClause: SQLQueryString = jobName.map { "\(ident: "jobName")=\(bind: $0)" } ?? "\(literal: 1)=\(literal: 1)" 102 | let dateDiffExpression: (String, String) -> SQLQueryString 103 | switch sql.dialect.name { 104 | case "mysql": dateDiffExpression = { "TIMESTAMPDIFF(SECOND, \(ident: $1), \(ident: $0))" } 105 | case "postgresql": dateDiffExpression = { "extract(epoch from \(ident: $0) - \(ident: $1))" } 106 | case "sqlite": dateDiffExpression = { "strftime(\(literal: "%s"), \(ident: $0)) - strftime(\(literal: "%s"), \(ident: $1))" } 107 | case let name: 108 | /// Because people are stubborn, only crash in Debug. 109 | assertionFailure("You're using an unsupported SQL dialect (\(name)), try again.") 110 | return sql.eventLoop.makeFailedFuture(Abort(.internalServerError, reason: "Unsupported SQL database dialect '\(name)'")) 111 | } 112 | 113 | return sql.raw(""" 114 | SELECT 115 | COALESCE(AVG(\(dateDiffExpression("completedAt", "dequeuedAt"))), \(literal: 0)) AS \(ident: "avgRunTime"), 116 | COALESCE(AVG(\(dateDiffExpression("dequeuedAt", "queuedAt"))), \(literal: 0)) AS \(ident: "avgWaitTime") 117 | FROM 118 | \(ident: QueueDatabaseEntry.schema) 119 | WHERE 120 | \(ident: "dequeuedAt") IS NOT \(SQLLiteral.null) AND 121 | \(ident: "completedAt")>=\(bind: deadline) AND 122 | \(jobNameClause) 123 | """) 124 | .first(decoding: JobsTimingResponse.self).unwrap(or: Abort(.badRequest, reason: "Could not get data for status")) 125 | } else { 126 | return QueueDatabaseEntry.query(on: db) 127 | .field(\.$queuedAt).field(\.$dequeuedAt).field(\.$completedAt) 128 | .filter(\.$completedAt >= deadline) 129 | .filter(\.$dequeuedAt != nil) 130 | .filter(\.$jobName, .equality(inverse: jobName == nil), jobName ?? "") 131 | .all() 132 | .nonemptyMap(or: .init(avgRunTime: nil, avgWaitTime: nil)) { 133 | let sums = $0.reduce((0.0, 0.0)) { s, q in ( 134 | s.0 + (q.completedAt!.timeIntervalSinceReferenceDate - q.dequeuedAt!.timeIntervalSinceReferenceDate), 135 | s.1 + (q.dequeuedAt!.timeIntervalSinceReferenceDate - q.queuedAt.timeIntervalSinceReferenceDate) 136 | ) } 137 | return .init(avgRunTime: sums.0 / Double($0.count), avgWaitTime: sums.1 / Double($0.count)) 138 | } 139 | } 140 | } 141 | } 142 | 143 | /// Data about jobs currently queued or running 144 | public struct CurrentJobsStatusResponse: Content { 145 | /// The number of queueud jobs currently waiting to be run 146 | public let queuedCount: Int 147 | 148 | /// The number of jobs currently running 149 | public let runningCount: Int 150 | } 151 | 152 | /// Data about jobs that have run successfully over a time period 153 | public struct CompletedJobStatusResponse: Content { 154 | /// The number of jobs that completed successfully 155 | public let completedJobs: Int 156 | 157 | /// The percent of jobs (out of all jobs run in the time period) that ran successfully 158 | public let percentSuccess: Double 159 | } 160 | 161 | /// Data about how long jobs are taking to run 162 | public struct JobsTimingResponse: Content { 163 | 164 | /// The average time spent running a job 165 | public let avgRunTime: Double? 166 | 167 | /// The average time jobs spent waiting to be processed 168 | public let avgWaitTime: Double? 169 | } 170 | -------------------------------------------------------------------------------- /Sources/QueuesDatabaseHooks/QueueDatabaseEntry.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import FluentKit 3 | import SQLKit 4 | 5 | /// Stores information about a `Queue` job. 6 | /// 7 | /// A record is added when the job is dispatched and updated with the final status 8 | /// when it succeeds or fails. 9 | public final class QueueDatabaseEntry: Model { 10 | public static let schema = "_queue_job_completions" 11 | 12 | /// The database primary key. 13 | @ID(key: .id) 14 | public var id: UUID? 15 | 16 | /// The `jobId` from the `queues` package. 17 | @Field(key: "jobId") 18 | public var jobId: String 19 | 20 | /// The name of the job. 21 | @Field(key: "jobName") 22 | public var jobName: String 23 | 24 | /// The name of the queue on which the job was run. 25 | @Field(key: "queueName") 26 | public var queueName: String 27 | 28 | /// The data associated with the job. 29 | @Field(key: "payload") 30 | public var payload: Data 31 | 32 | /// The job's retry count. 33 | @Field(key: "maxRetryCount") 34 | public var maxRetryCount: Int 35 | 36 | /// The `delayUntil` date from the `queues` package. 37 | @OptionalField(key: "delayUntil") 38 | public var delayUntil: Date? 39 | 40 | /// The `queuedAt` timestamp from the `queues` package. 41 | @Field(key: "queuedAt") 42 | public var queuedAt: Date 43 | 44 | /// The timestamp at which the job was dequeued, or `nil` if it is still queued. 45 | @Timestamp(key: "dequeuedAt", on: .none) 46 | public var dequeuedAt: Date? 47 | 48 | /// The timestamp at which the job was completed, or `nil` if it is not complete. 49 | /// 50 | /// A job is considered complete regardless of success or failure. 51 | /// 52 | /// - Precondition: If this property is not `nil`, `dequeuedAt` must not be `nil`. 53 | @Timestamp(key: "completedAt", on: .none) 54 | public var completedAt: Date? 55 | 56 | /// The error string for the job, or `nil` if it succeeded or is not yet complete. 57 | /// 58 | /// - Precondition: If this property is not `nil`, `completedAt` must not be `nil`. 59 | @OptionalField(key: "errorString") 60 | public var errorString: String? 61 | 62 | /// The status of the job. 63 | @Field(key: "status") 64 | public var status: Status 65 | 66 | /// The timestamp at which the database record of the job was created. 67 | /// 68 | /// This should usually be within a few ms of `queuedAt`, unless under heavy load. 69 | @Timestamp(key: "createdAt", on: .create) 70 | public var createdAt: Date? 71 | 72 | /// The timestamp at which the database record was last updated. 73 | @Timestamp(key: "updatedAt", on: .update) 74 | public var updatedAt: Date? 75 | 76 | /// A queue job's status. 77 | public enum Status: Int, CaseIterable, Codable { 78 | /// The job is queued but not yet picked up for processing. 79 | case queued 80 | 81 | /// The job has been moved to the processing queue and is currently running. 82 | case running 83 | 84 | /// The job was completed successfully. 85 | case success 86 | 87 | /// The job failed. 88 | case error 89 | } 90 | 91 | public init() { } 92 | 93 | public init(jobId: String, 94 | jobName: String, 95 | queueName: String, 96 | payload: Data, 97 | maxRetryCount: Int, 98 | delayUntil: Date?, 99 | queuedAt: Date, 100 | dequeuedAt: Date?, 101 | completedAt: Date?, 102 | errorString: String?, 103 | status: Status 104 | ) { 105 | self.jobId = jobId 106 | self.jobName = jobName 107 | self.queueName = queueName 108 | self.payload = payload 109 | self.maxRetryCount = maxRetryCount 110 | self.delayUntil = delayUntil 111 | self.queuedAt = queuedAt 112 | self.errorString = errorString 113 | self.status = status 114 | self.completedAt = completedAt 115 | self.createdAt = nil 116 | self.updatedAt = nil 117 | } 118 | } 119 | 120 | extension QueueDatabaseEntry { 121 | /// It is a hard error to queue the same `jobId` more than once. 122 | internal static func recordDispatch( 123 | jobId: String, jobName: String, queueName: String, payload: Data, 124 | maxRetryCount: Int, delayUntil: Date?, dispatchTimestamp: Date, 125 | on database: Database 126 | ) -> EventLoopFuture { 127 | return QueueDatabaseEntry( 128 | jobId: jobId, 129 | jobName: jobName, 130 | queueName: queueName, 131 | payload: payload, 132 | maxRetryCount: maxRetryCount, 133 | delayUntil: delayUntil, 134 | queuedAt: dispatchTimestamp, 135 | dequeuedAt: nil, 136 | completedAt: nil, 137 | errorString: nil, 138 | status: .queued 139 | ).create(on: database) 140 | } 141 | 142 | /// Dequeuing an already-running or completed job only sets the dequeuing timestamp iff it wasn't already set. 143 | internal static func recordDequeue( 144 | jobId: String, dequeueTimestamp: Date, on database: Database 145 | ) -> EventLoopFuture { 146 | if let sql = database as? SQLDatabase { 147 | return sql.raw(""" 148 | UPDATE \(ident: QueueDatabaseEntry.schema) 149 | SET 150 | \(ident: "dequeuedAt")=CASE WHEN \(ident: "dequeuedAt") IS NULL THEN \(bind: dequeueTimestamp) ELSE \(ident: "dequeuedAt") END, 151 | \(ident: "status")=CASE WHEN \(ident: "status")=\(bind: Status.queued) THEN \(bind: Status.running) ELSE \(ident: "status") END 152 | WHERE 153 | \(ident: "jobId")=\(bind: jobId) 154 | """) 155 | .run() 156 | } else { 157 | return QueueDatabaseEntry.query(on: database) 158 | .set(\.$dequeuedAt, to: dequeueTimestamp) 159 | .filter(\.$dequeuedAt == nil) 160 | .filter(\.$jobId == jobId) 161 | .update() 162 | .flatMap { 163 | QueueDatabaseEntry.query(on: database) 164 | .set(\.$status, to: .running) 165 | .filter(\.$status == .queued) 166 | .filter(\.$jobId == jobId) 167 | .update() 168 | } 169 | } 170 | } 171 | 172 | /// Completing an already-completed job has no effect, even if the final status differs (only the first completion 173 | /// takes effect, in other words). Completing a still-queued job updates the dequeuing timestamp as well. 174 | internal static func recordCompletion( 175 | jobId: String, completionTimestamp: Date, errorString: String?, on database: Database 176 | ) -> EventLoopFuture { 177 | if let sql = database as? SQLDatabase { 178 | return sql.raw(""" 179 | UPDATE \(ident: QueueDatabaseEntry.schema) 180 | SET 181 | \(ident: "dequeuedAt")=CASE WHEN \(ident: "dequeuedAt") IS NULL THEN \(bind: completionTimestamp) ELSE \(ident: "dequeuedAt") END, 182 | \(ident: "completedAt")=CASE WHEN \(ident: "completedAt") IS NULL THEN \(bind: completionTimestamp) ELSE \(ident: "completedAt") END, 183 | \(ident: "errorString")=CASE WHEN \(ident: "errorString") IS NULL THEN \(bind: errorString) ELSE \(ident: "errorString") END, 184 | \(ident: "status")=CASE WHEN \(ident: "status") IN (\(bind: Status.queued), \(bind: Status.running)) THEN \(bind: errorString == nil ? Status.success : Status.error) ELSE \(ident: "status") END 185 | WHERE 186 | \(ident: "jobId")=\(bind: jobId) 187 | """) 188 | .run() 189 | } else { 190 | return QueueDatabaseEntry.query(on: database).set(\.$dequeuedAt, to: completionTimestamp).filter(\.$dequeuedAt == nil).filter(\.$jobId == jobId).update() 191 | .flatMap { QueueDatabaseEntry.query(on: database).set(\.$completedAt, to: completionTimestamp).filter(\.$completedAt == nil).filter(\.$jobId == jobId).update() } 192 | .flatMap { QueueDatabaseEntry.query(on: database).set(\.$errorString, to: errorString).filter(\.$errorString == nil).filter(\.$jobId == jobId).update() } 193 | .flatMap { QueueDatabaseEntry.query(on: database).set(\.$status, to: errorString == nil ? .success : .error).filter(\.$status ~~ [.queued, .running]).filter(\.$jobId == jobId).update() } 194 | } 195 | } 196 | } 197 | 198 | public struct QueueDatabaseEntryMigration: AsyncMigration { 199 | public init() { } 200 | 201 | public func prepare(on database: Database) async throws { 202 | try await database.schema(QueueDatabaseEntry.schema) 203 | .field(.id, .uuid, .identifier(auto: false)) 204 | .field("jobId", .string, .required) 205 | .field("jobName", .string, .required) 206 | .field("queueName", .string, .required) 207 | .field("payload", .data, .required) 208 | .field("maxRetryCount", .int, .required) 209 | .field("delayUntil", .datetime) 210 | .field("queuedAt", .datetime, .required) 211 | .field("dequeuedAt", .datetime) 212 | .field("completedAt", .datetime) 213 | .field("errorString", .string) 214 | .field("status", .int, .required) 215 | .field("createdAt", .datetime) 216 | .field("updatedAt", .datetime) 217 | .unique(on: "jobId") 218 | .create() 219 | } 220 | 221 | public func revert(on database: Database) async throws { 222 | try await database.schema(QueueDatabaseEntry.schema).delete() 223 | } 224 | } 225 | --------------------------------------------------------------------------------