Skip to content

Commit a0b96a5

Browse files
author
Kacper Kawecki
authored
Allow delaying retires of failed job (#101)
* Add nextRetryIn to job for delaying retries * Small improvements to docs and test * Jobs with retry delay 0 are pushed back to the queue * Clear jobs data before pushing data for retry * Fix indentation
1 parent 1762fa0 commit a0b96a5

4 files changed

Lines changed: 155 additions & 14 deletions

File tree

Sources/Queues/Job.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ public protocol Job: AnyJob {
2626
_ error: Error,
2727
_ payload: Payload
2828
) -> EventLoopFuture<Void>
29+
30+
/// Called when there was an error and job will be retired.
31+
///
32+
/// - Parameters:
33+
/// - attempt: Number of job attempts which failed
34+
/// - Returns: Number of seconds for which next retry will be delayed.
35+
/// Return `-1` if you want to retry job immediately without putting it back to the queue.
36+
func nextRetryIn(attempt: Int) -> Int
2937

3038
static func serializePayload(_ payload: Payload) throws -> [UInt8]
3139
static func parsePayload(_ bytes: [UInt8]) throws -> Payload
@@ -60,7 +68,16 @@ extension Job {
6068
) -> EventLoopFuture<Void> {
6169
context.eventLoop.makeSucceededFuture(())
6270
}
63-
71+
72+
/// See `Job`.`nextRetryIn`
73+
public func nextRetryIn(attempt: Int) -> Int {
74+
return -1
75+
}
76+
77+
public func _nextRetryIn(attempt: Int) -> Int {
78+
return nextRetryIn(attempt: attempt)
79+
}
80+
6481
public func _error(_ context: QueueContext, id: String, _ error: Error, payload: [UInt8]) -> EventLoopFuture<Void> {
6582
var contextCopy = context
6683
contextCopy.logger[metadataKey: "job_id"] = .string(id)
@@ -88,4 +105,5 @@ public protocol AnyJob {
88105
static var name: String { get }
89106
func _dequeue(_ context: QueueContext, id: String, payload: [UInt8]) -> EventLoopFuture<Void>
90107
func _error(_ context: QueueContext, id: String, _ error: Error, payload: [UInt8]) -> EventLoopFuture<Void>
108+
func _nextRetryIn(attempt: Int) -> Int
91109
}

Sources/Queues/JobData.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ public struct JobData: Codable {
55

66
/// The maxRetryCount for the `Job`.
77
public let maxRetryCount: Int
8+
9+
/// The number of attempts made to run the `Job`.
10+
public let attempts: Int?
811

912
/// A date to execute this job after
1013
public let delayUntil: Date?
@@ -21,12 +24,14 @@ public struct JobData: Codable {
2124
maxRetryCount: Int,
2225
jobName: String,
2326
delayUntil: Date?,
24-
queuedAt: Date
27+
queuedAt: Date,
28+
attempts: Int = 0
2529
) {
2630
self.payload = payload
2731
self.maxRetryCount = maxRetryCount
2832
self.jobName = jobName
2933
self.delayUntil = delayUntil
3034
self.queuedAt = queuedAt
35+
self.attempts = attempts
3136
}
3237
}

Sources/Queues/QueueWorker.swift

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,9 @@ public struct QueueWorker {
6363
payload: data.payload,
6464
logger: logger,
6565
remainingTries: data.maxRetryCount,
66+
attempts: data.attempts,
6667
jobData: data
67-
).flatMap {
68-
logger.trace("Job done being run")
69-
return self.queue.clear(id)
70-
}
68+
)
7169
}
7270
}
7371
}
@@ -80,6 +78,7 @@ public struct QueueWorker {
8078
payload: [UInt8],
8179
logger: Logger,
8280
remainingTries: Int,
81+
attempts: Int?,
8382
jobData: JobData
8483
) -> EventLoopFuture<Void> {
8584
logger.trace("Running the queue job (remaining tries: \(remainingTries)")
@@ -92,6 +91,9 @@ public struct QueueWorker {
9291
}.flatten(on: self.queue.context.eventLoop).flatMapError { error in
9392
self.queue.logger.error("Could not send success notification: \(error)")
9493
return self.queue.context.eventLoop.future()
94+
}.flatMap {
95+
logger.trace("Job done being run")
96+
return self.queue.clear(id)
9597
}
9698
}.flatMapError { error in
9799
logger.trace("Job failed (remaining tries: \(remainingTries)")
@@ -109,24 +111,75 @@ public struct QueueWorker {
109111
}.flatten(on: self.queue.context.eventLoop).flatMapError { error in
110112
self.queue.logger.error("Failed to send error notification: \(error)")
111113
return self.queue.context.eventLoop.future()
114+
}.flatMap {
115+
logger.trace("Job done being run")
116+
return self.queue.clear(id)
112117
}
113118
}
114119
} else {
115-
logger.error("Job failed, retrying... \(error)", metadata: [
116-
"job_id": .string(id.string),
117-
"job_name": .string(name),
118-
"queue": .string(self.queue.queueName.string)
119-
])
120-
return self.run(
120+
return self.retry(
121121
id: id,
122122
name: name,
123123
job: job,
124124
payload: payload,
125125
logger: logger,
126-
remainingTries: remainingTries - 1,
127-
jobData: jobData
126+
remainingTries: remainingTries,
127+
attempts: attempts,
128+
jobData: jobData,
129+
error: error
128130
)
129131
}
130132
}
131133
}
134+
135+
private func retry(
136+
id: JobIdentifier,
137+
name: String,
138+
job: AnyJob,
139+
payload: [UInt8],
140+
logger: Logger,
141+
remainingTries: Int,
142+
attempts: Int?,
143+
jobData: JobData,
144+
error: Error
145+
) -> EventLoopFuture<Void> {
146+
let attempts = attempts ?? 0
147+
let delayInSeconds = job._nextRetryIn(attempt: attempts + 1)
148+
if delayInSeconds == -1 {
149+
logger.error("Job failed, retrying... \(error)", metadata: [
150+
"job_id": .string(id.string),
151+
"job_name": .string(name),
152+
"queue": .string(self.queue.queueName.string)
153+
])
154+
return self.run(
155+
id: id,
156+
name: name,
157+
job: job,
158+
payload: payload,
159+
logger: logger,
160+
remainingTries: remainingTries - 1,
161+
attempts: attempts + 1,
162+
jobData: jobData
163+
)
164+
} else {
165+
logger.error("Job failed, retrying in \(delayInSeconds)s... \(error)", metadata: [
166+
"job_id": .string(id.string),
167+
"job_name": .string(name),
168+
"queue": .string(self.queue.queueName.string)
169+
])
170+
let storage = JobData(
171+
payload: jobData.payload,
172+
maxRetryCount: remainingTries - 1,
173+
jobName: jobData.jobName,
174+
delayUntil: Date(timeIntervalSinceNow: Double(delayInSeconds)),
175+
queuedAt: jobData.queuedAt,
176+
attempts: attempts + 1
177+
)
178+
return self.queue.clear(id).flatMap {
179+
self.queue.set(id, to: storage)
180+
}.flatMap {
181+
self.queue.push(id)
182+
}
183+
}
184+
}
132185
}

