From 57423986495a170177439d8288e21016175562cf Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 8 Oct 2026 22:35:23 -0700 Subject: [PATCH 1/3] feat(job): configure the jobs tick route's batch size, timeout and queues The /wheels/jobs/tick route ran JobRunner.tick() with its defaults, so each call ran at most one job and nothing could raise that. It now passes three settings through: jobsRunnerTickMaxJobs (default 1), jobsRunnerTickTimeout (default 300) and jobsRunnerTickQueues (default every queue). The defaults keep today's behaviour. They are settings rather than request parameters, so a caller holding the token can't widen the work one request does; an invalid value falls back to its default. Documented in the multi-server jobs guide. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- changelog.d/jobs-tick-settings.added.md | 1 + vendor/wheels/JobTickEndpoint.cfc | 27 ++++++++-- vendor/wheels/events/onapplicationstart.cfc | 6 +++ .../tests/specs/jobs/JobTickEndpointSpec.cfc | 50 +++++++++++++++++++ .../running-jobs-on-multiple-servers.mdx | 9 +++- 5 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 changelog.d/jobs-tick-settings.added.md diff --git a/changelog.d/jobs-tick-settings.added.md b/changelog.d/jobs-tick-settings.added.md new file mode 100644 index 0000000000..d384a78e8a --- /dev/null +++ b/changelog.d/jobs-tick-settings.added.md @@ -0,0 +1 @@ +- The jobs tick route (`/wheels/jobs/tick`) takes its batch from settings: `jobsRunnerTickMaxJobs` (most jobs per call, default `1`), `jobsRunnerTickTimeout` (seconds per job, default `300`) and `jobsRunnerTickQueues` (comma list, default every queue). The defaults keep today's behaviour of one job per call. They are settings rather than request parameters, so a caller holding the token can't widen the work one request does. diff --git a/vendor/wheels/JobTickEndpoint.cfc b/vendor/wheels/JobTickEndpoint.cfc index 1c82a37235..ef648a4d9d 100644 --- a/vendor/wheels/JobTickEndpoint.cfc +++ b/vendor/wheels/JobTickEndpoint.cfc @@ -10,7 +10,9 @@ * - a request carrying any forwarding header (see $forwardingHeaders()), even an empty one, * came through a proxy, so it is refused (403) whatever its token; * - a request whose method or headers can't be read is refused (500) without running a tick, - * rather than being judged on missing information. + * rather than being judged on missing information; + * - each tick runs with the jobsRunnerTickMaxJobs / jobsRunnerTickTimeout / jobsRunnerTickQueues + * settings, never with values from the request. * * handle() takes the request's parts and returns {status, contentType, body}, so the rules are * testable without HTTP. Dispatch calls it before routing and before the public-component gate. @@ -63,17 +65,19 @@ component { /** * Internal: one tick under a per-host lock taken try-once, so a call that overlaps a running - * tick on this server returns at once instead of stacking. + * tick on this server returns at once instead of stacking. Its batch size, timeout and queues + * come from the app's settings ($tickArguments()), never from the request. */ public struct function $runTick() { local.started = GetTickCount(); local.job = new wheels.Job(); local.host = local.job.$jobHostName(); + local.tickArgs = $tickArguments(); var outcome = {ran = false, summary = {}, error = ""}; lock name="wheels.jobs.tick.#local.host#" type="exclusive" timeout="1" throwOnTimeout="false" { outcome.ran = true; try { - outcome.summary = $newRunner().tick(); + outcome.summary = $newRunner().tick(argumentCollection = local.tickArgs); } catch (any e) { outcome.error = e.message; } @@ -93,6 +97,23 @@ component { return local.rv; } + /** + * Internal: the arguments each tick runs with, from settings so that a caller holding the token + * can't widen the work one request does: jobsRunnerTickMaxJobs (most jobs per call, default 1), + * jobsRunnerTickTimeout (seconds per job, default 300) and jobsRunnerTickQueues (comma list, + * default every queue). A missing or invalid value falls back to its default. + */ + public struct function $tickArguments() { + local.maxJobs = $setting("jobsRunnerTickMaxJobs", 1); + local.timeout = $setting("jobsRunnerTickTimeout", 300); + local.queues = $setting("jobsRunnerTickQueues", ""); + return { + maxJobs = IsNumeric(local.maxJobs) && local.maxJobs >= 1 ? Int(local.maxJobs) : 1, + timeout = IsNumeric(local.timeout) && local.timeout > 0 ? Int(local.timeout) : 300, + queues = IsSimpleValue(local.queues) ? Trim(local.queues) : "" + }; + } + /** * Internal: compares two tokens in time that doesn't depend on where they differ. Both are * hashed first, so the comparison is always over two 64-character digests. diff --git a/vendor/wheels/events/onapplicationstart.cfc b/vendor/wheels/events/onapplicationstart.cfc index 6469a3b2dc..dcb5c23228 100644 --- a/vendor/wheels/events/onapplicationstart.cfc +++ b/vendor/wheels/events/onapplicationstart.cfc @@ -353,6 +353,12 @@ component { // (a token in a URL ends up in access logs). application.$wheels.jobsRunnerToken = ""; application.$wheels.jobsRunnerTokenInQuery = false; + // What each call to the tick route runs (JobRunner.tick()): at most this many jobs, each + // capped at this many seconds, from these queues ("" = every queue). Settings, not request + // parameters, so a caller holding the token can't widen the work one request does. + application.$wheels.jobsRunnerTickMaxJobs = 1; + application.$wheels.jobsRunnerTickTimeout = 300; + application.$wheels.jobsRunnerTickQueues = ""; // Job schedules (config/schedules.cfm) are checked and their due slots enqueued at most // this often, from every worker poll, processQueue() and JobRunner.tick(). 0 = never. application.$wheels.jobsScheduleCheckSeconds = 15; diff --git a/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc b/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc index d9be6a929a..fef9afaf2b 100644 --- a/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc +++ b/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc @@ -20,6 +20,10 @@ component extends="wheels.WheelsTest" { afterEach(function() { application.wheels.jobsRunnerToken = ""; application.wheels.jobsRunnerTokenInQuery = false; + application.wheels.jobsRunnerTickMaxJobs = 1; + application.wheels.jobsRunnerTickTimeout = 300; + application.wheels.jobsRunnerTickQueues = ""; + queryExecute("DELETE FROM wheels_jobs WHERE queue = 'test_tick_batch'", {}, {datasource = application.wheels.dataSourceName}); }); it("recognises the route, with or without a trailing slash", function() { @@ -113,6 +117,52 @@ component extends="wheels.WheelsTest" { expect(StructKeyExists(body, "durationMs")).toBeTrue(); }); + it("runs each tick with the configured batch size, timeout and queues", function() { + application.wheels.jobsRunnerTickMaxJobs = 25; + application.wheels.jobsRunnerTickTimeout = 900; + application.wheels.jobsRunnerTickQueues = "mail, reports"; + var endpoint = new wheels.JobTickEndpoint(); + prepareMock(endpoint); + var runner = createStub(); + runner.$("tick", {processed = 0}); + endpoint.$("$newRunner", runner); + endpoint.$runTick(); + var passed = runner.$callLog().tick[1]; + expect(passed.maxJobs).toBe(25); + expect(passed.timeout).toBe(900); + expect(passed.queues).toBe("mail, reports"); + }); + + it("keeps one job, 300 seconds and every queue when nothing is set, or a setting is invalid", function() { + StructDelete(application.wheels, "jobsRunnerTickMaxJobs"); + StructDelete(application.wheels, "jobsRunnerTickTimeout"); + StructDelete(application.wheels, "jobsRunnerTickQueues"); + var endpoint = new wheels.JobTickEndpoint(); + expect(endpoint.$tickArguments()).toBe({maxJobs = 1, timeout = 300, queues = ""}); + application.wheels.jobsRunnerTickMaxJobs = 0; + application.wheels.jobsRunnerTickTimeout = "soon"; + application.wheels.jobsRunnerTickQueues = ["mail"]; + expect(endpoint.$tickArguments()).toBe({maxJobs = 1, timeout = 300, queues = ""}); + }); + + it("drains several jobs in one call when jobsRunnerTickMaxJobs is above 1", function() { + var job = new wheels.tests._assets.jobs.ProcessOrdersJob(); + for (var i = 1; i <= 3; i++) { + job.enqueue(queue = "test_tick_batch"); + } + application.wheels.jobsRunnerTickMaxJobs = 3; + application.wheels.jobsRunnerTickQueues = "test_tick_batch"; + var response = new wheels.JobTickEndpoint().handle(method = "POST", headers = {"X-Wheels-Jobs-Token" = "spec-tick-token"}); + expect(response.status).toBe(200); + expect(DeserializeJSON(response.body).processed).toBe(3); + var done = queryExecute( + "SELECT COUNT(*) AS cnt FROM wheels_jobs WHERE queue = 'test_tick_batch' AND status = 'completed'", + {}, + {datasource = application.wheels.dataSourceName} + ); + expect(done.cnt).toBe(3); + }); + it("compares tokens in full, whatever their length", function() { var endpoint = new wheels.JobTickEndpoint(); expect(endpoint.$tokensMatch("abc", "abc")).toBeTrue(); diff --git a/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx b/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx index d8e1a3d15b..7c9d94908a 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx @@ -251,12 +251,19 @@ new wheels.JobRunner().tick( ### Drive it over HTTP: the tick route -When your scheduler can only make an HTTP request — an engine scheduled task, a cron `curl`, a systemd timer — point it at `/wheels/jobs/tick`. Each call runs one `JobRunner.tick()` on that server with its defaults — so it reaps, enqueues any due schedules, and runs **at most one job** (capped at 300 s) — and returns a JSON summary. There's no request parameter to raise either limit yet ([#4520](https://github.com/wheels-dev/wheels/issues/4520) will make the route's batch size and timeout configurable), so one call a minute is one job a minute per server: fine for light, steady queues, but for volume run `wheels jobs work` (a continuous loop) or call `JobRunner.tick(maxJobs = n, timeout = …)` from CFML instead. Turn the route on by setting a token: +When your scheduler can only make an HTTP request — an engine scheduled task, a cron `curl`, a systemd timer — point it at `/wheels/jobs/tick`. Each call runs one `JobRunner.tick()` on that server — so it reaps, enqueues any due schedules, and runs a bounded batch of jobs — and returns a JSON summary. By default that batch is **one job** (capped at 300 s) from any queue, so one call a minute is one job a minute per server. Raise it with settings — not request parameters, so a caller holding the token can't widen the work one request does. For a steady high volume, `wheels jobs work` (a continuous loop) is still the better fit. Turn the route on by setting a token: ```cfm {test:compile} title="config/settings.cfm" set(jobsRunnerToken = "a-long-random-secret"); // the route is 404 until this is set + +// Optional: what each call runs (the defaults are 1, 300 and every queue). +set(jobsRunnerTickMaxJobs = 10); // at most this many jobs per call, one after another +set(jobsRunnerTickTimeout = 600); // seconds each job may run +set(jobsRunnerTickQueues = "default"); // comma list of queues; "" = every queue ``` +Pick `jobsRunnerTickMaxJobs` × the time a job takes to fit inside your scheduler's interval: the jobs in one call run one after another, and an overlapping call on the same server is skipped rather than stacked. + Then hit it with the token in a header, once a minute, from each server: ```bash title="a cron entry / systemd timer" From ba41584bd46fdd79237d601f021d2d9170a74a6b Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 8 Oct 2026 22:42:32 -0700 Subject: [PATCH 2/3] fix(job): bound the tick route's batch and timeout settings to whole-number ranges jobsRunnerTickMaxJobs takes a whole number from 1 to 1000 and jobsRunnerTickTimeout one from 1 to 86400; a fractional, out-of-range or non-numeric value falls back to the default instead of being truncated (0.5 became 0) or passed through past the INTEGER columns it reaches. The guide says a batch must fit inside the engine's request timeout, which the setting doesn't extend, and that a request killed mid-job leaves the job for the reaper to recover. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- changelog.d/jobs-tick-settings.added.md | 2 +- vendor/wheels/JobTickEndpoint.cfc | 27 ++++++++++++++---- vendor/wheels/events/onapplicationstart.cfc | 6 ++-- .../tests/specs/jobs/JobTickEndpointSpec.cfc | 28 +++++++++++++++---- .../running-jobs-on-multiple-servers.mdx | 9 ++++-- 5 files changed, 55 insertions(+), 17 deletions(-) diff --git a/changelog.d/jobs-tick-settings.added.md b/changelog.d/jobs-tick-settings.added.md index d384a78e8a..51aa4fc356 100644 --- a/changelog.d/jobs-tick-settings.added.md +++ b/changelog.d/jobs-tick-settings.added.md @@ -1 +1 @@ -- The jobs tick route (`/wheels/jobs/tick`) takes its batch from settings: `jobsRunnerTickMaxJobs` (most jobs per call, default `1`), `jobsRunnerTickTimeout` (seconds per job, default `300`) and `jobsRunnerTickQueues` (comma list, default every queue). The defaults keep today's behaviour of one job per call. They are settings rather than request parameters, so a caller holding the token can't widen the work one request does. +- The jobs tick route (`/wheels/jobs/tick`) takes its batch from settings: `jobsRunnerTickMaxJobs` (most jobs per call, `1`-`1000`, default `1`), `jobsRunnerTickTimeout` (seconds per job, `1`-`86400`, default `300`) and `jobsRunnerTickQueues` (comma list, default every queue); a fractional or out-of-range value falls back to the default. The defaults keep today's behaviour of one job per call. They are settings rather than request parameters, so a caller holding the token can't widen the work one request does. diff --git a/vendor/wheels/JobTickEndpoint.cfc b/vendor/wheels/JobTickEndpoint.cfc index ef648a4d9d..7ae50f675c 100644 --- a/vendor/wheels/JobTickEndpoint.cfc +++ b/vendor/wheels/JobTickEndpoint.cfc @@ -101,19 +101,36 @@ component { * Internal: the arguments each tick runs with, from settings so that a caller holding the token * can't widen the work one request does: jobsRunnerTickMaxJobs (most jobs per call, default 1), * jobsRunnerTickTimeout (seconds per job, default 300) and jobsRunnerTickQueues (comma list, - * default every queue). A missing or invalid value falls back to its default. + * default every queue). maxJobs must be a whole number from 1 to 1000 and timeout one from 1 to + * 86400 (a day); a missing, fractional or out-of-range value falls back to its default. */ public struct function $tickArguments() { - local.maxJobs = $setting("jobsRunnerTickMaxJobs", 1); - local.timeout = $setting("jobsRunnerTickTimeout", 300); local.queues = $setting("jobsRunnerTickQueues", ""); return { - maxJobs = IsNumeric(local.maxJobs) && local.maxJobs >= 1 ? Int(local.maxJobs) : 1, - timeout = IsNumeric(local.timeout) && local.timeout > 0 ? Int(local.timeout) : 300, + maxJobs = $wholeSetting(name = "jobsRunnerTickMaxJobs", fallback = 1, maximum = 1000), + timeout = $wholeSetting(name = "jobsRunnerTickTimeout", fallback = 300, maximum = 86400), queues = IsSimpleValue(local.queues) ? Trim(local.queues) : "" }; } + /** + * Internal: a setting that must be a whole number from 1 to `maximum` (well inside the INTEGER + * columns it can end up in, such as a claimed job's claimTimeout). Anything else (missing, not + * a number, a fraction, zero or less, or above the maximum) is the fallback. Never through + * Int(), which Lucee truncates to 32 bits. + */ + public numeric function $wholeSetting(required string name, required numeric fallback, required numeric maximum) { + local.value = $setting(arguments.name, arguments.fallback); + if (!IsSimpleValue(local.value) || !IsNumeric(local.value)) { + return arguments.fallback; + } + local.value = Val(local.value); + if (local.value < 1 || local.value > arguments.maximum || local.value != Round(local.value)) { + return arguments.fallback; + } + return local.value; + } + /** * Internal: compares two tokens in time that doesn't depend on where they differ. Both are * hashed first, so the comparison is always over two 64-character digests. diff --git a/vendor/wheels/events/onapplicationstart.cfc b/vendor/wheels/events/onapplicationstart.cfc index dcb5c23228..9d404dcb19 100644 --- a/vendor/wheels/events/onapplicationstart.cfc +++ b/vendor/wheels/events/onapplicationstart.cfc @@ -353,9 +353,9 @@ component { // (a token in a URL ends up in access logs). application.$wheels.jobsRunnerToken = ""; application.$wheels.jobsRunnerTokenInQuery = false; - // What each call to the tick route runs (JobRunner.tick()): at most this many jobs, each - // capped at this many seconds, from these queues ("" = every queue). Settings, not request - // parameters, so a caller holding the token can't widen the work one request does. + // What each call to the tick route runs (JobRunner.tick()): at most this many jobs (1-1000), + // each capped at this many seconds (1-86400), from these queues ("" = every queue). Settings, + // not request parameters, so a caller holding the token can't widen the work one request does. application.$wheels.jobsRunnerTickMaxJobs = 1; application.$wheels.jobsRunnerTickTimeout = 300; application.$wheels.jobsRunnerTickQueues = ""; diff --git a/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc b/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc index fef9afaf2b..25cc22fc3a 100644 --- a/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc +++ b/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc @@ -133,16 +133,34 @@ component extends="wheels.WheelsTest" { expect(passed.queues).toBe("mail, reports"); }); - it("keeps one job, 300 seconds and every queue when nothing is set, or a setting is invalid", function() { + it("keeps one job, 300 seconds and every queue when nothing is set", function() { StructDelete(application.wheels, "jobsRunnerTickMaxJobs"); StructDelete(application.wheels, "jobsRunnerTickTimeout"); StructDelete(application.wheels, "jobsRunnerTickQueues"); + expect(new wheels.JobTickEndpoint().$tickArguments()).toBe({maxJobs = 1, timeout = 300, queues = ""}); + }); + + it("takes maxJobs from 1 to 1000 and a timeout from 1 to 86400, whole numbers only", function() { var endpoint = new wheels.JobTickEndpoint(); - expect(endpoint.$tickArguments()).toBe({maxJobs = 1, timeout = 300, queues = ""}); - application.wheels.jobsRunnerTickMaxJobs = 0; - application.wheels.jobsRunnerTickTimeout = "soon"; + // Each setting on its own, with the other at its default. + var cases = [ + {name = "jobsRunnerTickMaxJobs", key = "maxJobs", fallback = 1, invalid = [0, -1, 0.5, 1.5, 1001, 2147483648, "soon", "", "2 jobs", [5], {n = 5}], valid = [{value = 1, expected = 1}, {value = "10", expected = 10}, {value = 1000, expected = 1000}]}, + {name = "jobsRunnerTickTimeout", key = "timeout", fallback = 300, invalid = [0, -300, 0.5, 300.25, 86401, 2147483648, "soon", "", "1 hour", [5], {n = 5}], valid = [{value = 1, expected = 1}, {value = "600", expected = 600}, {value = 86400, expected = 86400}]} + ]; + for (var c in cases) { + for (var value in c.invalid) { + application.wheels[c.name] = value; + var label = IsSimpleValue(value) ? "[" & value & "]" : "a complex value"; + expect(endpoint.$tickArguments()[c.key]).toBe(c.fallback, c.name & " " & label); + } + for (var v in c.valid) { + application.wheels[c.name] = v.value; + expect(endpoint.$tickArguments()[c.key]).toBe(v.expected, c.name & " [" & v.value & "]"); + } + application.wheels[c.name] = c.fallback; + } application.wheels.jobsRunnerTickQueues = ["mail"]; - expect(endpoint.$tickArguments()).toBe({maxJobs = 1, timeout = 300, queues = ""}); + expect(endpoint.$tickArguments().queues).toBe(""); }); it("drains several jobs in one call when jobsRunnerTickMaxJobs is above 1", function() { diff --git a/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx b/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx index 7c9d94908a..e7c5c0f531 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx @@ -257,12 +257,15 @@ When your scheduler can only make an HTTP request — an engine scheduled task, set(jobsRunnerToken = "a-long-random-secret"); // the route is 404 until this is set // Optional: what each call runs (the defaults are 1, 300 and every queue). -set(jobsRunnerTickMaxJobs = 10); // at most this many jobs per call, one after another -set(jobsRunnerTickTimeout = 600); // seconds each job may run +set(jobsRunnerTickMaxJobs = 10); // at most this many jobs per call, one after another (1-1000) +set(jobsRunnerTickTimeout = 600); // seconds each job may run (1-86400) set(jobsRunnerTickQueues = "default"); // comma list of queues; "" = every queue ``` -Pick `jobsRunnerTickMaxJobs` × the time a job takes to fit inside your scheduler's interval: the jobs in one call run one after another, and an overlapping call on the same server is skipped rather than stacked. +A fractional or out-of-range value falls back to the default. When sizing a batch: + +- **It has to fit inside one HTTP request.** The jobs in a call run one after another, inside that request, so `jobsRunnerTickMaxJobs` × the time a job takes, plus a little overhead, must fit within **your engine's request timeout**. `jobsRunnerTickTimeout` caps each job; it doesn't extend the request's timeout. It should also fit inside your scheduler's interval, because an overlapping call on the same server is skipped rather than stacked. For long or many jobs, run `wheels jobs work` (a continuous worker) instead. +- **A request the engine kills mid-job leaves that job claimed.** The reaper recovers it like any job whose worker died: once its claim or heartbeat goes stale it is retried, or, for a job with `this.idempotent = false`, marked `interrupted` (see [Idempotency and `this.idempotent`](#idempotency-and-thisidempotent)). Keep your jobs idempotent. Then hit it with the token in a header, once a minute, from each server: From 63b9c778b08e92a510ab6fa255a5ab7deb4807e0 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 8 Oct 2026 22:53:44 -0700 Subject: [PATCH 3/3] fix(job): read the tick route's numeric settings only as plain whole numbers IsNumeric() accepts exponent notation, and Val() reads only a numeric prefix, so "1e3" became 1. A setting now has to read as plain digits (an engine's ".0" tail allowed); anything else, including "1e3", "1e99" and a leading sign, falls back to the default. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- vendor/wheels/JobTickEndpoint.cfc | 20 ++++++++++++------- .../tests/specs/jobs/JobTickEndpointSpec.cfc | 4 ++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/vendor/wheels/JobTickEndpoint.cfc b/vendor/wheels/JobTickEndpoint.cfc index 7ae50f675c..d7c156398d 100644 --- a/vendor/wheels/JobTickEndpoint.cfc +++ b/vendor/wheels/JobTickEndpoint.cfc @@ -115,20 +115,26 @@ component { /** * Internal: a setting that must be a whole number from 1 to `maximum` (well inside the INTEGER - * columns it can end up in, such as a claimed job's claimTimeout). Anything else (missing, not - * a number, a fraction, zero or less, or above the maximum) is the fallback. Never through - * Int(), which Lucee truncates to 32 bits. + * columns it can end up in, such as a claimed job's claimTimeout), written as plain digits. + * Anything else (missing, a fraction, zero, a sign, exponent notation such as "1e3", or above + * the maximum) is the fallback: a value that isn't plainly a whole number is never coerced + * into one. Never through Int(), which Lucee truncates to 32 bits. */ public numeric function $wholeSetting(required string name, required numeric fallback, required numeric maximum) { local.value = $setting(arguments.name, arguments.fallback); - if (!IsSimpleValue(local.value) || !IsNumeric(local.value)) { + if (!IsSimpleValue(local.value)) { return arguments.fallback; } - local.value = Val(local.value); - if (local.value < 1 || local.value > arguments.maximum || local.value != Round(local.value)) { + // A whole number reads as digits, possibly with a ".0" tail (an engine's double). + local.text = Trim(ToString(local.value)); + if (!ReFind("^[0-9]+(\.0+)?$", local.text)) { return arguments.fallback; } - return local.value; + local.number = Val(ListFirst(local.text, ".")); + if (local.number < 1 || local.number > arguments.maximum) { + return arguments.fallback; + } + return local.number; } /** diff --git a/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc b/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc index 25cc22fc3a..4b8d059b28 100644 --- a/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc +++ b/vendor/wheels/tests/specs/jobs/JobTickEndpointSpec.cfc @@ -144,8 +144,8 @@ component extends="wheels.WheelsTest" { var endpoint = new wheels.JobTickEndpoint(); // Each setting on its own, with the other at its default. var cases = [ - {name = "jobsRunnerTickMaxJobs", key = "maxJobs", fallback = 1, invalid = [0, -1, 0.5, 1.5, 1001, 2147483648, "soon", "", "2 jobs", [5], {n = 5}], valid = [{value = 1, expected = 1}, {value = "10", expected = 10}, {value = 1000, expected = 1000}]}, - {name = "jobsRunnerTickTimeout", key = "timeout", fallback = 300, invalid = [0, -300, 0.5, 300.25, 86401, 2147483648, "soon", "", "1 hour", [5], {n = 5}], valid = [{value = 1, expected = 1}, {value = "600", expected = 600}, {value = 86400, expected = 86400}]} + {name = "jobsRunnerTickMaxJobs", key = "maxJobs", fallback = 1, invalid = [0, -1, 0.5, 1.5, 1001, 2147483648, "1e3", "1e99", "+5", "0x10", "soon", "", "2 jobs", [5], {n = 5}], valid = [{value = 1, expected = 1}, {value = "10", expected = 10}, {value = " 10 ", expected = 10}, {value = 1000, expected = 1000}]}, + {name = "jobsRunnerTickTimeout", key = "timeout", fallback = 300, invalid = [0, -300, 0.5, 300.25, 86401, 2147483648, "1e3", "1e99", "+600", "soon", "", "1 hour", [5], {n = 5}], valid = [{value = 1, expected = 1}, {value = "600", expected = 600}, {value = 86400, expected = 86400}]} ]; for (var c in cases) { for (var value in c.invalid) {