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 c5c62714d4..a935ea5c9a 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