Skip to content

Commit 7f4dc07

Browse files
bpamiriclaude
andauthored
fix(cli): let wheels jobs work run each job with its own timeout (#4518)
* fix(cli): let wheels jobs work run each job with its own timeout The jobsProcessNext bridge command behind `wheels jobs work` defaulted to timeout=300, and the worker runs a job for Min(its own timeout, the poll's), so every job was cut off at 300 seconds whatever its this.timeout. The bridge now sets up the worker the way processQueue() does: each job is claimed and run with its own class's timeout (recorded as claimTimeout), and `wheels jobs work --timeout=<seconds>` passes an explicit cap. Rows that recorded no claimTimeout are still never reaped inside 300 seconds. Documents --timeout in the background-jobs guide and the consumer AI docs. Fixes #4517 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(cli): rename the jobs work cap to --job-timeout --timeout is the LuCLI runtime's own root option ("fail if the command runs longer than N seconds"). On runtimes without the module-timeout fix it is taken before dispatch, so `wheels jobs work --timeout=600` would stop the whole worker after 600 seconds instead of capping each job. Use --job-timeout, as `wheels test` uses --test-timeout, and add a spec that no jobs option is named after a LuCLI root option. Refs #4517 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a6c2d00 commit 7f4dc07

8 files changed

Lines changed: 159 additions & 3 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- `wheels jobs work` no longer cuts every job off at 300 seconds: each job now runs with its own `this.timeout`, like `processQueue()`. The new `--job-timeout=<seconds>` option caps it. (#4517)

‎cli/lucli/Module.cfc‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,7 @@ component extends="modules.BaseModule" {
597597
.option(name = "max-jobs", default = 0, type = "numeric", description = "work only: stop after this many jobs (successes + failures count). 0 = run until stopped")
598598
.flag(name = "stop-when-empty", default = false, description = "work only: exit when a poll finds no job ready to run, instead of waiting for more. For one-shot batches from cron or CI; combines with --max-jobs")
599599
.flag(name = "quiet", default = false, description = "work only: suppress per-job completion output, only print failures")
600+
.option(name = "job-timeout", default = 0, type = "numeric", description = "work only: cap, in seconds, on each job's run time. 0 = each job runs with its own timeout (this.timeout, default 300). Not --timeout: that is the runtime's own option and stops the whole worker")
600601
.option(name = "format", default = "table", description = "status, enqueue, drain and resume: output format, table or json")
601602
.option(name = "wait", default = "", type = "any", description = "drain only: after draining, wait until this server has no running jobs (--wait = up to 600 seconds, --wait=<seconds> for another limit). Exits with an error if jobs are still running at the limit")
602603
.option(name = "expires", default = 3600, type = "numeric", description = "drain only: seconds until the drain lifts itself if nothing resumes it. 0 = until wheels jobs resume");
@@ -5828,6 +5829,7 @@ component extends="modules.BaseModule" {
58285829
maxJobs = parsed["max-jobs"],
58295830
stopWhenEmpty = parsed["stop-when-empty"],
58305831
quiet = parsed.quiet,
5832+
jobTimeout = parsed["job-timeout"],
58315833
format = lCase(trim(parsed.format)),
58325834
job = trim(parsed.job),
58335835
data = trim(parsed.data),
@@ -5860,6 +5862,12 @@ component extends="modules.BaseModule" {
58605862
message = "wheels jobs #opts.action# takes no value '#opts.job#'. For a wait limit write --wait=<seconds>, with an equals sign."
58615863
);
58625864
}
5865+
if (opts.jobTimeout < 0) {
5866+
throw(
5867+
type = "Wheels.InvalidArguments",
5868+
message = "--job-timeout must be zero (each job's own timeout) or a positive number of seconds."
5869+
);
5870+
}
58635871
if (opts.expires < 0) {
58645872
throw(
58655873
type = "Wheels.InvalidArguments",
@@ -5910,7 +5918,7 @@ component extends="modules.BaseModule" {
59105918
);
59115919
default:
59125920
out("Unknown jobs action: #opts.action#", "red");
5913-
out("Usage: wheels jobs [work|status|enqueue <JobName>|drain|resume] [--queue=<names>] [--interval=<seconds>] [--max-jobs=<n>] [--stop-when-empty] [--quiet] [--wait[=<seconds>]] [--expires=<seconds>] [--format=table|json]");
5921+
out("Usage: wheels jobs [work|status|enqueue <JobName>|drain|resume] [--queue=<names>] [--interval=<seconds>] [--max-jobs=<n>] [--stop-when-empty] [--quiet] [--job-timeout=<seconds>] [--wait[=<seconds>]] [--expires=<seconds>] [--format=table|json]");
59145922
throw(type = "Wheels.InvalidArguments", message = "Unknown jobs action: #opts.action#");
59155923
}
59165924
}
@@ -5932,10 +5940,15 @@ component extends="modules.BaseModule" {
59325940
if (len(arguments.opts.queue)) {
59335941
workUrl &= "&queues=" & urlEncodedFormat(arguments.opts.queue);
59345942
}
5943+
// Without --job-timeout the server runs each job with its own class's timeout.
5944+
if (arguments.opts.jobTimeout > 0) {
5945+
workUrl &= "&timeout=" & arguments.opts.jobTimeout;
5946+
}
59355947

59365948
out("Wheels Job Worker", "cyan");
59375949
out("Queues: " & (len(arguments.opts.queue) ? arguments.opts.queue : "all"));
59385950
out("Poll interval: #arguments.opts.interval#s");
5951+
out("Job timeout: " & (arguments.opts.jobTimeout > 0 ? "each job's own, capped at #arguments.opts.jobTimeout#s" : "each job's own timeout"));
59395952
if (arguments.opts.maxJobs > 0) {
59405953
out("Max jobs: #arguments.opts.maxJobs#");
59415954
}

‎cli/lucli/templates/app/.ai/jobs.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Run jobs with the worker. It needs this app's server running, started with `whee
3838
wheels jobs work --queue=mailers --interval=3 # long-lived worker loop; --quiet for less output
3939
wheels jobs work --max-jobs=10 # stops after 10 processed jobs (it keeps polling until then)
4040
wheels jobs work --stop-when-empty # exits once no job is ready to run: one-shot batches from cron/CI
41+
wheels jobs work --job-timeout=600 # caps each job at 600s (not --timeout: that stops the worker); without it each job runs with its own this.timeout
4142
wheels jobs status [--queue=mailers] [--format=json]
4243
wheels jobs enqueue SendWelcomeEmailJob --data='{"userId":42}' # enqueue from the CLI (class under app/jobs/)
4344
wheels jobs enqueue SendWelcomeEmailJob --in=300 # or --at=2026-10-06T09:00:00Z; a delayed run, not cron

‎cli/lucli/tests/specs/commands/JobsCommandSpec.cfc‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,48 @@ component extends="wheels.wheelstest.system.BaseSpec" {
206206

207207
});
208208

209+
describe("work --job-timeout", () => {
210+
211+
it("defaults to 0, each job's own timeout", () => {
212+
expect(mod.$parseJobsArgs({arg1 = "work"}).jobTimeout).toBe(0);
213+
});
214+
215+
it("parses --job-timeout=<seconds>", () => {
216+
expect(mod.$parseJobsArgs({arg1 = "work", "job-timeout" = "1800"}).jobTimeout).toBe(1800);
217+
});
218+
219+
it("refuses a negative --job-timeout", () => {
220+
expect(() => mod.$parseJobsArgs({arg1 = "work", "job-timeout" = "-1"})).toThrow(type = "Wheels.InvalidArguments");
221+
});
222+
223+
it("doesn't send a timeout without --job-timeout, so each job runs with its own", () => {
224+
var m = workerModule([idlePoll()]);
225+
m.jobs(arg1 = "work", "stop-when-empty" = "true");
226+
expect(m.$callLog().makeBridgePost[1][1]).notToInclude("timeout=");
227+
expect(printed(m)).toInclude("each job's own timeout");
228+
});
229+
230+
it("names no option after a LuCLI root option, which the runtime would take before the module", () => {
231+
// LuCLI's root command owns --timeout (it stops the whole command), --env and --envfile;
232+
// `wheels test` uses --test-timeout for the same reason.
233+
var m = new cli.lucli.Module(cwd = variables.tempRoot);
234+
makePublic(m, "$commandOptionLines", "$jobsOptionLines");
235+
var help = arrayToList(m.$jobsOptionLines("jobs"), chr(10));
236+
expect(help).toInclude("--job-timeout");
237+
for (var rootOption in ["timeout", "env", "envfile", "verbose", "debug", "timing", "whitespace"]) {
238+
expect(reFind("(^|\s)--#rootOption#[\s=<]", help)).toBe(0, "jobs must not define --#rootOption#");
239+
}
240+
});
241+
242+
it("sends --job-timeout to the server as the cap", () => {
243+
var m = workerModule([idlePoll()]);
244+
m.jobs(arg1 = "work", "stop-when-empty" = "true", "job-timeout" = "1800");
245+
expect(m.$callLog().makeBridgePost[1][1]).toInclude("&timeout=1800");
246+
expect(printed(m)).toInclude("1800s");
247+
});
248+
249+
});
250+
209251
describe("$formatJobsStatusTable — status rendering", () => {
210252

211253
it("reports no jobs for an empty stats struct", () => {

‎docs/consumer-ai/.ai/jobs.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Run jobs with the worker. It needs this app's server running, started with `whee
3838
wheels jobs work --queue=mailers --interval=3 # long-lived worker loop; --quiet for less output
3939
wheels jobs work --max-jobs=10 # stops after 10 processed jobs (it keeps polling until then)
4040
wheels jobs work --stop-when-empty # exits once no job is ready to run: one-shot batches from cron/CI
41+
wheels jobs work --job-timeout=600 # caps each job at 600s (not --timeout: that stops the worker); without it each job runs with its own this.timeout
4142
wheels jobs status [--queue=mailers] [--format=json]
4243
wheels jobs enqueue SendWelcomeEmailJob --data='{"userId":42}' # enqueue from the CLI (class under app/jobs/)
4344
wheels jobs enqueue SendWelcomeEmailJob --in=300 # or --at=2026-10-06T09:00:00Z; a delayed run, not cron

‎vendor/wheels/public/CliBridge.cfc‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -816,8 +816,13 @@ component output="false" displayName="CLI Bridge" {
816816
try {
817817
local.worker = new wheels.JobWorker();
818818
local.jobQueues = structKeyExists(arguments.params, "queues") ? arguments.params.queues : "";
819-
local.jobTimeout = structKeyExists(arguments.params, "timeout") ? val(arguments.params.timeout) : 300;
820-
local.jobResult = local.worker.processNext(queues = local.jobQueues, timeout = local.jobTimeout);
819+
// Each job runs with its own class's timeout, as processQueue() does; `wheels jobs work
820+
// --timeout` passes a cap. 300 is the timeout for a job whose class can't be loaded.
821+
local.worker.perJobTimeout = true;
822+
local.worker.timeoutCap = structKeyExists(arguments.params, "timeout") && val(arguments.params.timeout) > 0 ? val(arguments.params.timeout) : 0;
823+
local.pollTimeout = local.worker.timeoutCap > 0 ? local.worker.timeoutCap : 300;
824+
local.worker.legacyReapTimeout = Max(local.worker.timeoutCap, 300);
825+
local.jobResult = local.worker.processNext(queues = local.jobQueues, timeout = local.pollTimeout);
821826
local.rv.success = true;
822827
local.rv.jobResult = local.jobResult;
823828
local.rv.message = local.jobResult.skipped ? "No jobs available" : "Processed job #local.jobResult.jobId#";
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* `wheels jobs work` runs each job with its own class's timeout (4517). The CLI worker polls the
3+
* jobsProcessNext bridge command, which used to default to timeout=300 and so cut every job off
4+
* at 300 seconds whatever its this.timeout. It now claims and runs each job with its own
5+
* timeout, like processQueue(), unless the CLI passes --timeout as a cap. The spec reads the
6+
* timeout each claim recorded (claimTimeout) rather than waiting 300+ seconds.
7+
*/
8+
component extends="wheels.WheelsTest" {
9+
10+
function run() {
11+
12+
describe("jobsProcessNext (wheels jobs work) timeouts", function() {
13+
14+
beforeEach(function() {
15+
var bootstrapJob = new wheels.Job();
16+
bootstrapJob.$ensureJobTable();
17+
$cleanup();
18+
});
19+
20+
afterEach(function() {
21+
$cleanup();
22+
});
23+
24+
it("claims a job with its own class's timeout when no cap is given", function() {
25+
var id = $insertJob(queue = "test_cpn_own", jobClass = "wheels.tests._assets.jobs.LongTimeoutJob");
26+
var rv = $processNext({queues = "test_cpn_own"});
27+
expect(rv.success).toBeTrue(rv.message ?: "");
28+
expect(Val($claimTimeout(id))).toBe(900, "not the bridge's old 300-second default");
29+
});
30+
31+
it("caps the timeout when --timeout is passed", function() {
32+
var id = $insertJob(queue = "test_cpn_cap", jobClass = "wheels.tests._assets.jobs.LongTimeoutJob");
33+
$processNext({queues = "test_cpn_cap", timeout = 120});
34+
expect(Val($claimTimeout(id))).toBe(120);
35+
});
36+
37+
it("keeps a default-timeout job at 300 seconds", function() {
38+
var id = $insertJob(queue = "test_cpn_default");
39+
$processNext({queues = "test_cpn_default"});
40+
expect(Val($claimTimeout(id))).toBe(300);
41+
});
42+
43+
});
44+
45+
}
46+
47+
private struct function $processNext(required struct params) {
48+
var bridge = new wheels.public.CliBridge();
49+
return bridge.dispatch(command = "jobsProcessNext", context = {}, params = arguments.params);
50+
}
51+
52+
private void function $cleanup() {
53+
try {
54+
queryExecute("DELETE FROM wheels_jobs WHERE queue LIKE 'test_cpn_%'", {}, {datasource = application.wheels.dataSourceName});
55+
} catch (any e) {
56+
}
57+
}
58+
59+
private string function $insertJob(
60+
required string queue,
61+
string jobClass = "wheels.tests._assets.jobs.ProcessOrdersJob"
62+
) {
63+
var id = CreateUUID();
64+
var stamp = DateAdd("s", -5, Now());
65+
queryExecute(
66+
"INSERT INTO wheels_jobs (id, jobClass, queue, data, priority, status, attempts, maxRetries, runAt, createdAt, updatedAt)
67+
VALUES (:id, :jobClass, :queue, '{}', 0, 'pending', 0, 3, :runAt, :createdAt, :updatedAt)",
68+
{
69+
id = {value = id, cfsqltype = "cf_sql_varchar"},
70+
jobClass = {value = arguments.jobClass, cfsqltype = "cf_sql_varchar"},
71+
queue = {value = arguments.queue, cfsqltype = "cf_sql_varchar"},
72+
runAt = {value = stamp, cfsqltype = "cf_sql_timestamp"},
73+
createdAt = {value = stamp, cfsqltype = "cf_sql_timestamp"},
74+
updatedAt = {value = stamp, cfsqltype = "cf_sql_timestamp"}
75+
},
76+
{datasource = application.wheels.dataSourceName}
77+
);
78+
return id;
79+
}
80+
81+
private string function $claimTimeout(required string id) {
82+
var q = queryExecute(
83+
"SELECT claimTimeout FROM wheels_jobs WHERE id = :id",
84+
{id = {value = arguments.id, cfsqltype = "cf_sql_varchar"}},
85+
{datasource = application.wheels.dataSourceName}
86+
);
87+
return q.recordCount && !IsNull(q.claimTimeout[1]) ? q.claimTimeout[1] : "";
88+
}
89+
90+
}

‎web/sites/guides/src/content/docs/v4-2-0/digging-deeper/background-jobs.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,11 @@ wheels jobs work --queue=mailers --interval=3 # poll every 3 seconds when idle
217217
wheels jobs work --stop-when-empty # exit once no job is ready to run — one-shot batches from cron or CI
218218
wheels jobs work --max-jobs=100 # stop after 100 jobs (combine with --stop-when-empty to cap a batch)
219219
wheels jobs work --quiet # suppress per-job output; failures still print
220+
wheels jobs work --job-timeout=600 # cap each job's run time at 600 seconds (default: each job's own timeout)
220221
```
221222

223+
Each job runs with its own `timeout` (set in `config()`, default `300` seconds), so a job with `this.timeout = 900` gets its full 15 minutes. `--job-timeout=<seconds>` caps that: a job with a longer `timeout` is cut off at the cap, and one with a shorter `timeout` keeps its own. (Don't use `--timeout`: that is the CLI runtime's own option, which stops the whole worker after that many seconds.) Before 4.2 the worker stopped every job at 300 seconds, whatever its `timeout`.
224+
222225
In production, run the worker under a process supervisor (systemd, Docker's restart policy, Kamal accessory containers): when it loses the server it exits non-zero and the supervisor restarts it. Run several workers for parallelism — the framework uses optimistic row locking, so two workers never run the same job twice. `--queue` accepts a comma-delimited list (`--queue=critical,default`); jobs across the listed queues run in `priority DESC, runAt ASC` order.
223226

224227
Check queue health without tailing logs:

0 commit comments

Comments
 (0)