Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/jobs-work-timeout.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +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)
15 changes: 14 additions & 1 deletion cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ component extends="modules.BaseModule" {
.option(name = "max-jobs", default = 0, type = "numeric", description = "work only: stop after this many jobs (successes + failures count). 0 = run until stopped")
.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")
.flag(name = "quiet", default = false, description = "work only: suppress per-job completion output, only print failures")
.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")
.option(name = "format", default = "table", description = "status, enqueue, drain and resume: output format, table or json")
.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")
.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");
Expand Down Expand Up @@ -5828,6 +5829,7 @@ component extends="modules.BaseModule" {
maxJobs = parsed["max-jobs"],
stopWhenEmpty = parsed["stop-when-empty"],
quiet = parsed.quiet,
jobTimeout = parsed["job-timeout"],
format = lCase(trim(parsed.format)),
job = trim(parsed.job),
data = trim(parsed.data),
Expand Down Expand Up @@ -5860,6 +5862,12 @@ component extends="modules.BaseModule" {
message = "wheels jobs #opts.action# takes no value '#opts.job#'. For a wait limit write --wait=<seconds>, with an equals sign."
);
}
if (opts.jobTimeout < 0) {
throw(
type = "Wheels.InvalidArguments",
message = "--job-timeout must be zero (each job's own timeout) or a positive number of seconds."
);
}
if (opts.expires < 0) {
throw(
type = "Wheels.InvalidArguments",
Expand Down Expand Up @@ -5910,7 +5918,7 @@ component extends="modules.BaseModule" {
);
default:
out("Unknown jobs action: #opts.action#", "red");
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]");
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]");
throw(type = "Wheels.InvalidArguments", message = "Unknown jobs action: #opts.action#");
}
}
Expand All @@ -5932,10 +5940,15 @@ component extends="modules.BaseModule" {
if (len(arguments.opts.queue)) {
workUrl &= "&queues=" & urlEncodedFormat(arguments.opts.queue);
}
// Without --job-timeout the server runs each job with its own class's timeout.
if (arguments.opts.jobTimeout > 0) {
workUrl &= "&timeout=" & arguments.opts.jobTimeout;
}

out("Wheels Job Worker", "cyan");
out("Queues: " & (len(arguments.opts.queue) ? arguments.opts.queue : "all"));
out("Poll interval: #arguments.opts.interval#s");
out("Job timeout: " & (arguments.opts.jobTimeout > 0 ? "each job's own, capped at #arguments.opts.jobTimeout#s" : "each job's own timeout"));
if (arguments.opts.maxJobs > 0) {
out("Max jobs: #arguments.opts.maxJobs#");
}
Expand Down
1 change: 1 addition & 0 deletions cli/lucli/templates/app/.ai/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Run jobs with the worker. It needs this app's server running, started with `whee
wheels jobs work --queue=mailers --interval=3 # long-lived worker loop; --quiet for less output
wheels jobs work --max-jobs=10 # stops after 10 processed jobs (it keeps polling until then)
wheels jobs work --stop-when-empty # exits once no job is ready to run: one-shot batches from cron/CI
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
wheels jobs status [--queue=mailers] [--format=json]
wheels jobs enqueue SendWelcomeEmailJob --data='{"userId":42}' # enqueue from the CLI (class under app/jobs/)
wheels jobs enqueue SendWelcomeEmailJob --in=300 # or --at=2026-10-06T09:00:00Z; a delayed run, not cron
Expand Down
42 changes: 42 additions & 0 deletions cli/lucli/tests/specs/commands/JobsCommandSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,48 @@ component extends="wheels.wheelstest.system.BaseSpec" {

});

