Skip to content
1 change: 1 addition & 0 deletions changelog.d/4484-jobs-install.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `wheels jobs install` writes a migration that creates the background-job tables (`wheels_jobs`, `wheels_job_hosts`, `wheels_job_schedules`, `wheels_job_locks`), so an app can apply the jobs schema once, reviewed and under migration control, instead of having every server create and alter it at startup. The migration adopts tables Wheels already created automatically, adding only what they lack, and running it again changes nothing. Apps that use it set `set(jobsAutoCreateTables = false)`, which stops the framework from creating or altering the job tables at runtime; a missing table then raises `Wheels.Job.SchemaMissing` from enqueue and from a worker's poll, with a pointer to `wheels jobs install`. Auto-create stays the default (`jobsAutoCreateTables = true`). The migration's `down()` drops the job tables, including ones it adopted, and every job in them. The job schema is now defined once (`wheels.JobSchema`), so the migration and auto-create build the same tables. ([#4484](https://github.com/wheels-dev/wheels/issues/4484))
67 changes: 62 additions & 5 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ component extends="modules.BaseModule" {

private any function jobsArgSpec() {
return new services.ArgSpec()
.positional(name = "action", default = "status", description = "work (long-lived worker loop), status (queue snapshot), enqueue (add a job), drain (stop this server starting new jobs) or resume. Defaults to status")
.positional(name = "action", default = "status", description = "work (long-lived worker loop), status (queue snapshot), enqueue (add a job), drain (stop this server starting new jobs), resume, or install (write a migration that creates the job tables). Defaults to status")
.positional(name = "job", default = "", description = "enqueue only: the job class under app/jobs/, e.g. SendWelcomeEmailJob or billing.InvoiceJob")
.option(name = "queue", default = "", description = "work: comma-delimited queue names to process in order. status: single queue to filter by. enqueue: the queue to put the job on (default: the job's own). Empty = all queues")
.option(name = "data", default = "", description = "enqueue only: the job's data, a JSON object passed to perform()")
Expand All @@ -600,7 +600,8 @@ component extends="modules.BaseModule" {
.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");
.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")
.flag(name = "force", default = false, description = "install only: overwrite the jobs migration this command wrote before");
}

private any function dbArgSpec() {
Expand Down Expand Up @@ -5837,7 +5838,8 @@ component extends="modules.BaseModule" {
"in" = trim(parsed["in"]),
at = trim(parsed.at),
wait = $jobsWaitSeconds(trim(parsed.wait)),
expires = parsed.expires
expires = parsed.expires,
force = parsed.force
};
if (!len(opts.action)) {
opts.action = "status";
Expand Down Expand Up @@ -5884,7 +5886,7 @@ component extends="modules.BaseModule" {
}

/**
* hint: Background job queue — `work` runs a long-lived worker loop, `status` prints per-queue counts and this server's running jobs, cap and drain (--format=json for machines), `enqueue <JobName>` adds a job now or after a delay, `drain [--wait]` stops this server starting new jobs (for deploys) and `resume` lifts it. retry/purge/monitor are tracked follow-ups (issue 3090).
* hint: Background job queue — `work` runs a long-lived worker loop, `status` prints per-queue counts and this server's running jobs, cap and drain (--format=json for machines), `enqueue <JobName>` adds a job now or after a delay, `drain [--wait]` stops this server starting new jobs (for deploys) and `resume` lifts it, `install` writes a migration that creates the job tables. retry/purge/monitor are tracked follow-ups (issue 3090).
*/
public string function jobs() {
var opts = $parseJobsArgs(structuredArgs(arguments));
Expand All @@ -5900,6 +5902,8 @@ component extends="modules.BaseModule" {
return runJobsDrain(opts);
case "resume":
return runJobsResume(opts);
case "install":
return runJobsInstall(opts);
// The framework bridge (vendor/wheels/public/views/cli.cfm) already
// implements jobsRetry/jobsPurge/jobsMonitor — the CLI verbs are
// deliberate follow-ups tracked in ##3090. Fail loudly with the
Expand All @@ -5918,7 +5922,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] [--job-timeout=<seconds>] [--wait[=<seconds>]] [--expires=<seconds>] [--format=table|json]");
out("Usage: wheels jobs [work|status|enqueue <JobName>|drain|resume|install [--force]] [--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 Down Expand Up @@ -6020,6 +6024,59 @@ component extends="modules.BaseModule" {
}
}

/**
* `wheels jobs install`: write app/migrator/migrations/<timestamp>_CreateWheelsJobTables.cfc,
* the migration that creates the job tables (or tops up the ones Wheels already created), so a
* multi-server app applies the jobs schema once, under migration control. The source comes from
* the running app (wheels.JobSchema) so it matches what auto-create builds; nothing touches the
* database until `wheels migrate latest`.
*/
private string function runJobsInstall(required struct opts) {
var serverPort = $requireRunningServer(
hints = ["Start one with: wheels start (the migration is generated from the app's own framework version)"]
);
var httpResult = "";
try {
httpResult = makeHttpRequest("#$serverUrlBase(serverPort)#/wheels/cli?command=jobsInstallSource&format=json");
} catch (any httpErr) {
throw(
type = "Wheels.Cli.CommandFailed",
message = "Jobs install failed (connection error): #httpErr.message#",
detail = httpErr.detail ?: ""
);
}
var result = parseCliResponse(httpResult, "Jobs install");
if (!len(result.source ?: "")) {
throw(type = "Wheels.Cli.CommandFailed", message = "Jobs install: the server returned no migration source.");
}

var migrationDir = variables.projectRoot & "/app/migrator/migrations";
$ensureProjectDirectory(migrationDir);
var existing = directoryList(migrationDir, false, "name", "*_#result.migrationName#.cfc");
var fileName = "";
if (arrayLen(existing)) {
if (!arguments.opts.force) {
out("The jobs migration already exists: app/migrator/migrations/#existing[1]#", "yellow");
out("Run it with: wheels migrate latest. To rewrite it from this framework version, pass --force.");
return "";
}
// Same file name, so a migration that already ran keeps its version.
fileName = existing[1];
} else {
fileName = getService("helpers").generateMigrationTimestamp() & "_" & result.migrationName & ".cfc";
}
fileWrite(migrationDir & "/" & fileName, result.source);

out("Wrote app/migrator/migrations/#fileName#", "green");
out("");
out("Next:");
out(" 1. Review it, then apply it: wheels migrate latest");
out(" It creates the job tables, or adds what existing ones are missing.");
out(" 2. In config/settings.cfm: set(jobsAutoCreateTables = false)");
out(" so servers never change the job tables at startup.");
return "";
}

/**
* Print why the worker loop is ending, plus its counts, and return "" for
* runJobsWork() to return.
Expand Down
45 changes: 45 additions & 0 deletions cli/lucli/tests/specs/commands/JobsCommandSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ component extends="wheels.wheelstest.system.BaseSpec" {
return serializeJSON({success: true, host: {host: "spec-host", running: arguments.running, maxConcurrent: 0, draining: arguments.draining, drainExpiresAt: ""}});
}

// A Module whose `jobs install` gets `source` back from a canned jobsInstallSource response.
private any function installModule(required string source) {
var m = new cli.lucli.Module(cwd = variables.tempRoot);
prepareMock(m);
m.$("out");
m.$(method = "$requireRunningServer", returns = 61999);
m.$("makeHttpRequest", serializeJSON({success: true, migrationName: "CreateWheelsJobTables", source: arguments.source}));
return m;
}

private string function printed(required any m) {
var said = "";
for (var call in arguments.m.$callLog().out) {
Expand Down Expand Up @@ -358,6 +368,41 @@ component extends="wheels.wheelstest.system.BaseSpec" {

});

describe("install", () => {

it("parses install and --force", () => {
expect(mod.$parseJobsArgs({arg1 = "install"}).action).toBe("install");
expect(mod.$parseJobsArgs({arg1 = "install"}).force).toBeFalse();
expect(mod.$parseJobsArgs({arg1 = "install", force = "true"}).force).toBeTrue();
});

it("writes the migration the server generates, once, and rewrites it in place with --force", () => {
var dir = variables.tempRoot & "/app/migrator/migrations";
if (directoryExists(dir)) {
for (var old in directoryList(dir, false, "path", "*_CreateWheelsJobTables.cfc")) {
fileDelete(old);
}
}
var m = installModule("// first");
m.jobs(arg1 = "install");
var written = directoryList(dir, false, "name", "*_CreateWheelsJobTables.cfc");
expect(arrayLen(written)).toBe(1);
expect(fileRead(dir & "/" & written[1])).toBe("// first");
expect(printed(m)).toInclude("jobsAutoCreateTables = false");

var again = installModule("// second");
again.jobs(arg1 = "install");
expect(fileRead(dir & "/" & written[1])).toBe("// first", "without --force an existing migration is left alone");
expect(printed(again)).toInclude("already exists");

var forced = installModule("// third");
forced.jobs(arg1 = "install", force = "true");
expect(directoryList(dir, false, "name", "*_CreateWheelsJobTables.cfc")).toBe(written, "rewritten under the same name and version");
expect(fileRead(dir & "/" & written[1])).toBe("// third");
});

});

describe("MCP surface", () => {

it("hides the jobs command from MCP tools/list", () => {
Expand Down
Loading
Loading