Scheduled Jobs
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools
version
6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0) with queues 1.18.0,
queues-redis-driver
1.1.2, redis 4.14.0 and Fluent 4.13.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the
Swift 6
language mode with zero warnings in a scratch package. The schedule arithmetic, the scheduler (started with
startScheduledJobs), the failure and long-run behaviour, the test suite shown and the queues --scheduled and custom
overdue-scan commands were run, and the output quoted is captured from those runs. The Redis slot lock was compiled
but
not run against a live Redis server, and the Kubernetes manifests were not applied to a cluster. Vapor 5 is at
5.0.0-beta.4
and is not released yet; the scheduling API is not described for Vapor 5 here because it was not verified. The page
extends the
Bookshelf service of the earlier pages: a nightly scan finds overdue loans and hands the slow work,
one
reminder per loan, to a queued job. Swift itself is covered by the Swift
Reference,
and async functions, Sendable and actors by Async/Await
and Tasks
and Actors, Isolation and Sendable. Queued jobs,
workers and
retries are the subject of the Queues & Background Jobs page; this
page only needs the part that a scheduled
job uses. The reminder that the job finally sends is covered by Sending Email and
APNs Push Notifications.
What a scheduled job is
A scheduled job is a piece of code that runs by itself at times described by a schedule ("every day at 02:00", "every
10
minutes"), as opposed to a request handler, which runs when a client calls it, and a queued job, which runs because some
other
code dispatched it. Vapor ships scheduled jobs in the same package as queued jobs, vapor/queues, and they share
the Application.Queues configuration, but they are different mechanisms:
| Aspect | Queued job (AsyncJob) |
Scheduled job (AsyncScheduledJob) |
|---|---|---|
Trigger |
Code calls |
A timer in a scheduler process fires at the next time of the schedule |
Payload |
A |
None: the job is a value registered once, with the |
Storage |
Redis, a database or the test driver; survives restarts |
Nothing is stored: the schedule lives in memory only |
Who runs it |
Any worker process on the queue |
Every process that started the scheduler |
Retries |
|
None: a failed run is logged and the next occurrence is scheduled normally |
Needs a queue driver |
Yes |
Only if the job dispatches queued jobs or uses |
Two consequences drive the rest of the page. First, a scheduled job should be short and cheap: find the work and dispatch it, then let queued jobs (with retries and several workers) do it. The Bookshelf scan below only reads the overdue loans and dispatches one reminder job per loan. Second, because the schedule is not stored anywhere, something must start the scheduler exactly once per deployment; that is the main operational question and has its own section.
Where it sits in the application
A scheduled job is not part of the request lifecycle. There is no middleware, router or handler involved. The
Application
holds a list of scheduled jobs in app.queues.configuration; a scheduler is a set of NIO timers (`RepeatedTask`s on an
event loop)
created when the scheduler starts, in one of two ways, and cancelled when the application shuts down:
-
try app.queues.startScheduledJobs()starts the timers inside the current process, typically the same process that serves HTTP (serve). It returns immediately; the timers run on the application’s event loop group. -
swift run App queues --scheduled(in a container,./App queues --scheduled) is a separate process whose only job is to run the timers. ItsQueuesCommandinstallsSIGTERMandSIGINThandlers, keeps the application running until one arrives and shuts the timers down in an orderly way.
The figure shows the topology this page ends up recommending: HTTP replicas and queue workers scale out, while the scheduler is a single process. The rest of the page explains why.
The job: AsyncScheduledJob
AsyncScheduledJob refines ScheduledJob (whose run(context:) returns an EventLoopFuture<Void>) with an async
throws
requirement; the future-returning method is implemented for you. The protocol requires Sendable because the scheduler
hands the job
value to a timer on another event loop, so a job is normally a struct holding only Sendable configuration. Anything
else
it needs, such as the database or the application logger, comes from the QueueContext argument, which has
application,
logger, eventLoop, queue (the default Queue), queues(_:) for a named one, and configuration.
The default name of a job is its type name. Override it to make log lines and the scheduler’s bookkeeping unambiguous
(see the pitfalls below).
import Fluent
import Foundation
import Queues
import Vapor
struct OverdueReminderPayload: Codable, Sendable {
let loanID: UUID
}
/// A queued job: the scan below only finds the loans, this one does the slow work (email, push).
struct OverdueReminderJob: AsyncJob {
typealias Payload = OverdueReminderPayload
func dequeue(_ context: QueueContext, _ payload: OverdueReminderPayload) async throws {
context.logger.info("Sending overdue reminder", metadata: ["loan": "\(payload.loanID)"])
}
}
/// A scheduled job: runs on the scheduler process at the time given by the schedule.
struct OverdueLoanScan: AsyncScheduledJob {
var name: String { "overdue-loan-scan" }
func run(context: QueueContext) async throws {
let now = Date()
let overdue = try await Loan.query(on: context.application.db)
.filter(\.$returnedAt == nil)
.filter(\.$dueAt < now)
.all()
for loan in overdue {
let id = try loan.requireID()
try await context.queue.dispatch(
OverdueReminderJob.self,
OverdueReminderPayload(loanID: id),
maxRetryCount: 3
)
}
context.logger.info("Overdue scan finished", metadata: ["overdue": "\(overdue.count)"])
}
}
Points that matter in this listing:
-
OverdueLoanScanis astructwith no stored properties, so it is triviallySendable. TheLoanmodel class is@unchecked Sendablein the Bookshelf models of the earlier pages (see Fluent Models); the query result never leaves therunfunction except as the loan identifiers placed in the payload. -
Loan.query(on: context.application.db)uses the application-wide database: a scheduled job has noRequest, so there is noreq.db. The same holds for caches, the HTTP client and every other service stored on the application. -
context.queue.dispatch(...)uses the default queue of the configured driver. Because the dispatch needs a driver, the application must have calledapp.queues.use(...)andapp.queues.add(OverdueReminderJob()); the scheduler itself would start without them. -
The scan does not send e-mail or push notifications itself. A scheduled run that takes minutes delays the next occurrence of the same job (see below), while dispatching a job costs one Redis round trip.
-
A scheduled job should be idempotent or guard itself, because a restart, a second scheduler or a manual command run can execute the same occurrence again. The scan above is naturally safe to repeat except that it would send a second reminder; store a "reminded on" marker on the loan, or deduplicate with the job
id:argument ofdispatch, if repeated reminders matter.
Describing the schedule: app.queues.schedule
app.queues.schedule(_:) registers a job and returns a ScheduleBuilder. You then describe when by chaining calls on
the
builder; nothing runs until a scheduler is started. The builder has two families of methods.
Calendar-based schedules use Calendar.nextDate(after:matching:matchingPolicy:) with the components you provided and
fire at the
next wall-clock time that matches:
| Builder call | Fires |
|---|---|
|
on 1 January at 12:00 |
|
on the 1st of every month at 00:00 |
|
on the 31st, in the months that have a 31st only |
|
every Monday at 08:00 |
|
every day at 02:00 (24-hour |
|
every day at 21:30 (12-hour |
|
the same, as a |
|
at minute 30 of every hour |
|
at second 15 of every minute |
|
every second |
|
once, at that |
Interval-based schedules do not look at the calendar: every(seconds:), every(minutes:), every(hours:),
every(days:)
and every(weeks:) (all take an Int) fire a fixed time after the previous scheduled time. They are right for "refresh
every
10 minutes" and wrong for "every day at 02:00", because the first run is interval after the moment the scheduler
started, and
that moment is arbitrary.
The Weekday and Month enumerations give names to the numbers (Weekday.sunday is 1 and .saturday is 7, as in
Calendar), and Day is .first, .last or an integer literal. .midnight and .noon are shorthand Time values.
import Foundation
import Queues
import Vapor
/// Each helper registers one job with one of the builder forms; `nextDate` is shown in the tests.
struct Heartbeat: AsyncScheduledJob {
func run(context: QueueContext) async throws {
context.logger.info("tick")
}
}
func registerSchedules(_ app: Application) {
// 02:00 every day, in an explicit time zone instead of the process default
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Europe/Madrid")!
app.queues.schedule(OverdueLoanScan())
.using(calendar)
.daily()
.at(2, 0)
// other builder forms
app.queues.schedule(Heartbeat()).hourly().at(30) // minute 30 of every hour
app.queues.schedule(Heartbeat()).weekly().on(.monday).at("08:00") // Mondays 08:00
app.queues.schedule(Heartbeat()).monthly().on(1).at(.midnight) // the 1st at 00:00
app.queues.schedule(Heartbeat()).yearly().in(.january).on(1).at(.noon)
app.queues.schedule(Heartbeat()).minutely().at(15) // second 15 of every minute
app.queues.schedule(Heartbeat()).every(minutes: 10) // fixed delay after the previous run
}
Every Heartbeat registration above shares one type, which is fine for a demonstration but not for production: give
each
registered job a distinct name (next section on pitfalls).
What the builder computes, measured
ScheduleBuilder.nextDate(current:) is public, so you can ask any builder when it would fire next. The table lists the
results
for each form, given "now" of Saturday 2026-10-17 09:30:15 in Europe/Madrid (UTC+2 on that date) and a builder created
with a
Calendar in that zone. The values were printed by a test run.
| Builder | nextDate after 2026-10-17 09:30:15 |
|---|---|
|
2026-10-18 02:00:00 |
|
2026-10-17 21:30:00 |
|
2026-10-17 12:00:00 |
|
2026-10-18 00:00:00 |
|
2026-10-17 10:30:00 |
|
2026-10-17 09:31:15 |
|
2026-10-19 08:00:00 |
|
2026-10-18 08:00:00 |
|
2026-11-01 00:00:00 |
|
2026-10-31 00:00:00 |
|
2027-01-01 12:00:00 |
|
2026-10-17 09:40:15 (the current time plus ten minutes) |
|
2026-10-17 09:30:16 |
|
2026-12-25 09:00:00; after it has fired, |
|
|
Time zones, daylight saving and month edges
The calendar that does the arithmetic is Calendar.current unless you pass another with using(_:) (or
ScheduleBuilder(calendar:) in a test). Calendar.current takes its time zone from the process, and in a container
that is
normally UTC. daily().at(2, 0) on a UTC container therefore fires at 02:00 UTC, which is 04:00 in Madrid in summer.
The trace
log of the queues --scheduled run shows the effect of the explicit calendar used above: with the Madrid calendar the
next
overdue scan was scheduled for 2026-10-18 00:00:00 +0000, which is 02:00 in Madrid. Set the time zone explicitly on
every
calendar-based job whose wall-clock time matters, and keep it out of the job (an environment variable is a good source
if
operations needs to change it).
Other measured edge cases, all from the same test run with a Madrid or UTC calendar:
-
Daylight saving, spring forward. Madrid skipped from 02:00 to 03:00 on 2027-03-28. After 2027-03-27 12:00 the next
daily().at(2, 30)was 2027-03-29 02:30, so the run of the day with the missing hour is skipped, not shifted. A job scheduled at 03:00 fired on 2027-03-28 03:00 as usual. The reason ismatchingPolicy: .strict, which the builder uses, so a time that does not exist on a given day does not match. If a run must happen on every calendar day, schedule it away from the 02:00 to 03:00 window. -
Daylight saving, autumn. In the repeated hour the behaviour of
Calendar.nextDateapplies; this page did not measure it, so for a job that must run exactly once per day choose a time outside the repeated hour or use the slot lock below. -
The 31st.
monthly().on(31)from 2027-01-31 fired next on 2027-03-31, then 2027-05-31, 2027-07-31 and 2027-08-31: months without a 31st are skipped, they do not fall back to the last day. Likewiseyearly().in(.february).on(29)waited until 2028-02-29. -
The last day of the month is not supported.
monthly().on(.last)compiles, but computing the next date reaches afatalError. The measured output wasFatal error: Last day of the month is not yet supported., which crashes the whole process when the scheduler starts. Do not use.last; scheduledaily()and check the date inside the job, or schedule on the 1st and treat the run as "the previous month". -
Incomplete builders never run.
daily()orhourly()with noat(...)producednil, so the job is silently never scheduled (only a debug-level log line, "Scheduler returned no date").weekly().on(.friday)withoutatfired on Friday at 00:00. Always finish a builder chain withat. -
String literals trap.
"08:00"and friends are parsed at run time; a malformed literal such as"8am"stops the process with afatalError, and an hour above 23 or a minute above 59 fails aprecondition.
Starting the scheduler
There are two ways, as outlined above. In both, the scheduler needs the jobs to have been registered first, and with no registered jobs it logs a warning ("No scheduled jobs exist, exiting scheduled jobs worker.") and does nothing.
In the web process. The simplest setup for one instance or a development machine is to start the timers in
configure. Do not
call startScheduledJobs() unconditionally: configure runs for every command of the executable (migrate,
routes, a
custom command), so each of them would start a scheduler. Guard it with something that says "this instance is the
scheduler":
import Fluent
import FluentSQLiteDriver
import Queues
import QueuesRedisDriver
import Vapor
func configure(_ app: Application) async throws {
app.databases.use(.sqlite(.memory), as: .sqlite)
app.migrations.add(CreateBookshelf())
try await app.autoMigrate()
if app.environment != .testing {
try app.queues.use(.redis(url: Environment.get("REDIS_URL") ?? "redis://127.0.0.1:6379"))
}
app.queues.add(OverdueReminderJob())
registerSchedules(app)
app.asyncCommands.use(OverdueScanCommand(), as: "overdue-scan")
// in-process scheduler, only when this instance is the designated scheduler
if Environment.get("RUN_SCHEDULER") == "true" {
try app.queues.startScheduledJobs()
}
try routes(app)
}
The app.environment != .testing guard keeps unit tests from needing Redis (see testing below). RUN_SCHEDULER is a
name chosen
for this example, not a Vapor setting.
As a dedicated process. queues --scheduled starts the same timers in a process that serves no HTTP:
swift run App queues --scheduled
# in a container built from the official template (ENTRYPOINT is the App binary)
./App queues --scheduled --env production
The --scheduled flag and the --queue option of the same command are alternatives: with --scheduled the process
runs only
the scheduler and no queue worker, and without it the process is a worker for the default queue (or the named one).
Running
both is two processes. Both flags are declared by QueuesCommand.Signature in queues 1.18.0. With trace logging the
process
reports each job being scheduled; the lines below are from a run of the example application (shortened):
[ INFO ] Starting scheduled jobs worker
[ TRACE ] Scheduling job [name: overdue-loan-scan]
[ DEBUG ] Job scheduled [job-name: overdue-loan-scan, scheduled-date: 2026-10-18 00:00:00 +0000]
[ TRACE ] Job was scheduled successfully [name: overdue-loan-scan]
Both start paths cancel their timers when the application shuts down, and the queues command also stops on SIGTERM
and
SIGINT, so a container runtime can end it with a normal termination signal; see
Application Lifecycle and Entrypoint for how shutdown
works. A run
that is in progress when the timers are cancelled is not awaited by the scheduler, so keep scheduled bodies short or
idempotent.
What happens when a job fails or runs long
The scheduler is a loop per job: compute the next date, wait, run, and on completion compute the next date again. The
details come
from QueuesCommand and AnyScheduledJob in queues 1.18.0 and were confirmed with a test that registered a normal job,
a job
that always threw and a job that slept longer than its interval.
-
A throwing job does not stop the schedule. The error is logged at error level ("Scheduled job failed") with the job name, and the next occurrence is scheduled normally. In a run with one failing and one healthy
every(seconds: 1)job over 3.6 seconds, both ran three times. There is no retry, no backoff and no dead-letter: if a failure must be retried, have the scheduled job dispatch a queued job, which hasmaxRetryCount. -
A job never overlaps itself. The next run is scheduled only after the current run completes. For a job that sleeps 2.5 seconds with
every(seconds: 1), the job ran three times in 7.2 seconds, back to back, not seven times; when a run is longer than the interval, the next one starts right away. By reading the source (not measured), a calendar-based job that overruns its slot likewise computes its next date after the previous scheduled time, which may already be in the past, and so starts that occurrence immediately; a slow job cannot run twice at once, but it does shift. -
Nothing is remembered. The schedule is in memory only. If the scheduler process is down at 02:00, that run does not happen later, and when the process restarts the next date is computed from the current time (an
every(minutes: 10)job runs ten minutes after the restart). Jobs that must not be missed need their own record of the last successful run, which the job checks when it starts. -
Timer jitter. In queues 1.18.0 the next date is computed from the previously scheduled date, not from the time the timer actually fired, so a timer that wakes slightly early does not run the same occurrence twice.
Exactly one scheduler
Because the schedule lives in each process, every process that starts the scheduler runs every job. Two web replicas
that both
call startScheduledJobs() send two reminders per overdue loan; ten replicas send ten. There is no built-in leader
election,
lock or deduplication in vapor/queues. The options, from the simplest, are:
| Approach | How | Trade-offs |
|---|---|---|
One scheduler process |
A Deployment (or systemd unit) with exactly one replica running |
Simple. A crash or rollout leaves a gap; a rolling update briefly runs two. Use |
Slot lock |
Any number of instances start the scheduler; each run first takes a Redis key for the slot (the day, the hour) with |
Survives a replica loss without a gap. Needs Redis and clocks that agree on the slot. The lock is a coordination hint, not a safety proof, so the job must still be idempotent. |
Cluster scheduler |
A Kubernetes |
The platform owns the schedule, history and concurrency policy; nothing in your pods has timers. Minimum granularity of one minute, a pod start per run, and the schedule is configuration in the manifests, not in the code. |
One replica
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookshelf-scheduler
spec:
replicas: 1 # never scale this out
strategy:
type: Recreate # no overlap between the old and the new scheduler pod
selector:
matchLabels: {app: bookshelf-scheduler}
template:
metadata:
labels: {app: bookshelf-scheduler}
spec:
containers:
- name: scheduler
image: registry.example.com/bookshelf:1.0.0 # placeholder image
args: ["queues", "--scheduled", "--env", "production"]
env:
- name: REDIS_URL
valueFrom: {secretKeyRef: {name: bookshelf-secrets, key: redis-url}}
This manifest was not applied to a cluster; it relies on the image’s ENTRYPOINT being the App binary, as in the
official
Dockerfile (see Deployment with Docker), so args replace its default
serve command. See Deployment Strategies
for the Recreate strategy and Health Probes and Lifecycle
for
termination.
A slot lock in Redis
The lock below wraps the scan. Every instance can run the scheduler, but only the first to take
bookshelf:scheduled:<slot> does
the work; the key expires on its own after 23 hours, so a dead owner cannot block the next day. There is no release
step: the
key is the record that the day’s run happened.
import Foundation
import Queues
import Redis
import Vapor
/// Runs `work` on at most one instance per slot (for example one day) using SET key value NX EX seconds.
struct SlotLock: Sendable {
let app: Application
func runOnce(slot: String, ttlSeconds: Int, instance: String, _ work: () async throws -> Void) async throws {
// Legacy bridge: RediStack 1.6.3 has no async overload of set(_:to:onCondition:expiration:), so its
// EventLoopFuture is awaited with .get().
let acquired = try await app.redis.set(
RedisKey("bookshelf:scheduled:\(slot)"),
to: instance,
onCondition: .keyDoesNotExist,
expiration: .seconds(ttlSeconds)
).map { $0 == .ok }.get()
guard acquired else {
app.logger.info("Another instance owns this slot", metadata: ["slot": "\(slot)"])
return
}
try await work()
}
}
struct LockedOverdueLoanScan: AsyncScheduledJob {
var name: String { "overdue-loan-scan-locked" }
func run(context: QueueContext) async throws {
let day = Date().formatted(.iso8601.year().month().day())
let instance = Environment.get("HOSTNAME") ?? "local"
try await SlotLock(app: context.application)
.runOnce(slot: "overdue-loan-scan:\(day)", ttlSeconds: 23 * 3600, instance: instance) {
try await OverdueLoanScan().run(context: context)
}
}
}
Notes on the lock: the conditional set is the one EventLoopFuture call on this page, a legacy bridge because
neither RediStack 1.6.3 nor vapor/redis 4.14.0 has an async overload for it. Environment.get("HOSTNAME") stores
which instance won, useful when inspecting the key; the .map { $0 == .ok } turns the non-Sendable RedisSetCommandResult into a Bool before it crosses the async boundary, which avoids a
Swift 6
Sendable warning; the ISO date makes the slot the UTC day, so schedule the job and the slot in the same time zone.
This listing
compiled but was not run against a live Redis server. The Redis & Caching
page covers RedisConfiguration and the
SET key value NX pattern in more detail.
A Kubernetes CronJob and a custom command
Instead of timers, the platform can call the same code once per slot. The code is an AsyncCommand (see
Commands) that builds a QueueContext
for the scan and exits; Vapor runs a registered command by name as the process’s first argument.
import Vapor
import Queues
/// `swift run App overdue-scan` runs the same job once and exits: schedule it with cron or a Kubernetes CronJob.
struct OverdueScanCommand: AsyncCommand {
struct Signature: CommandSignature {}
var help: String { "Runs the overdue-loan scan once and exits" }
func run(using context: CommandContext, signature: Signature) async throws {
let app = context.application
let queueContext = QueueContext(
queueName: .init(string: "scheduled"),
configuration: app.queues.configuration,
application: app,
logger: app.logger,
on: app.eventLoopGroup.any()
)
try await OverdueLoanScan().run(context: queueContext)
}
}
app.asyncCommands.use(OverdueScanCommand(), as: "overdue-scan") in configure registers it, and
swift run App overdue-scan runs it. Commands themselves, Signature, arguments and options have their own page in
this
section; this one only needs the shortest form. The run printed Overdue scan finished [overdue: 0] against the empty
in-memory
database of the example. The manifest that calls it every night at 02:00 Madrid time:
apiVersion: batch/v1
kind: CronJob
metadata:
name: bookshelf-overdue-scan
spec:
schedule: "0 2 * * *"
timeZone: Europe/Madrid # field available in current Kubernetes releases
concurrencyPolicy: Forbid # skip a run while the previous one is active
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
containers:
- name: overdue-scan
image: registry.example.com/bookshelf:1.0.0 # placeholder image
args: ["overdue-scan", "--env", "production"]
env:
- name: DATABASE_URL
valueFrom: {secretKeyRef: {name: bookshelf-secrets, key: database-url}}
- name: REDIS_URL
valueFrom: {secretKeyRef: {name: bookshelf-secrets, key: redis-url}}
Source: Kubernetes: CronJob
The manifest was not applied to a cluster. The schedule is a Unix cron expression, a different dialect from the Quartz
one;
see Cron Expressions for the differences and
Jobs and CronJobs for concurrencyPolicy, timeZone and history
limits. The
database and Redis URLs are secrets read from the environment, never from the image. For the general trade-off between
an
in-process scheduler and an external one, see Triggers and
Schedules.
Testing
Test the schedule and the job body separately; neither needs a running scheduler or a queue server. The general setup is on the Testing page.
The schedule is date arithmetic. Build a ScheduleBuilder with a fixed Calendar, call nextDate(current:) with a
fixed
"now", and compare the components. This also guards against a time zone being forgotten.
The job body is an ordinary async function. Create a QueueContext from the application and call run(context:);
replace the
queue driver with the in-memory .asyncTest provider from the XCTQueues product so that dispatch stores the job in
memory
instead of Redis, and inspect app.queues.asyncTest.queue and contains(_:). The suite below was run with VaporTesting
and
Swift Testing; the test target in Package.swift needs the XCTQueues product of the queues package.
import Fluent
import Foundation
import Queues
import Testing
import VaporTesting
import XCTQueues
@testable import App
@Suite struct OverdueScanTests {
/// The schedule is plain date arithmetic: check it without starting a scheduler.
@Test func dailyAtTwoInMadrid() throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try #require(TimeZone(identifier: "Europe/Madrid"))
let builder = ScheduleBuilder(calendar: calendar)
builder.daily().at(2, 0)
let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 10, day: 17, hour: 9, minute: 30)))
let next = try #require(builder.nextDate(current: now))
let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: next)
#expect(parts.day == 18 && parts.hour == 2 && parts.minute == 0)
}
/// The job body is an ordinary async function: call it with a QueueContext and the in-memory test driver.
@Test func dispatchesOneReminderPerOverdueLoan() async throws {
let app = try await Application.make(.testing)
do {
try await configure(app)
app.queues.use(.asyncTest)
let author = Author(name: "Ursula K. Le Guin")
try await author.save(on: app.db)
let book = Book(title: "The Dispossessed", isbn: "978-0-06-051275-0", publishedYear: 1974,
priceCents: 1299, authorID: try author.requireID())
try await book.save(on: app.db)
let user = User(email: "reader@example.com", passwordHash: "placeholder")
try await user.save(on: app.db)
let now = Date()
let overdue = Loan(bookID: try book.requireID(), userID: try user.requireID(),
loanedAt: now.addingTimeInterval(-30 * 86_400), dueAt: now.addingTimeInterval(-86_400))
let current = Loan(bookID: try book.requireID(), userID: try user.requireID(),
loanedAt: now, dueAt: now.addingTimeInterval(14 * 86_400))
try await overdue.save(on: app.db)
try await current.save(on: app.db)
let context = QueueContext(
queueName: .init(string: "scheduled"),
configuration: app.queues.configuration,
application: app,
logger: app.logger,
on: app.eventLoopGroup.any()
)
try await OverdueLoanScan().run(context: context)
#expect(app.queues.asyncTest.queue.count == 1)
#expect(app.queues.asyncTest.contains(OverdueReminderJob.self))
} catch {
try await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
}
To test the scheduler itself, register a job with every(seconds: 1) and a counter, call startScheduledJobs(), wait
a few
seconds and assert the counter; keep this kind of test to a few seconds and remember to shut the application down so the
timers
are cancelled. The runs quoted on this page used exactly that.
Common mistakes
| Symptom | Cause and fix |
|---|---|
Jobs never run |
|
Reminders sent twice, three times… |
More than one process started the scheduler (several web replicas, a rolling update, a second |
A scheduler starts during |
|
Runs at the wrong hour |
The calendar uses the process time zone (UTC in a container). Pass a |
A daily 02:30 job missed one day in spring |
Daylight saving: the time does not exist on that day and the builder skips it. Move the job. |
The monthly "last day" job crashes the process |
|
The 31st job misses months |
Months without a 31st are skipped. Use day 28 or |
Only one of several jobs is cancelled at shutdown |
Jobs of the same type share the default |
|
Malformed string such as |
|
A job stores a class, a closure or the |
|
The job calls |
A long job delays its next run |
Runs are sequential per job. Dispatch the heavy work as queued jobs and keep the scheduled body short. |
A restart loses the run |
The schedule is not persisted. Keep a last-run marker in the database and have the job catch up. |
Version notes and where older material differs
-
AsyncScheduledJobis theasyncprotocol beside the future-basedScheduledJob, whoserun(context:)returns anEventLoopFuture<Void>; the future form still exists in queues 1.18.0 andAsyncScheduledJobis implemented on top of it. Older Vapor 3-era and early Vapor 4 material shows only the future form. -
The books consulted for this section target Vapor 3 and early Vapor 4 with Swift 5; none of their examples was reused. Swift 6 language mode makes the
Sendablerequirements of jobs compile-time errors. -
queues 1.18.0 documents
QueuesCommand.init(application:scheduled:)as a historical artifact whosescheduledargument has no effect; the flag that matters is--scheduledon the command line. -
Vapor 5 (5.0.0-beta.4, not released) is a major version; the page’s code targets 4.122.x. Review the Vapor 5 upgrade notes before moving the scheduler; whether
vapor/queueschanges was not verified for this page.