describe("work --job-timeout", () => {

it("defaults to 0, each job's own timeout", () => {
expect(mod.$parseJobsArgs({arg1 = "work"}).jobTimeout).toBe(0);
});

it("parses --job-timeout=<seconds>", () => {
expect(mod.$parseJobsArgs({arg1 = "work", "job-timeout" = "1800"}).jobTimeout).toBe(1800);
});

it("refuses a negative --job-timeout", () => {
expect(() => mod.$parseJobsArgs({arg1 = "work", "job-timeout" = "-1"})).toThrow(type = "Wheels.InvalidArguments");
});

it("doesn't send a timeout without --job-timeout, so each job runs with its own", () => {
var m = workerModule([idlePoll()]);
m.jobs(arg1 = "work", "stop-when-empty" = "true");
expect(m.$callLog().makeBridgePost[1][1]).notToInclude("timeout=");
expect(printed(m)).toInclude("each job's own timeout");
});

it("names no option after a LuCLI root option, which the runtime would take before the module", () => {
// LuCLI's root command owns --timeout (it stops the whole command), --env and --envfile;
// `wheels test` uses --test-timeout for the same reason.
var m = new cli.lucli.Module(cwd = variables.tempRoot);
makePublic(m, "$commandOptionLines", "$jobsOptionLines");
var help = arrayToList(m.$jobsOptionLines("jobs"), chr(10));
expect(help).toInclude("--job-timeout");
for (var rootOption in ["timeout", "env", "envfile", "verbose", "debug", "timing", "whitespace"]) {
expect(reFind("(^|\s)--#rootOption#[\s=<]", help)).toBe(0, "jobs must not define --#rootOption#");
}
});

it("sends --job-timeout to the server as the cap", () => {
var m = workerModule([idlePoll()]);
m.jobs(arg1 = "work", "stop-when-empty" = "true", "job-timeout" = "1800");
expect(m.$callLog().makeBridgePost[1][1]).toInclude("&timeout=1800");
expect(printed(m)).toInclude("1800s");
});

});