Tests/QueuesTests/QueueTests.swift

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ final class QueueTests: XCTestCase {
234234
app.queues.add(Bar())
235235
app.queues.add(SuccessHook())
236236
app.queues.add(ErrorHook())
237+
ErrorHook.errorCount = 0
237238

238239
app.get("foo") { req in
239240
req.queue.dispatch(Bar.self, .init(foo: "bar"), maxRetryCount: 3)
@@ -259,6 +260,51 @@ final class QueueTests: XCTestCase {
259260
XCTAssertEqual(app.queues.test.queue.count, 0)
260261
XCTAssertEqual(app.queues.test.jobs.count, 0)
261262
}
263+
264+
func testFailureHooksWithDelay() throws {
265+
let app = Application(.testing)
266+
defer { app.shutdown() }
267+
app.queues.use(.test)
268+
app.queues.add(Baz())
269+
app.queues.add(SuccessHook())
270+
app.queues.add(ErrorHook())
271+
ErrorHook.errorCount = 0
272+
273+
app.get("foo") { req in
274+
req.queue.dispatch(Baz.self, .init(foo: "baz"), maxRetryCount: 1)
275+
.map { _ in "done" }
276+
}
277+
278+
try app.testable().test(.GET, "foo") { res in
279+
XCTAssertEqual(res.status, .ok)
280+
XCTAssertEqual(res.body.string, "done")
281+
}
282+
283+
XCTAssertEqual(SuccessHook.successHit, false)
284+
XCTAssertEqual(ErrorHook.errorCount, 0)
285+
XCTAssertEqual(app.queues.test.queue.count, 1)
286+
XCTAssertEqual(app.queues.test.jobs.count, 1)
287+
var job = app.queues.test.first(Baz.self)
288+
XCTAssert(app.queues.test.contains(Baz.self))
289+
XCTAssertNotNil(job)
290+
291+
try app.queues.queue.worker.run().wait()
292+
XCTAssertEqual(SuccessHook.successHit, false)
293+
XCTAssertEqual(ErrorHook.errorCount, 0)
294+
XCTAssertEqual(app.queues.test.queue.count, 1)
295+
XCTAssertEqual(app.queues.test.jobs.count, 1)
296+
job = app.queues.test.first(Baz.self)
297+
XCTAssert(app.queues.test.contains(Baz.self))
298+
XCTAssertNotNil(job)
299+
300+
sleep(1)
301+
302+
try app.queues.queue.worker.run().wait()
303+
XCTAssertEqual(SuccessHook.successHit, false)
304+
XCTAssertEqual(ErrorHook.errorCount, 1)
305+
XCTAssertEqual(app.queues.test.queue.count, 0)
306+
XCTAssertEqual(app.queues.test.jobs.count, 0)
307+
}
262308
}
263309

264310
class DispatchHook: JobEventDelegate {
@@ -408,3 +454,22 @@ struct Bar: Job {
408454
return context.eventLoop.makeSucceededFuture(())
409455
}
410456
}
457+
458+
struct Baz: Job {
459+
struct Data: Codable {
460+
var foo: String
461+
}
462+
463+
func dequeue(_ context: QueueContext, _ data: Data) -> EventLoopFuture<Void> {
464+
return context.eventLoop.makeFailedFuture(Abort(.badRequest))
465+
}
466+
467+
func error(_ context: QueueContext, _ error: Error, _ data: Data) -> EventLoopFuture<Void> {
468+
return context.eventLoop.makeSucceededFuture(())
469+
}
470+
471+
func nextRetryIn(attempt: Int) -> Int {
472+
return attempt
473+
}
474+
}
475+

0 commit comments

Comments
 (0)