session-ios/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift

1749 lines
85 KiB
Swift

// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import Quick
import Nimble
@testable import SessionUtilitiesKit
class JobRunnerSpec: QuickSpec {
override class func spec() {
// MARK: Configuration
@TestState var job1: Job! = Job(
id: 100,
failureCount: 0,
variant: .messageSend,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: nil
)
@TestState var job2: Job! = Job(
id: 101,
failureCount: 0,
variant: .attachmentUpload,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: nil
)
@TestState var mockStorage: Storage! = SynchronousStorage(
customWriter: try! DatabaseQueue(),
migrationTargets: [
SNUtilitiesKit.self
],
initialData: { db in
// Migrations add jobs which we don't want so delete them
try Job.deleteAll(db)
}
)
@TestState var dependencies: Dependencies! = Dependencies(
storage: mockStorage,
dateNow: Date(timeIntervalSince1970: 0),
forceSynchronous: true
)
@TestState var jobRunner: JobRunnerType! = {
let result = JobRunner(isTestingJobRunner: true, using: dependencies)
result.setExecutor(TestJob.self, for: .messageSend)
result.setExecutor(TestJob.self, for: .attachmentUpload)
result.setExecutor(TestJob.self, for: .messageReceive)
// Need to assign this to ensure it's used by nested dependencies
dependencies.jobRunner = result
return result
}()
// MARK: - a JobRunner
describe("a JobRunner") {
afterEach {
/// We **must** set `fixedTime` to ensure we break any loops within the `TestJob` executor
dependencies.fixedTime = Int.max
jobRunner.stopAndClearPendingJobs()
}
// MARK: -- when configuring
context("when configuring") {
// MARK: ---- adds an executor correctly
it("adds an executor correctly") {
job1 = Job(
id: 101,
failureCount: 0,
variant: .getSnodePool,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
// Save the job to the database
mockStorage.write { db in _ = try job1.inserted(db) }
expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(1))
// Try to start the job
mockStorage.write { db in
jobRunner.upsert(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
// Ensure the job isn't running, and that it has been deleted (can't retry if there
// is no executer so no failure counts)
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0))
// Add the executor and start the job again
jobRunner.setExecutor(TestJob.self, for: .getSnodePool)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
// Job is now running
expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
}
}
// MARK: ---- when managing state
context("when managing state") {
// MARK: ------ by checking if a job is currently running
context("by checking if a job is currently running") {
// MARK: -------- returns false when not given a job
it("returns false when not given a job") {
expect(jobRunner.isCurrentlyRunning(nil)).to(beFalse())
}
// MARK: -------- returns false when given a job that has not been persisted
it("returns false when given a job that has not been persisted") {
job1 = Job(variant: .messageSend)
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
}
// MARK: -------- returns false when given a job that is not running
it("returns false when given a job that is not running") {
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
}
// MARK: -------- returns true when given a non blocking job that is running
it("returns true when given a non blocking job that is running") {
job1 = job1.with(details: TestDetails(completeTime: 1))
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
}
// MARK: -------- returns true when given a blocking job that is running
it("returns true when given a blocking job that is running") {
job2 = Job(
id: 101,
failureCount: 0,
variant: .messageSend,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
mockStorage.write { db in
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
jobRunner.appDidFinishLaunching(using: dependencies)
expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue())
}
}
// MARK: ------ by getting the details for jobs
context("by getting the details for jobs") {
// MARK: -------- returns an empty dictionary when there are no jobs
it("returns an empty dictionary when there are no jobs") {
expect(jobRunner.allJobInfo()).to(equal([:]))
}
// MARK: -------- returns an empty dictionary when there are no jobs matching the filters
it("returns an empty dictionary when there are no jobs matching the filters") {
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend)).to(equal([:]))
}
// MARK: -------- can filter to specific jobs
it("can filter to specific jobs") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
job2 = Job(
id: 101,
failureCount: 0,
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: nil
)
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
// Validate the filtering works
expect(jobRunner.allJobInfo()).toNot(beEmpty())
expect(jobRunner.jobInfoFor(jobs: [job1]))
.to(equal([
100: JobRunner.JobInfo(
variant: .messageReceive,
threadId: nil,
interactionId: nil,
detailsData: job1.details
)
]))
expect(jobRunner.jobInfoFor(jobs: [job2]))
.to(equal([
101: JobRunner.JobInfo(
variant: .messageReceive,
threadId: nil,
interactionId: nil,
detailsData: nil
)
]))
}
// MARK: -------- can filter to running jobs
it("can filter to running jobs") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
job2 = Job(
id: 101,
failureCount: 0,
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
jobRunner.add(
db,
job: job2,
canStartJob: false,
using: dependencies
)
}
// Wait for there to be data and the validate the filtering works
expect(jobRunner.jobInfoFor(state: .running))
.to(equal([
100: JobRunner.JobInfo(
variant: .messageReceive,
threadId: nil,
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
]))
expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101]))
}
// MARK: -------- can filter to pending jobs
it("can filter to pending jobs") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
job2 = Job(
id: 101,
failureCount: 0,
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
// Wait for there to be data and the validate the filtering works
expect(jobRunner.jobInfoFor(state: .pending))
.to(equal([
101: JobRunner.JobInfo(
variant: .messageReceive,
threadId: nil,
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
]))
expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101]))
}
// MARK: -------- can filter to specific variants
it("can filter to specific variants") {
job1 = job1.with(details: TestDetails(completeTime: 1))
job2 = job2.with(details: TestDetails(completeTime: 2))
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
// Wait for there to be data and the validate the filtering works
expect(jobRunner.jobInfoFor(variant: .attachmentUpload))
.to(equal([
101: JobRunner.JobInfo(
variant: .attachmentUpload,
threadId: nil,
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 2))
)
]))
expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101]))
}
// MARK: -------- includes non blocking jobs
it("includes non blocking jobs") {
job2 = job2.with(details: TestDetails(completeTime: 1))
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload))
.to(equal([
101: JobRunner.JobInfo(
variant: .attachmentUpload,
threadId: nil,
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
]))
}
// MARK: -------- includes blocking jobs
it("includes blocking jobs") {
job2 = Job(
id: 101,
failureCount: 0,
variant: .attachmentUpload,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
mockStorage.write { db in
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
jobRunner.appDidFinishLaunching(using: dependencies)
expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload))
.to(equal([
101: JobRunner.JobInfo(
variant: .attachmentUpload,
threadId: nil,
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
]))
}
}
// MARK: ------ by checking for an existing job
context("by checking for an existing job") {
// MARK: -------- returns false for a queue that doesn't exist
it("returns false for a queue that doesn't exist") {
jobRunner = JobRunner(
isTestingJobRunner: true,
variantsToExclude: [.attachmentUpload],
using: dependencies
)
expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails()))
.to(beFalse())
}
// MARK: -------- returns false when the provided details fail to decode
it("returns false when the provided details fail to decode") {
expect(jobRunner.hasJob(of: .attachmentUpload, with: InvalidDetails()))
.to(beFalse())
}
// MARK: -------- returns false when there is not a pending or running job
it("returns false when there is not a pending or running job") {
expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails()))
.to(beFalse())
}
// MARK: -------- returns true when there is a pending job
it("returns true when there is a pending job") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
job2 = Job(
id: 101,
failureCount: 0,
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 2))
)
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
expect(Array(jobRunner.jobInfoFor(state: .pending, variant: .messageReceive).keys))
.to(equal([101]))
expect(jobRunner.hasJob(of: .messageReceive, with: TestDetails(completeTime: 2)))
.to(beTrue())
}
// MARK: -------- returns true when there is a running job
it("returns true when there is a running job") {
job2 = job2.with(details: TestDetails(completeTime: 1))
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(equal([101]))
expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1)))
.to(beTrue())
}
// MARK: -------- returns true when there is a blocking job
it("returns true when there is a blocking job") {
job2 = Job(
id: 101,
failureCount: 0,
variant: .attachmentUpload,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
mockStorage.write { db in
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
// Need to add the job before starting it since it's a 'runOnceNextLaunch'
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(equal([101]))
expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1)))
.to(beTrue())
}
// MARK: -------- returns true when there is a non blocking job
it("returns true when there is a non blocking job") {
job2 = job2.with(details: TestDetails(completeTime: 1))
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(equal([101]))
expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1)))
.to(beTrue())
}
}
// MARK: ------ by being notified of app launch
context("by being notified of app launch") {
// MARK: -------- does not start a job before getting the app launch call
it("does not start a job before getting the app launch call") {
job1 = job1.with(details: TestDetails(completeTime: 1))
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
}
// MARK: -------- starts the job queues if there are no app launch jobs
it("does nothing if there are no app launch jobs") {
job1 = job1.with(details: TestDetails(completeTime: 1))
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
jobRunner.appDidFinishLaunching(using: dependencies)
expect(jobRunner.allJobInfo()).to(beEmpty())
}
}
// MARK: ------ by being notified of app becoming active
context("by being notified of app becoming active") {
// MARK: -------- does not start a job before getting the app active call
it("does not start a job before getting the app active call") {
job1 = job1.with(details: TestDetails(completeTime: 1))
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
}
// MARK: -------- does not start the job queues if there are no app active jobs and blocking jobs are running
it("does not start the job queues if there are no app active jobs and blocking jobs are running") {
job1 = job1.with(details: TestDetails(completeTime: 2))
job2 = Job(
id: 101,
failureCount: 0,
variant: .messageSend,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
// Not currently running
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
// Start the blocking job
jobRunner.appDidFinishLaunching(using: dependencies)
// Make sure the other queues don't start
jobRunner.appDidBecomeActive(using: dependencies)
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
}
// MARK: -------- does not start the job queues if there are app active jobs and blocking jobs are running
it("does not start the job queues if there are app active jobs and blocking jobs are running") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageSend,
behaviour: .recurringOnActive,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 2))
)
job2 = Job(
id: 101,
failureCount: 0,
variant: .messageSend,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
// Not currently running
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
// Start the blocking queue
jobRunner.appDidFinishLaunching(using: dependencies)
// Make sure the other queues don't start
jobRunner.appDidBecomeActive(using: dependencies)
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
}
// MARK: -------- starts the job queues if there are no app active jobs
it("starts the job queues if there are no app active jobs") {
job1 = job1.with(details: TestDetails(completeTime: 1))
jobRunner.appDidFinishLaunching(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
// Make sure it isn't already started
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
// Make sure it starts after 'appDidBecomeActive' is called
jobRunner.appDidBecomeActive(using: dependencies)
expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
}
// MARK: -------- starts the job queues if there are app active jobs
it("starts the job queues if there are app active jobs") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageSend,
behaviour: .recurringOnActive,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
job2 = Job(
id: 101,
failureCount: 0,
variant: .messageSend,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
jobRunner.appDidFinishLaunching(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
// Not currently running
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse())
// Make sure the queues are started
jobRunner.appDidBecomeActive(using: dependencies)
expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue())
}
// MARK: -------- starts the job queues after completing blocking app launch jobs
it("starts the job queues after completing blocking app launch jobs") {
job1 = job1.with(details: TestDetails(completeTime: 2))
job2 = Job(
id: 101,
failureCount: 0,
variant: .messageSend,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
// Not currently running
expect(jobRunner.isCurrentlyRunning(job1))
.to(beFalse())
expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse())
// Make sure it starts
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
// Blocking job running but blocked job not
expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue())
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
// Complete 'job2'
dependencies.stepForwardInTime()
// Blocked job eventually starts
expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse())
expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
}
// MARK: -------- starts the job queues alongside non blocking app launch jobs
it("starts the job queues alongside non blocking app launch jobs") {
job1 = job1.with(details: TestDetails(completeTime: 1))
job2 = Job(
id: 101,
failureCount: 0,
variant: .messageSend,
behaviour: .runOnceNextLaunch,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
jobRunner.add(
db,
job: job2,
canStartJob: true,
using: dependencies
)
}
// Not currently running
expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse())
expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse())
// Make sure it starts
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue())
expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue())
}
}
// MARK: ------ by checking if a job can be added to the queue
context("by checking if a job can be added to the queue") {
// MARK: -------- does not add a general job to the queue before launch
it("does not add a general job to the queue before launch") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageSend,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
expect(jobRunner.allJobInfo()).to(beEmpty())
}
// MARK: -------- adds a launch job to the queue in a pending state before launch
it("adds a launch job to the queue in a pending state before launch") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageSend,
behaviour: .recurringOnLaunch,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
expect(Array(jobRunner.jobInfoFor(state: [.pending]).keys)).to(equal([100]))
}
// MARK: -------- does not add a general job to the queue after launch but before becoming active
it("does not add a general job to the queue after launch but before becoming active") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageSend,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
jobRunner.appDidFinishLaunching(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
expect(jobRunner.allJobInfo()).to(beEmpty())
}
// MARK: -------- adds a launch job to the queue in a pending state after launch but before becoming active
it("adds a launch job to the queue in a pending state after launch but before becoming active") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageSend,
behaviour: .recurringOnLaunch,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
jobRunner.appDidFinishLaunching(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
expect(Array(jobRunner.jobInfoFor(state: .pending).keys)).to(equal([100]))
}
// MARK: -------- adds a general job to the queue after becoming active
it("adds a general job to the queue after becoming active") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageSend,
behaviour: .runOnce,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
expect(Array(jobRunner.allJobInfo().keys)).to(equal([100]))
}
// MARK: -------- adds a launch job to the queue and starts it after becoming active
it("adds a launch job to the queue and starts it after becoming active") {
job1 = Job(
id: 100,
failureCount: 0,
variant: .messageSend,
behaviour: .recurringOnLaunch,
shouldBlock: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
interactionId: nil,
details: try? JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
)
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
mockStorage.write { db in
jobRunner.add(
db,
job: job1,
canStartJob: true,
using: dependencies
)
}
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
}
}
}
// MARK: ---- when running jobs
context("when running jobs") {
beforeEach {
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
}
// MARK: ------ by adding
context("by adding") {
// MARK: -------- does not start until after the db transaction completes
it("does not start until after the db transaction completes") {
job1 = job1.with(details: TestDetails(completeTime: 1))
mockStorage.write { db in
jobRunner.add(db, job: job1, canStartJob: true, using: dependencies)
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
}
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
}
}
// MARK: ------ with job dependencies
context("with job dependencies") {
// MARK: -------- starts dependencies first
it("starts dependencies first") {
job1 = job1.with(details: TestDetails(completeTime: 1))
job2 = job2.with(details: TestDetails(completeTime: 2))
mockStorage.write { db in
try job1.insert(db)
try job2.insert(db)
try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(equal([101]))
}
// MARK: -------- removes the initial job from the queue
it("removes the initial job from the queue") {
job1 = job1.with(details: TestDetails(completeTime: 1))
job2 = job2.with(details: TestDetails(completeTime: 2))
mockStorage.write { db in
try job1.insert(db)
try job2.insert(db)
try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the initial job is removed from the queue
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(equal([101]))
expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
}
// MARK: -------- starts the initial job when the dependencies succeed
it("starts the initial job when the dependencies succeed") {
job1 = job1.with(details: TestDetails(completeTime: 2))
job2 = job2.with(details: TestDetails(completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
try job2.insert(db)
try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(equal([101]))
expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
// Make sure the initial job starts
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys))
.to(equal([100]))
}
// MARK: -------- does not start the initial job if the dependencies are deferred
it("does not start the initial job if the dependencies are deferred") {
job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2))
job2 = job2.with(details: TestDetails(result: .deferred, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
try job2.insert(db)
try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(equal([101]))
expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
}
// MARK: -------- does not start the initial job if the dependencies fail
it("does not start the initial job if the dependencies fail") {
job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2))
job2 = job2.with(details: TestDetails(result: .failure, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
try job2.insert(db)
try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(equal([101]))
expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
}
// MARK: -------- does not delete the initial job if the dependencies fail
it("does not delete the initial job if the dependencies fail") {
job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2))
job2 = job2.with(details: TestDetails(result: .failure, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
try job2.insert(db)
try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(equal([101]))
expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(beEmpty())
// Stop the queues so it doesn't run out of retry attempts
jobRunner.stopAndClearPendingJobs(exceptForVariant: nil, onComplete: nil)
// Make sure the jobs still exist
expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(2))
}
// MARK: -------- deletes the initial job if the dependencies permanently fail
it("deletes the initial job if the dependencies permanently fail") {
job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2))
job2 = job2.with(details: TestDetails(result: .permanentFailure, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
try job2.insert(db)
try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(equal([101]))
expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys))
.to(beEmpty())
// Make sure the jobs were deleted
expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0))
}
}
}
// MARK: ---- when completing jobs
context("when completing jobs") {
beforeEach {
jobRunner.appDidFinishLaunching(using: dependencies)
jobRunner.appDidBecomeActive(using: dependencies)
}
// MARK: ------ by succeeding
context("by succeeding") {
// MARK: -------- removes the job from the queue
it("removes the job from the queue") {
job1 = job1.with(details: TestDetails(result: .success, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
}
// MARK: -------- deletes the job
it("deletes the job") {
job1 = job1.with(details: TestDetails(result: .success, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
// Make sure the jobs were deleted
expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0))
}
}
// MARK: ------ by deferring
context("by deferring") {
// MARK: -------- reschedules the job to run again later
it("reschedules the job to run again later") {
job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(jobRunner.jobInfoFor(state: .running)).to(beEmpty())
expect {
mockStorage.read { db in try Job.select(.details).asRequest(of: Data.self).fetchOne(db) }
}.to(equal(
try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(result: .deferred, completeTime: 3))
))
}
// MARK: -------- does not delete the job
it("does not delete the job") {
job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(jobRunner.jobInfoFor(state: .running)).to(beEmpty())
// Make sure the jobs were deleted
expect(mockStorage.read { db in try Job.fetchCount(db) }).toNot(equal(0))
}
// MARK: -------- fails the job if it is deferred too many times
it("fails the job if it is deferred too many times") {
job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure it runs
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
// Progress the time
dependencies.stepForwardInTime()
// Make sure it finishes once
expect(jobRunner.jobInfoFor(state: .running))
.to(equal([
100: JobRunner.JobInfo(
variant: .messageSend,
threadId: nil,
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(result: .deferred, completeTime: 3))
)
]))
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
// Progress the time
dependencies.stepForwardInTime()
// Make sure it finishes twice
expect(jobRunner.jobInfoFor(state: .running))
.to(equal([
100: JobRunner.JobInfo(
variant: .messageSend,
threadId: nil,
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(result: .deferred, completeTime: 5))
)
]))
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
// Progress the time
dependencies.stepForwardInTime()
// Make sure it's finishes the last time
expect(jobRunner.jobInfoFor(state: .running))
.to(equal([
100: JobRunner.JobInfo(
variant: .messageSend,
threadId: nil,
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(result: .deferred, completeTime: 7))
)
]))
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
// Make sure the job was marked as failed
expect(mockStorage.read { db in try Job.fetchOne(db, id: 100)?.failureCount }).to(equal(1))
}
}
// MARK: ------ by failing
context("by failing") {
// MARK: -------- removes the job from the queue
it("removes the job from the queue") {
job1 = job1.with(details: TestDetails(result: .failure, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
}
// MARK: -------- does not delete the job
it("does not delete the job") {
job1 = job1.with(details: TestDetails(result: .failure, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
// Make sure the jobs were deleted
expect(mockStorage.read { db in try Job.fetchCount(db) }).toNot(equal(0))
}
}
// MARK: ------ by permanently failing
context("by permanently failing") {
// MARK: -------- removes the job from the queue
it("removes the job from the queue") {
job1 = job1.with(details: TestDetails(result: .permanentFailure, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
}
// MARK: -------- deletes the job
it("deletes the job") {
job1 = job1.with(details: TestDetails(result: .permanentFailure, completeTime: 1))
mockStorage.write { db in
try job1.insert(db)
jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies)
}
// Make sure the dependency is run
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100]))
// Make sure there are no running jobs
dependencies.stepForwardInTime()
expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty())
// Make sure the jobs were deleted
expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0))
}
}
}
}
}
}
// MARK: - Test Types
fileprivate struct TestDetails: Codable {
enum ResultType: Codable {
case success
case failure
case permanentFailure
case deferred
}
public let result: ResultType
public let completeTime: Int
public let intValue: Int64
public let stringValue: String
init(
result: ResultType = .success,
completeTime: Int = 0,
intValue: Int64 = 100,
stringValue: String = "200"
) {
self.result = result
self.completeTime = completeTime
self.intValue = intValue
self.stringValue = stringValue
}
}
fileprivate struct InvalidDetails: Codable {
func encode(to encoder: Encoder) throws { throw HTTPError.parsingFailed }
}
fileprivate enum TestJob: JobExecutor {
static let maxFailureCount: Int = 1
static let requiresThreadId: Bool = false
static let requiresInteractionId: Bool = false
static func run(
_ job: Job,
queue: DispatchQueue,
success: @escaping (Job, Bool, Dependencies) -> (),
failure: @escaping (Job, Error?, Bool, Dependencies) -> (),
deferred: @escaping (Job, Dependencies) -> (),
using dependencies: Dependencies
) {
guard
let detailsData: Data = job.details,
let details: TestDetails = try? JSONDecoder().decode(TestDetails.self, from: detailsData)
else { return success(job, true, dependencies) }
let completeJob: () -> () = {
// Need to increase the 'completeTime' and 'nextRunTimestamp' to prevent the job
// from immediately being run again or immediately completing afterwards
let updatedJob: Job = job
.with(nextRunTimestamp: TimeInterval(details.completeTime + 1))
.with(
details: TestDetails(
result: details.result,
completeTime: (details.completeTime + 2),
intValue: details.intValue,
stringValue: details.stringValue
)
)!
dependencies.storage.write { db in try _ = updatedJob.saved(db) }
switch details.result {
case .success: success(job, true, dependencies)
case .failure: failure(job, nil, false, dependencies)
case .permanentFailure: failure(job, nil, true, dependencies)
case .deferred: deferred(updatedJob, dependencies)
}
}
guard dependencies.fixedTime < details.completeTime else {
return queue.async(using: dependencies) {
completeJob()
}
}
dependencies.asyncExecutions.appendTo(details.completeTime) {
queue.async(using: dependencies) {
completeJob()
}
}
}
}