describe("$formatJobsStatusTable — status rendering", () => {

it("reports no jobs for an empty stats struct", () => {
Expand Down
1 change: 1 addition & 0 deletions docs/consumer-ai/.ai/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Run jobs with the worker. It needs this app's server running, started with `whee
wheels jobs work --queue=mailers --interval=3 # long-lived worker loop; --quiet for less output
wheels jobs work --max-jobs=10 # stops after 10 processed jobs (it keeps polling until then)
wheels jobs work --stop-when-empty # exits once no job is ready to run: one-shot batches from cron/CI
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
wheels jobs status [--queue=mailers] [--format=json]
wheels jobs enqueue SendWelcomeEmailJob --data='{"userId":42}' # enqueue from the CLI (class under app/jobs/)
wheels jobs enqueue SendWelcomeEmailJob --in=300 # or --at=2026-10-06T09:00:00Z; a delayed run, not cron
Expand Down
9 changes: 7 additions & 2 deletions vendor/wheels/public/CliBridge.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -816,8 +816,13 @@ component output="false" displayName="CLI Bridge" {
try {
local.worker = new wheels.JobWorker();
local.jobQueues = structKeyExists(arguments.params, "queues") ? arguments.params.queues : "";
local.jobTimeout = structKeyExists(arguments.params, "timeout") ? val(arguments.params.timeout) : 300;
local.jobResult = local.worker.processNext(queues = local.jobQueues, timeout = local.jobTimeout);
// Each job runs with its own class's timeout, as processQueue() does; `wheels jobs work
// --timeout` passes a cap. 300 is the timeout for a job whose class can't be loaded.
local.worker.perJobTimeout = true;
local.worker.timeoutCap = structKeyExists(arguments.params, "timeout") && val(arguments.params.timeout) > 0 ? val(arguments.params.timeout) : 0;
local.pollTimeout = local.worker.timeoutCap > 0 ? local.worker.timeoutCap : 300;
local.worker.legacyReapTimeout = Max(local.worker.timeoutCap, 300);
local.jobResult = local.worker.processNext(queues = local.jobQueues, timeout = local.pollTimeout);
local.rv.success = true;
local.rv.jobResult = local.jobResult;
local.rv.message = local.jobResult.skipped ? "No jobs available" : "Processed job #local.jobResult.jobId#";
Expand Down
90 changes: 90 additions & 0 deletions vendor/wheels/tests/specs/jobs/CliJobsProcessNextTimeoutSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* `wheels jobs work` runs each job with its own class's timeout (4517). The CLI worker polls the
* jobsProcessNext bridge command, which used to default to timeout=300 and so cut every job off
* at 300 seconds whatever its this.timeout. It now claims and runs each job with its own
* timeout, like processQueue(), unless the CLI passes --timeout as a cap. The spec reads the
* timeout each claim recorded (claimTimeout) rather than waiting 300+ seconds.
*/
component extends="wheels.WheelsTest" {

function run() {

describe("jobsProcessNext (wheels jobs work) timeouts", function() {

beforeEach(function() {
var bootstrapJob = new wheels.Job();
bootstrapJob.$ensureJobTable();
$cleanup();
});

afterEach(function() {
$cleanup();
});

it("claims a job with its own class's timeout when no cap is given", function() {
var id = $insertJob(queue = "test_cpn_own", jobClass = "wheels.tests._assets.jobs.LongTimeoutJob");
var rv = $processNext({queues = "test_cpn_own"});
expect(rv.success).toBeTrue(rv.message ?: "");
expect(Val($claimTimeout(id))).toBe(900, "not the bridge's old 300-second default");
});

it("caps the timeout when --timeout is passed", function() {
var id = $insertJob(queue = "test_cpn_cap", jobClass = "wheels.tests._assets.jobs.LongTimeoutJob");
$processNext({queues = "test_cpn_cap", timeout = 120});
expect(Val($claimTimeout(id))).toBe(120);
});

it("keeps a default-timeout job at 300 seconds", function() {
var id = $insertJob(queue = "test_cpn_default");
$processNext({queues = "test_cpn_default"});
expect(Val($claimTimeout(id))).toBe(300);
});

});

}

private struct function $processNext(required struct params) {
var bridge = new wheels.public.CliBridge();
return bridge.dispatch(command = "jobsProcessNext", context = {}, params = arguments.params);
}

private void function $cleanup() {
try {
queryExecute("DELETE FROM wheels_jobs WHERE queue LIKE 'test_cpn_%'", {}, {datasource = application.wheels.dataSourceName});
} catch (any e) {
}
}

private string function $insertJob(
required string queue,
string jobClass = "wheels.tests._assets.jobs.ProcessOrdersJob"
) {
var id = CreateUUID();
var stamp = DateAdd("s", -5, Now());
queryExecute(
"INSERT INTO wheels_jobs (id, jobClass, queue, data, priority, status, attempts, maxRetries, runAt, createdAt, updatedAt)
VALUES (:id, :jobClass, :queue, '{}', 0, 'pending', 0, 3, :runAt, :createdAt, :updatedAt)",
{
id = {value = id, cfsqltype = "cf_sql_varchar"},
jobClass = {value = arguments.jobClass, cfsqltype = "cf_sql_varchar"},
queue = {value = arguments.queue, cfsqltype = "cf_sql_varchar"},
runAt = {value = stamp, cfsqltype = "cf_sql_timestamp"},
createdAt = {value = stamp, cfsqltype = "cf_sql_timestamp"},
updatedAt = {value = stamp, cfsqltype = "cf_sql_timestamp"}
},
{datasource = application.wheels.dataSourceName}
);
return id;
}

private string function $claimTimeout(required string id) {
var q = queryExecute(
"SELECT claimTimeout FROM wheels_jobs WHERE id = :id",
{id = {value = arguments.id, cfsqltype = "cf_sql_varchar"}},
{datasource = application.wheels.dataSourceName}
);
return q.recordCount && !IsNull(q.claimTimeout[1]) ? q.claimTimeout[1] : "";
}

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

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`.

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.

Check queue health without tailing logs:
Expand Down
Loading