From c5655f2d7c4c80d7b7e7c9b9e068e200c8486028 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Fri, 9 Oct 2026 02:06:59 -0700 Subject: [PATCH] fix(job): report a missing job schema from inside a PostgreSQL transaction With jobsAutoCreateTables = false, $ensureJobTable() probed wheels_jobs and asked the catalog only after the probe failed. On PostgreSQL and CockroachDB a failed statement aborts the transaction, so inside one the catalog check then failed with "current transaction is aborted" instead of reporting Wheels.Job.SchemaMissing. There it now asks the catalog first. Also, before the 4.2.0 cut: - a spec that a job idle past the 30-second heartbeat-grace minimum is reclaimed when the setting is below it (the existing spec covers a job inside the minimum); - a spec, and a guide line, that an empty-string perform() return is stored as NULL, like a return of nothing. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- .../jobs-schema-missing-in-transaction.fixed.md | 1 + vendor/wheels/Job.cfc | 6 ++++++ .../wheels/tests/_assets/jobs/HookProbeJob.cfc | 6 +++++- .../tests/specs/jobs/JobHeartbeatGraceSpec.cfc | 8 ++++++++ .../tests/specs/jobs/JobLifecycleHooksSpec.cfc | 8 ++++++++ vendor/wheels/tests/specs/jobs/JobSchemaSpec.cfc | 16 ++++++++++++++++ .../running-jobs-on-multiple-servers.mdx | 2 +- 7 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 changelog.d/jobs-schema-missing-in-transaction.fixed.md diff --git a/changelog.d/jobs-schema-missing-in-transaction.fixed.md b/changelog.d/jobs-schema-missing-in-transaction.fixed.md new file mode 100644 index 0000000000..609b331c4f --- /dev/null +++ b/changelog.d/jobs-schema-missing-in-transaction.fixed.md @@ -0,0 +1 @@ +- With `jobsAutoCreateTables = false` on PostgreSQL or CockroachDB, a job call made inside a database transaction while the job tables are missing now reports `Wheels.Job.SchemaMissing`, pointing at `wheels jobs install`. Before, the failed table probe aborted the transaction, and the caller saw "current transaction is aborted" instead. diff --git a/vendor/wheels/Job.cfc b/vendor/wheels/Job.cfc index 260dd4bb1d..e7646a169f 100644 --- a/vendor/wheels/Job.cfc +++ b/vendor/wheels/Job.cfc @@ -1232,6 +1232,12 @@ component { * Public with $ prefix so JobWorker can bootstrap the table on a fresh database. */ public boolean function $ensureJobTable() { + // PostgreSQL and CockroachDB abort a transaction on a failed statement, so inside one the + // probe below would turn a missing table into "current transaction is aborted" for the + // catalog check after it. With auto-create off, ask the catalog first there. + if (!$jobSchema().autoCreateEnabled() && $jobSchema().databaseType() == "postgresql" && !$jobSchema().hasTable("wheels_jobs")) { + $throwJobSchemaMissing("The wheels_jobs table"); + } try { // Check if table already exists by querying it $jobsQuery("SELECT COUNT(*) AS cnt FROM wheels_jobs WHERE 1=0", {}, {datasource = variables.$datasource}); diff --git a/vendor/wheels/tests/_assets/jobs/HookProbeJob.cfc b/vendor/wheels/tests/_assets/jobs/HookProbeJob.cfc index e9f45b07e3..8f0e5c8eb3 100644 --- a/vendor/wheels/tests/_assets/jobs/HookProbeJob.cfc +++ b/vendor/wheels/tests/_assets/jobs/HookProbeJob.cfc @@ -1,7 +1,8 @@ /** * A job with every lifecycle hook (core-suite fixture). Each hook appends to a list in the * application scope so the hook specs can check what ran, in what order, with what. - * data.mode picks what perform() does: "text" (default), "struct", "void", "long" or "fail". + * data.mode picks what perform() does: "text" (default), "struct", "void", "empty" (returns ""), + * "long", "unicode", "unicodeLong" or "fail". */ component extends="wheels.Job" { @@ -38,6 +39,9 @@ component extends="wheels.Job" { if (local.mode == "void") { return; } + if (local.mode == "empty") { + return ""; + } return "done"; } diff --git a/vendor/wheels/tests/specs/jobs/JobHeartbeatGraceSpec.cfc b/vendor/wheels/tests/specs/jobs/JobHeartbeatGraceSpec.cfc index 00f16277f1..dfdf22b284 100644 --- a/vendor/wheels/tests/specs/jobs/JobHeartbeatGraceSpec.cfc +++ b/vendor/wheels/tests/specs/jobs/JobHeartbeatGraceSpec.cfc @@ -62,6 +62,14 @@ component extends="wheels.WheelsTest" { expect($status(id)).toBe("processing", "15 seconds is inside the 30-second minimum"); }); + it("reclaims a job idle past the 30-second minimum when the setting is below it", function() { + application.wheels.jobsHeartbeatGraceSeconds = 5; + var id = $insertRunning(queue = "test_grace_floor_past", heartbeatSecondsAgo = 45); + var reaped = new wheels.JobWorker().checkTimeouts(timeout = 300, queues = "test_grace_floor_past"); + expect(reaped).toBe(1); + expect($status(id)).toBe("pending", "45 seconds is past the 30-second minimum, so the job is retried"); + }); + it("renews an exclusive job's lease by the heartbeat grace window", function() { application.wheels.jobsHeartbeatGraceSeconds = 100; var job = new wheels.Job(); diff --git a/vendor/wheels/tests/specs/jobs/JobLifecycleHooksSpec.cfc b/vendor/wheels/tests/specs/jobs/JobLifecycleHooksSpec.cfc index d0894f84a7..c122556a52 100644 --- a/vendor/wheels/tests/specs/jobs/JobLifecycleHooksSpec.cfc +++ b/vendor/wheels/tests/specs/jobs/JobLifecycleHooksSpec.cfc @@ -53,6 +53,14 @@ component extends="wheels.WheelsTest" { expect(ListLast(application["$hookSpecLog"], "|")).toBe("success:none"); }); + it("stores NULL when perform() returns an empty string, as for nothing", function() { + var id = $insertJob(queue = "test_hooks_empty", data = {mode = "empty"}); + new wheels.JobWorker().processNext(queues = "test_hooks_empty", timeout = 300); + var row = $row(id); + expect(row.status).toBe("completed"); + expect(row.resultIsNull).toBeTrue("Oracle can't tell an empty string from NULL, so every database stores NULL"); + }); + it("cuts a long result short with a visible marker", function() { var id = $insertJob(queue = "test_hooks_long", data = {mode = "long"}); new wheels.JobWorker().processNext(queues = "test_hooks_long", timeout = 300); diff --git a/vendor/wheels/tests/specs/jobs/JobSchemaSpec.cfc b/vendor/wheels/tests/specs/jobs/JobSchemaSpec.cfc index 387bf905dc..034aa9dc42 100644 --- a/vendor/wheels/tests/specs/jobs/JobSchemaSpec.cfc +++ b/vendor/wheels/tests/specs/jobs/JobSchemaSpec.cfc @@ -166,6 +166,22 @@ component extends="wheels.WheelsTest" { expect(new wheels.JobSchema().hasTable("wheels_jobs")).toBeFalse(); }); + it("with jobsAutoCreateTables = false, says the schema is missing from inside a transaction too", function() { + $dropJobTable(); + application.wheels.jobsAutoCreateTables = false; + var ensured = {type = "", message = ""}; + transaction { + try { + new wheels.Job().$ensureJobTable(); + } catch (any e) { + ensured.type = e.type; + ensured.message = e.message; + } + transaction action="rollback"; + } + expect(ensured.type).toBe("Wheels.Job.SchemaMissing", "not a database error from an aborted transaction: " & ensured.message); + }); + it("with jobsAutoCreateTables = false, reports a probe failure as itself when the table exists", function() { // The catalog says the table is there but the probe fails (here: a datasource that // doesn't exist, as for a lost connection): that error surfaces, not a misleading 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 e7c5c0f531..989296dccf 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 @@ -176,7 +176,7 @@ The `event` struct deliberately carries **no job `data`** (that can hold secrets ### The stored result -`perform()`'s return value is stored on the row, in a `wheels_jobs.result` column, and handed to `onSuccess(result)`. A simple value is stored as-is; a struct or array is stored as JSON. It's capped — 4000 characters on SQL Server (`NVARCHAR`), 4000 **UTF-8 bytes** on the other databases (a safe bound; Oracle's `VARCHAR2` counts bytes) — truncated on a character boundary with a visible marker if it's longer, so return a small summary, not a large payload. The `result` column is added by the same guarded auto-`ALTER` as the other 4.2 columns; without it, jobs run exactly as before and nothing is stored. +`perform()`'s return value is stored on the row, in a `wheels_jobs.result` column, and handed to `onSuccess(result)`. A simple value is stored as-is; a struct or array is stored as JSON. Returning nothing stores `NULL`, and so does returning an empty string (Oracle can't tell the two apart, so no database does). It's capped — 4000 characters on SQL Server (`NVARCHAR`), 4000 **UTF-8 bytes** on the other databases (a safe bound; Oracle's `VARCHAR2` counts bytes) — truncated on a character boundary with a visible marker if it's longer, so return a small summary, not a large payload. The `result` column is added by the same guarded auto-`ALTER` as the other 4.2 columns; without it, jobs run exactly as before and nothing is stored. ## Cap how many jobs a server runs at once