Skip to content

feat(cli): wheels jobs install and jobsAutoCreateTables - #4499

Merged
bpamiri merged 8 commits into
developfrom
peter/jobs-install
Oct 8, 2026
Merged

bpamiri merged 8 commits into
developfrom
peter/jobs-install

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #4484

Problem

The jobs schema is created and altered automatically at runtime, with no migration. Apps with migration discipline, and their DBAs, can't own or review it. On several servers, the automatic ALTERs run on every host at startup.

Change

wheels.JobSchema: the schema, defined once

Each job table is listed once, with:

  • its columns in creation order: name, type (string / integer / text / datetime), limit, nullability and default;
  • its indexes: columns, unique, and whether the index is optional.

Auto-create builds its DDL from it. Job.$ensureJobTable() and the column upgrades (claimTimeout, claimToken/claimedBy, uniqueKey) now build their CREATE TABLE, ALTER TABLE ... ADD and CREATE INDEX from the schema:

  • Types: the same per-database types as before.
  • Oracle claimTimeout: the claimTimeout upgrade on Oracle now adds INT, which Oracle stores as NUMBER(38). That is the type CREATE TABLE always used; the upgrade used to add NUMBER(10). Both hold any timeout, and existing columns aren't changed.
  • Indexes: each index is now built in its own try. Before, one failing optional index skipped the rest.

Catalog checks. It also answers hasTable / hasColumn / hasIndex from each database's catalog (information_schema, user_tab_columns / user_indexes, sys.indexes, pg_indexes, sqlite_master / pragma_table_info). It never runs a failing probe query, so the checks are safe inside a migration's transaction: a failed statement aborts a PostgreSQL transaction.

wheels jobs install

It writes app/migrator/migrations/<timestamp>_CreateWheelsJobTables.cfc.

How it's generated. The source comes from the running app's JobSchema (bridge command jobsInstallSource, read-only), so it matches the framework version the app runs. Nothing touches the database until wheels migrate latest.

What it contains. The migrator DSL (createTable, the column helpers, addColumn, addIndex), so the same file runs on every supported database. A dev-SQLite / prod-PostgreSQL app runs one file. execute() is used only where the DSL can't express the step:

  • SQL Server's filtered unique index (WHERE uniqueKey IS NOT NULL);
  • the uniqueKey = id backfill when it adds that column, as the automatic upgrade does.

Existing installs. up() adopts tables that auto-create already built: it creates only missing tables, columns and indexes. Running it again changes nothing.

down(). It drops the job tables, including ones the migration adopted rather than created, and every job in them. That is what rolling back a create-table migration means; the generated file says so in its header.

Datasource. The migration's catalog checks run against the migrator's datasource and credentials ($migratorDataSource()), so a TenantMigrator checks each tenant's database.

Re-running the command. --force rewrites an existing jobs migration under the same file name, so a migration that already ran keeps its version. Without --force the command leaves it alone and says so.

jobsAutoCreateTables

set(jobsAutoCreateTables = false), for an app that ran the migration, stops every runtime CREATE and ALTER of the job tables.

  • Missing table. Once the database catalog confirms the table is missing (a probe failing for another reason, such as a lost connection, surfaces as that error), enqueue and a worker's poll throw Wheels.Job.SchemaMissing, whose message points to wheels jobs install and wheels migrate latest. It is also logged once per application. A worker doesn't pass the poll off as an empty queue: wheels jobs work exits with the message.
  • Missing uniqueKey. The enqueue(uniqueKey=) error says the same.
  • Default. It is true, set with the other framework defaults: auto-create is unchanged for apps that don't opt out.

Why multi-server apps should use it (for the guide)

With auto-create, every server runs the jobs DDL at startup.

There is also a hypothesis, seen only in the test suite and not reproduced in a deployment. On PostgreSQL and CockroachDB, an automatic ALTER on a live table could make other servers' prepared job statements fail once per statement and connection (cached plan must not change result type).

Applying the schema once, through this migration, before deploying avoids both.

Tests

JobSchemaSpec (8):

  • auto-create builds every column and index the schema lists, and the catalog checks answer correctly;
  • the install migration, generated and run exactly as the command writes it, builds the same columns with the same nullability, plus every index, and the table works: a keyed enqueue de-duplicates;
  • an older auto-created table without the fencing columns, uniqueKey or indexes is topped up, existing rows get their uniqueKey, and a second run is a no-op;
  • up, down and up again through the Migrator (its transaction and lock). The migrated table has the same type family for every column (string / text / integer / datetime, read from the catalog) and the same nullability as auto-create's, down() drops it, and every index is back after the second up();
  • with jobsAutoCreateTables = false, $ensureJobTable(), enqueue and a worker poll each throw Wheels.Job.SchemaMissing with the install guidance and create nothing;
  • with jobsAutoCreateTables = false, a probe failure on a table the catalog says exists surfaces as its own error, not as a missing schema;
  • with jobsAutoCreateTables = false, an old table isn't altered;
  • each database's DDL.

JobsCommandSpec: install and --force parse; the command writes the server's source once; a re-run leaves it alone; --force rewrites it in place.

CliBridgeSpec: declares jobsInstallSource.

On develop:

  • the new core specs error, because wheels.JobSchema doesn't exist;
  • the jobsAutoCreateTables = false spec fails, because develop creates the table anyway;
  • CliBridgeSpec fails, because there is no jobsInstallSource.

Local results:

  • Full core suite, Lucee 7 + SQLite: 7147 pass, 0 fail, 0 error (582 bundles).
  • Jobs specs, Lucee 7: green on SQLite, H2, MySQL, PostgreSQL, CockroachDB, SQL Server and Oracle; PostgreSQL and CockroachDB also green on a second run against the same database.
  • Jobs specs, BoxLang: green on SQLite, PostgreSQL, CockroachDB, SQL Server and Oracle.
  • Jobs specs, Adobe 2023: green on SQLite and MySQL.
  • Jobs specs, Adobe 2025: green on SQLite, PostgreSQL and SQL Server.
  • Migrator specs, Lucee 7 + SQLite: green.
  • CLI-bridge core specs: green on Lucee 7. The CLI module's own suite (JobsCommandSpec) runs in PR CI.
  • Complexity gates (core and CLI): pass.

Develop merge (4.2.0): every job table now in JobSchema

Merged develop with a merge commit. develop now has the whole series: #4490 heartbeat, #4491/#4492 hosts and drain, #4493 schedules, #4494, #4495, #4496 lease lock and #4498 hooks. This PR lands last, so JobSchema adopts every job table and column, and auto-create and install both cover them.

What JobSchema adds:

Auto-create. Every auto-create path now builds its DDL from JobSchema: the wheels_jobs columns, hosts, schedules and locks. Each one also respects jobsAutoCreateTables:

  • Locks. With it false, a missing locks table throws Wheels.Job.SchemaMissing when an exclusive job runs.
  • Hosts and schedules. A missing hosts or schedules table logs once and disables only that feature (the cap/drain, or schedule enqueueing).

The generated migration. It covers all four tables, each with its own create-or-top-up block:

CLI. The install verb, --force and the bridge command sit beside #4492's drain and resume.

Spec. JobSchemaSpec now compares auto-create and the install migration for every table: columns and nullability, and type families through the migrator round trip. On SQL Server it also checks that result is NVARCHAR.

Local results on this head:

  • Full core suite, Lucee 7 + SQLite: 7233 pass, 0 fail, 0 error.
  • Complexity gates (core and CLI): pass.

From the trial merge of the same job and CLI code:

  • Jobs specs, Lucee 7: green on all seven databases. PostgreSQL and CockroachDB were also green on a second run against the same database (CockroachDB re-run after the useTransaction fix).
  • Jobs specs, BoxLang: green on SQLite, PostgreSQL, CockroachDB, SQL Server and Oracle.
  • Jobs specs, Adobe 2023: green on SQLite and MySQL.
  • Jobs specs, Adobe 2025: green on SQLite, PostgreSQL and SQL Server.

The per-attempt history table (#4497) joins behind --attempts once it exists.

Docs

The guide section (wheels jobs install, jobsAutoCreateTables, the multi-server recommendation) goes into the 4.2 guide (#4485). Changelog fragment included.

🤖 Generated with Claude Code

wheels.JobSchema defines the job schema once. Auto-create builds its CREATE
TABLE, ALTERs and indexes from it, and answers table/column/index checks from
the database catalog (never a failing probe, so they are safe in a migration's
transaction).

`wheels jobs install` writes app/migrator/migrations/<ts>_CreateWheelsJobTables.cfc
from the running app's schema: migrator DSL that creates the tables or tops up
auto-created ones, idempotently. set(jobsAutoCreateTables = false) stops every
runtime CREATE and ALTER of the job tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri bpamiri added this to the v4.3.0 milestone Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Wheels Test Results

36 965 tests   36 230 ✅  12m 17s ⏱️
 2 965 suites     735 💤
     5 files         0 ❌

Results for commit 11db051.

♻️ This comment has been updated with latest results.

@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review: draft, head e805d6b. REQUEST CHANGES: two MEDIUM, both on items this change set out to cover. CI is fully green and the shared-schema design is sound. Not for merge before the 4.2.0 cut anyway.

MEDIUM

1. With jobsAutoCreateTables = false and no job table, the "run wheels jobs install" guidance only reaches the log (Job.cfc $ensureJobTable / enqueue / JobWorker).

  • enqueue(). The INSERT fails, $ensureJobTable() logs missingSchemaMessage(...) to wheels_jobs.log and returns false, then $throwEnqueueFailed(error = e) throws Wheels.Job.EnqueueFailed. The exception carries the raw "table doesn't exist" database message, and its extendedInfo is about transactions and second datasources. So the developer is pointed at the wrong cause.
  • Worker. processNext() re-runs the candidate SELECT, fails again, and returns skipped = true with the raw error on every poll, so wheels jobs work just looks idle.
  • UniqueKeyUnavailable. With auto-create off, extendedInfo now prepends the install hint but goes on to say "The framework adds them on a worker poll or an enqueue…", which is no longer true.
  • Fix:
    • when auto-create is off and the table/column is missing, throw (or return as the worker's error) a typed error whose message is missingSchemaMessage() (e.g. Wheels.Job.SchemaMissing);
    • drop the "framework adds them" sentence in that mode;
    • spec the enqueue error and the worker result.

2. The generated migration's down(), and up() through the migrator, are untested (JobSchemaSpec).

  • The specs call migration.up() directly on an instance: no migrator, no $runMigrationStep transaction, no down().
  • Running inside the migrator's transaction matters for this migration:
    • on PostgreSQL its catalog checks run after DDL in the same transaction;
    • on MySQL/Oracle the DDL commits implicitly.
  • down() is never executed on any database.
  • Fix: run the generated file through the migrator, up → down → up (as wheels migrate latest && down && up does), and assert the tables after each step. The catalog-compare spec also compares only column names and nullability. Comparing a type family per column (string/int/text/timestamp) would catch the migrator DSL and auto-create drifting (e.g. t.datetime vs DATETIME/DATETIME2/TIMESTAMP).

LOW

  • The catalog checks may look at a different database from the one the migration changes.
    • In the generated migration, new wheels.JobSchema() defaults to application.wheels.dataSourceName and the default credentials.
    • The migration DSL runs on $migratorDataSource(), which a tenant migration sets to the tenant's datasource and credentials.
    • Example: under TenantMigrator, with the job tables already in the app database and missing from a tenant's, hasTable() says yes, up() creates nothing on the tenant, and the version is still recorded.
    • Pass the migrator's datasource (e.g. new wheels.JobSchema(datasource = $migratorDataSource()) in the generated source, or have JobSchema.init honour request.wheels.migratorDataSource).

INFO

  • down() also drops tables the migration adopted (auto-created before it ran), together with any queued jobs in them. That's defensible for an install migration; worth one line in the generated docblock.
  • jobsAutoCreateTables has no registered default (it's read as true when absent) and isn't in the settings docs or guides yet.
  • claimTimeout's ALTER on Oracle is now INT (from columnType) rather than NUMBER(10). Both work. Noted as a deliberate unification.
  • The spec writes the generated CFC under vendor/wheels/tests/_assets/jobs_install at runtime and removes it in afterAll. Engines sharing a bind mount write identical content, so this is fine.

Verified

  • Auto-create parity.
    • createTableSql() produces the same column list, order, types, defaults and NOT NULLs per database as the old inline DDL.
    • Each index is now created separately. Previously, one failing optional index skipped the rest. A non-optional failure (uniqueKey) is still logged, and $recordUniqueKeyIndexVerified() is only set on success.
    • The ALTER builders go through addColumnSql: Oracle ADD (...), SQL Server ADD, others ADD COLUMN.
  • Unique index. SQL Server's unique index is filtered WHERE uniqueKey IS NOT NULL, both in auto-create and in the generated migration (via execute). Elsewhere it's a plain addIndex(unique = true).
  • Adoption. The generated up() adds only missing nullable columns, backfills uniqueKey = id, and adds missing indexes. Running it again is a no-op (specced on all 32 legs).
  • Catalog queries. There's one per database, keyed on $detectDatabaseType(), with no failing probe SELECT. CockroachDB takes the postgresql branch (information_schema/pg_indexes, both supported).
  • Auto-create off with a table present. $ensureJobTable() returns true with no column upgrades, and every $ensure* upgrade path returns early (specced).
  • CLI.
    • install needs a running server, takes the source from the app's own framework version, and writes <timestamp>_CreateWheelsJobTables.cfc once.
    • --force rewrites the same file name, so a migration that already ran keeps its version.
    • JobsCommandSpec covers parsing and the write/rewrite.
    • The bridge command is read-only.

Compat matrix 37729374931: 32/32 Lucee/Adobe/BoxLang legs green, 0 fail / 0 error on every database. JobSchemaSpec 5/5 on every leg. RustCFML 7122/0/0.

PR CI: 20 pass, 0 fail, 2 skipped. Smoke: testing (Adobe 2023) failed once before any test ran (the container never answered); its single re-run passed.

bpamiri and others added 2 commits October 7, 2026 22:49
…nd-trip spec

- With jobsAutoCreateTables = false and no table, enqueue and a worker's poll
  throw Wheels.Job.SchemaMissing with the install guidance (logged once), instead
  of a generic enqueue failure or a poll that looks like an empty queue.
- The generated migration's catalog checks use $migratorDataSource() and its
  credentials, so a TenantMigrator checks each tenant's database.
- jobsAutoCreateTables defaults to true with the other settings.
- down() dropping adopted tables is documented in the generated file.
- New spec runs the migration up/down/up through the Migrator and compares
  column type families and nullability with auto-create.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
…exists

With jobsAutoCreateTables = false, any failure of the existence probe (a lost
connection, a permission) read as a missing table. The catalog now confirms the
table is missing before SchemaMissing is thrown; otherwise the probe runs again
so its own error reaches the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Final review: head a74786d. APPROVE (ready). Still a draft for v4.3.0; it stays held until after the 4.2.0 cut.

Earlier findings, all resolved

  • MEDIUM 1, clear errors with auto-create off.

    • With jobsAutoCreateTables = false and no job table, $ensureJobTable(), enqueue() and a worker poll now all throw Wheels.Job.SchemaMissing, whose message is the install guidance. It's logged once per app.
    • The worker's ensure wrapper rethrows it instead of reporting an empty queue.
    • A probe failure is only called a missing schema when the catalog agrees (JobSchema.hasTable()). Otherwise the probe runs again outside the try, so a lost connection or a permission error surfaces as itself.
    • Specs cover all three callers plus the mocked "table present, probe fails" case.
  • MEDIUM 2, migration coverage. A new spec writes the generated migration into a scratch migrations directory and runs up → down → up through a real wheels.Migrator (its transaction and lock). After each step it asserts:

    • the table exists or doesn't;
    • catalog type families (string/text/integer/datetime) and nullability equal auto-create's;
    • every index is present after the second up.

    The spec reads $typeFamily() per database, including SQLite's string→text folding, H2 1.4's TYPE_NAME fallback and CockroachDB's bigint.

  • LOW, migrator datasource. The generated up()/down() build JobSchema(datasource = $migratorDataSource(), credentials = $migratorDataSourceCredentials()), and type detection uses Job.$databaseTypeOf(ds). Under TenantMigrator, the checks now run on the database the migration changes.

  • INFO items handled:

    • down() dropping adopted tables (and their jobs) is stated in the generated header, the PR and the changelog;
    • jobsAutoCreateTables = true is registered in onapplicationstart;
    • the Oracle INT (NUMBER(38)) vs NUMBER(10) difference is noted in columnType().
  • Also fixed: Adobe's compile-time rejection of DirectoryCreate(dir, true).

Note (non-blocking)

Inside a PostgreSQL transaction, a failed probe aborts the transaction, so the hasTable() confirmation then errors with "current transaction is aborted" rather than SchemaMissing. With a missing table inside a transaction any path errors there, so the clearer message is simply unavailable in that one spot.

Evidence

  • Full matrix 37729374931 at e805d6b: 32/32 JVM legs green on every database, plus RustCFML.
  • Narrowed matrix 37734386995 at 10ddf9b (5 JVM engines × sqlite/mysql/postgres/sqlserver/oracle, plus RustCFML):
    • 25/25 legs green, 0 fail / 0 error, JobSchemaSpec 7/7 on every leg;
    • RustCFML 7124/0/0.
  • Run 37735590780 at a74786d (lucee7 + boxlang × h2/cockroachdb/sqlite): 5/5 legs green, JobSchemaSpec 8/8 on each. That covers the H2 TYPE_NAME path, the CockroachDB family mapping, and the migrator up/down/up on both.
  • Delta check. 10ddf9b→a74786d2d is the hasTable() confirmation in $ensureJobTable(), which behaves the same on every engine, plus one spec. That spec's MockBox prepareMock/$() usage has prior art in 20 core spec files, and it ran green on Lucee 7, BoxLang and RustCFML at this head.
  • PR CI at a74786d: 22 pass, 0 fail, 3 skipped. Lucee 7 + SQLite (LuCLI) is path-filtered (no CLI change since e805d6b, where it and JobsCommandSpec passed); the other two skips are reviewer jobs.

bpamiri and others added 2 commits October 8, 2026 11:26
Signed-off-by: Peter Amiri <peter@alurium.com>

# Conflicts:
#	cli/lucli/Module.cfc
#	cli/lucli/tests/specs/commands/JobsCommandSpec.cfc
#	vendor/wheels/Job.cfc
#	vendor/wheels/public/CliBridge.cfc
#	vendor/wheels/tests/specs/cli/CliBridgeSpec.cfc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri
bpamiri marked this pull request as ready for review October 8, 2026 18:28
@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review at 17b100d (develop merge 4284bfc, reviewed as code). REQUEST CHANGES: one MUST-FIX, an intermittent spec failure that turns the compat-matrix gate red. The schema adoption itself is sound.

MUST-FIX

1. JobSchemaSpec "with jobsAutoCreateTables = false, reports a probe failure as itself when the table exists" fails on boxlang-sqlite in matrix 37824635569 (Compatibility Matrix Gate red).

  • Symptom. The failed assertion is Len(probed.message) > 0 ("the probe's own error is rethrown"), so $ensureJobTable() returned without throwing.

  • Why that points at the spec. With hasTable mocked to true, the only no-throw path is that the re-run SELECT … FROM wheels_jobs WHERE 1=0 succeeded, i.e. wheels_jobs still existed. The spec's $dropJobTable() swallows any DROP TABLE error, so a failed drop (SQLite table lock or busy handle, plausibly after the merged-in specs) leaves the table in place and the spec runs against the wrong precondition.

  • It's intermittent. PR CI's own boxlang + sqlite leg at the same head passed (0 failures, 7383 specs), and the spec was 8/8 on boxlang-sqlite at a74786d (37735590780).

  • Fix:

    • make the precondition explicit: after $dropJobTable(), assert new wheels.JobSchema().hasTable("wheels_jobs") is false (or retry the drop) before mocking hasTable, so a failed drop fails as "couldn't set up", not as a behaviour regression;
    • better, make $dropJobTable() stop swallowing errors when the table still exists afterwards;
    • then find out why the drop fails on BoxLang + SQLite at that point in the suite (an unclosed result or handle from an earlier jobs spec?).

    Not a CI re-run: a spec that's order- or timing-dependent will come back.

LOW

  • The hosts warning overstates what's lost. With auto-create off and no wheels_job_hosts, $warnAuxTableMissingOnce("wheels_job_hosts", "the per-host cap and drain are unavailable") is logged. The cap still works without the registry: $runningOnHost() counts processing rows by claimedBy in wheels_jobs. Only drain, the registry row and wheels jobs status' host view are lost. Suggest: "drain and the host registry are unavailable (the per-host cap still applies)".

Verified (as code)

  • One definition for every table. JobSchema defines all four job tables, and each matches the DDL it replaces column for column (types, defaults, NOT NULLs, PKs):
    • wheels_jobs, now with heartbeatAt and result as unicodeString (NVARCHAR(4000) on SQL Server);
    • wheels_job_hosts;
    • wheels_job_schedules, with decimal precision 15 → DECIMAL(15,0);
    • wheels_job_locks (= LeaseLock.createTableSql() for the job table; the migrator's own lock table is untouched).
  • Every auto-create path uses JobSchema and respects jobsAutoCreateTables:
    • jobs table → SchemaMissing (catalog-confirmed);
    • column upgrades skipped;
    • locks → SchemaMissing;
    • hosts/schedules → logged once, feature off;
    • $ensureJobLockTable's in-transaction message now points at wheels jobs install.
  • Generated migration:
    • it covers all four tables and sets this.useTransaction = false, which the Migrator honours (Migrator.cfc ~1093) and which is justified (idempotent, mixed DDL commit semantics);
    • unicodeString → t.string and then ALTER COLUMN … NVARCHAR on SQL Server at create, or ADD … NVARCHAR when adopting;
    • decimals pass precision, scale = 0;
    • adoption still adds only missing nullable columns, plus the uniqueKey backfill.
  • CLI. install sits beside drain/resume, and the bridge jobsInstallSource is unchanged.

Evidence

  • Full matrix 37824635569 at 17b100d (all 32 legs; I re-fetched the artifacts after a partial first download):
    • 31/32 green, 0 fail / 0 error, 591 bundles each, with JobSchemaSpec 8/8 on all of those (Oracle, CockroachDB, H2 and SQL Server included, so the NVARCHAR check ran);
    • boxlang-sqlite: 7235 pass, 1 fail (MUST-FIX 1);
    • RustCFML 7208/0/0.
  • PR CI at 17b100d: 22 pass, 0 fail, 4 skipped (including boxlang + sqlite: 0 failures). Merge state is clean.

The probe-failure spec dropped wheels_jobs and expected the next probe to fail.
In the full BoxLang + SQLite suite the DROP was visible to the catalog check yet
not to the probe's pooled connection (consistent with a connection left inside
an uncommitted transaction by an earlier spec), so the probe passed. The spec now
fails the probe with a datasource that doesn't exist, the lost-connection case,
with no DROP. The schema specs' drop helpers no longer swallow a failed DROP:
they confirm through the catalog and fail with the DROP's own error.

The wheels_job_hosts warning no longer says the per-host cap is unavailable:
the cap counts claimedBy in wheels_jobs and still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Final review: head 4a60553. APPROVE (ready to merge).

  • MUST-FIX resolved. The probe-failure spec no longer depends on table state: it fails the probe with a datasource that doesn't exist, with hasTable mocked to true, and asserts that the datasource's own error surfaces rather than SchemaMissing. $dropTableOrFail() checks the catalog before and after the DROP and throws Spec.DropFailed with the DROP's error instead of swallowing it, so a spec can no longer run on the wrong precondition.
  • Root cause tracked. An earlier spec leaves an uncommitted transaction on a pooled BoxLang + SQLite connection. test: an earlier spec leaves an open transaction on a pooled BoxLang + SQLite connection #4508 tracks it, with the evidence and bisect criteria.
  • LOW resolved. The hosts-missing warning now says drain/resume and the host record are unavailable and the per-host cap still works (claimedBy).
  • Evidence:
    • full matrix 37849356846 at 4a60553: 32/32 legs green, 0 fail / 0 error, 591 bundles, JobSchemaSpec 8/8 on every leg (including boxlang-sqlite, the leg that failed before); RustCFML 7208/0/0;
    • PR CI: 24 pass, 0 fail, 2 skipped.
  • Everything verified as code at 17b100d still holds: the delta is the spec and the one warning string.

Signed-off-by: Peter Amiri <peter@alurium.com>

# Conflicts:
#	vendor/wheels/events/onapplicationstart.cfc
@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Delta-check at a6a52cb (develop a6c2d00 merged, including #4507): PASS. The final approval stands.

Signed-off-by: Peter Amiri <peter@alurium.com>

# Conflicts:
#	cli/lucli/Module.cfc
@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Delta-check at 11db051 (develop 7f4dc07 merged, including #4518): PASS. The final approval stands.

  • Unchanged files. Every file except cli/lucli/Module.cfc has the same added/removed lines against its new base as the approved a6a52cb.
  • Module.cfc. The only difference is the resolved jobs usage line, which now carries both install [--force] (this PR) and --job-timeout=<seconds> (fix(cli): let wheels jobs work run each job with its own timeout #4518). jobsArgSpec has both the job-timeout option and the install force flag, and $parseJobsArgs() maps both (jobTimeout = parsed["job-timeout"], force = parsed.force).
  • SQLite matrix 37857378288 at 11db051 (all 6 engines):
    • 5/5 JVM legs green, 0 fail / 0 error, 593 bundles;
    • JobSchemaSpec 8/8;
    • RustCFML 7218/0/0.
  • PR CI at 11db051: 24 pass, 0 fail. Deploy CLI tests + verb smoke: 2448 pass, 0 fail, 0 error (includes the install and job-timeout CLI specs). Merge state is clean.
  • Earlier evidence for the unchanged framework code: 37854454930 and the full 37849356846.

@bpamiri
bpamiri merged commit c284bc0 into develop Oct 8, 2026
37 checks passed
@bpamiri
bpamiri deleted the peter/jobs-install branch October 8, 2026 23:21
bpamiri added a commit that referenced this pull request Oct 8, 2026
Two merged changes:

- #4499: wheels jobs install writes a migration that creates (or tops
  up) the job tables from the running app's own schema, and
  set(jobsAutoCreateTables = false) turns off runtime CREATE/ALTER
  (then a missing table throws Wheels.Job.SchemaMissing). Restructure
  "The shared schema" to lead with install + the setting as the
  recommended multi-server path, keeping the hand-written DDL as the
  fallback; replace the "planned" aside; add an upgrade entry.
- #4518 (fixes #4517): wheels jobs work now runs each job for its own
  timeout, with --job-timeout to cap, instead of stopping every job at
  300 s. Rewrite the long-timeout caution accordingly and add an
  upgrade entry.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
bpamiri added a commit that referenced this pull request Oct 9, 2026
* docs(web): add the 4.2 "Running Jobs on Multiple Servers" guide

A companion to Background Jobs for when several app servers process one
wheels_jobs table. It documents the multi-server features already on
develop and marks the parts still to come.

Covered (merged):
- the guarantees: one recorded outcome per attempt (per-claim fencing),
  at-least-once for idempotent jobs, and why perform() must be idempotent
- heartbeat() for long jobs, and Wheels.Job.Fenced
- this.idempotent and the interrupted status (at-most-once)
- set(jobsMaxConcurrentPerHost) and the host name override
- runner options: the CLI worker, a scheduled processQueue(), JobRunner.tick()
- draining a server before a deploy with wheels jobs drain --wait / resume
- the shared schema to apply by hand on a live multi-server database
- clock skew and NTP

Marked as not-yet-built, each with its tracking issue: the token-gated
/wheels/jobs/tick route, a single UTC database clock, recurring
schedules, exclusive jobs and concurrency keys, lifecycle hooks, and
wheels jobs install.

Links both ways with Background Jobs, adds the sidebar and index entries,
and records the heartbeat/idempotency, per-host cap, and jobs drain/resume
changes on the 4.1 to 4.2 upgrade guide.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): correct the multi-server jobs guide per accuracy review

Fixes from rev1-r1's pass on the new guide and the 4.1 to 4.2 upgrade
entry:

- heartbeat() does not extend a job's timeout. The timeout is a hard cap
  per attempt (perform() runs in a bounded thread and is stopped when it
  elapses); heartbeat() only records liveness. Reframed the section and
  the upgrade entry, and noted the lease-renewal (#4496) and
  dead-worker-detection (#4502) work that builds on it.
- The heartbeatAt column is TIMESTAMP on PostgreSQL, CockroachDB, Oracle
  and H2 (they have no DATETIME); the manual DDL now gives per-database
  types.
- Documented purge(status="interrupted") for pruning interrupted rows,
  instead of "use your own SQL".
- Job timestamps are written with each server's application clock
  (Now()), not the database clock; corrected the clocks section.
- Dropped "unless you turn it off" from the upgrade note: turning the
  auto-schema off needs jobsAutoCreateTables, which isn't built yet.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): fold the merged processQueue-through-worker change into the guide

processQueue() now runs through the job worker (#4495), so it reaps
stale jobs, honours the per-host cap, and stops starting new jobs on a
draining host. Document that as fact instead of a forward reference,
note that it still doesn't refresh the host registry's lastSeenAt the
way tick() does (its running count and drain state are read live), and
drop #4495 from the "Coming in 4.2" list now that it has landed.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): phrase the heartbeat lease-renewal note as forward-looking

#4496 as approved sets a job's lease expiry at acquire and does not
renew it from heartbeat() yet; the renewal is wired in #4496's develop
merge. Word the liveness aside as "once #4496 lands, heartbeat() also
renews an exclusive job's lease" so it's accurate until #4496 merges,
then it becomes plain fact.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): document recurring job schedules (#4493)

Fold the merged recurring-schedules feature into the multi-server jobs
guide as a full section, replacing the placeholder:

- The config/schedules.cfm DSL: schedule().job().cron() / .every(),
  .timezone(), .catchUp() / .catchUpWindow(), .queue()/.priority()/
  .data()/.enabled(). wheels new doesn't create the file.
- Running schedules with new wheels.JobScheduler().enqueueDue(): each
  slot carries a uniqueKey, so calling it on every server yields one job
  per slot.
- Catch-up ("latest" default within catchUpWindow, "none"), the
  wheels_job_schedules table, code-vs-db schedule sources, and the
  java.time/DST notes for non-UTC time zones.

Also correct the Background Jobs page, which said Wheels has no
recurring schedule, to point here, and add a 4.1 to 4.2 upgrade entry.
Remove recurring schedules from the "Coming in 4.2" list.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): mark the planned automatic schedule runner

enqueueDue() is called manually today. Note the planned schedule-runner
wiring: JobRunner.tick() and the CLI worker's poll will call it every
jobsScheduleCheckSeconds (default 30), so a running worker fires
schedules without a separate call. Clearly marked as not built yet.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): note the uniqueKey index on old SQL Server levels (#4494)

Fold in the improved SQL Server guidance: the filtered unique index
needs compatibility level 100 or higher. Below that, either raise the
level or use a plain unique index, but only once every server runs 4.2
(a plain index allows one NULL, so a 4.1 server writing NULL keys would
collide). The framework's warning and Wheels.Job.UniqueKeyUnavailable
now name the failed step and the per-database fix. Added to the guide's
schema section and the 4.1 to 4.2 upgrade entry; removed from
"Coming in 4.2".

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): correct processQueue's skipped claim and add the fenced count

Since #4495 routed processQueue() through the worker, a row another
poller claims first is no longer counted as skipped — the atomic claim
just fails and the loser moves on. Fix the stale "shows up in the
skipped count" parenthetical, and add the fenced count (a current
field) to the result-shape comment, with a pointer to the fencing
guarantee. (leasesLost and the redefined skipped come with #4496.)

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): document exclusive jobs and concurrency keys (#4496)

Fold in the merged lease-lock feature:

- New "Exclusive jobs and concurrency keys" section: this.exclusive,
  this.concurrencyKey, and concurrencyKeyFor(data) (the method wins),
  the wheels_job_locks lease, a busy lease putting the job back to
  pending (counted as skipped), leasesLost, and the never-making-
  progress caution.
- heartbeat() now renews an exclusive job's lease: flipped the
  forward-looking aside to plain fact.
- Background Jobs: processQueue()'s result gains leasesLost, and its
  skipped/fenced/leasesLost counts are explained with links.
- Added a 4.1 to 4.2 upgrade entry; noted wheels_job_locks and
  wheels_job_schedules as cheap auto-create tables; removed exclusive
  jobs from "Coming in 4.2".

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): correct the lease window source and soften the renewal wording

Per rev1-r1: the exclusive-job lease window is sized from the timeout
the job was CLAIMED with (the claimTimeout passed to $acquireJobLease),
not this.timeout — they coincide only under processQueue. Reword to
"the timeout it was claimed with plus the reap grace", matching the
reaper language. And because that window outlasts the hard per-attempt
timeout cap, the lease can't actually expire mid-run today, so drop the
"expire out from under it" framing: heartbeat lease renewal is
groundwork, like its effect on the reaper.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): document job lifecycle hooks and the failure hook (#4498)

Fold in the merged lifecycle-hooks feature:

- New "Lifecycle hooks and failure handling" section: beforePerform/
  afterPerform (run under the timeout; a throw fails the attempt),
  onSuccess(result) / onFailure(error, attempt, isFinal) (best-effort,
  after the outcome is recorded), isFinal covering reaped and
  interrupted jobs, the app-wide set(jobsOnFailure=...) hook with its
  data-free struct, and the stored wheels_jobs.result column.
- Added the result column to the schema table and DDL.
- Added a 4.1 to 4.2 upgrade entry.
- Removed the now-empty "Coming in 4.2" section (remaining placeholders
  live in their own inline asides).

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): fix jobsOnFailure signature, var scoping, and the result cap

Per rev1-r1 on the #4498 fold:
- jobsOnFailure is invoked with a named `event` argument, so the hook
  method must declare `function report(required struct event)`. Show
  the signature in the guide and note it in the upgrade entry.
- Use `var order` in the ChargeCardJob example.
- The stored result cap is 4000 characters on SQL Server but 4000 UTF-8
  bytes on VARCHAR / Oracle VARCHAR2; reword to say so.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): clarify the result cap is a byte bound, not VARCHAR byte semantics

Per rev1-r1: only Oracle VARCHAR2 counts bytes; PG/MySQL/SQLite VARCHAR count characters, and the code caps at 4000 UTF-8 bytes there as a safe bound.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): heartbeats shorten dead-worker detection (#4507)

The groundwork the heartbeat section pointed at has landed: a job that
heartbeats is now reclaimed once its beats stop for a heartbeat grace,
even inside its timeout, instead of waiting out twice the timeout.

- Reframe the heartbeat section from "groundwork" to the real
  behaviour: the grace is jobsHeartbeatGraceSeconds (default 300, floor
  30, 0 = off) or this.heartbeatGrace; a job that never heartbeats
  keeps the full claim window; timeout stays the hard cap. Give the
  example a long timeout so the grace matters.
- The exclusive-job lease is renewed by the grace on each beat, so the
  lease and the reclaim run on one clock; retitle and update the aside.
- Drop the #4502 forward reference (it's this change) and add a 4.1 to
  4.2 upgrade entry.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): qualify the long timeout and the lease-vs-reclaim windows

Per rev1-r1 on the #4507 fold:
- MEDIUM: this.timeout is only the requested cap; the wheels jobs work
  worker caps every job at 300 s (Min(job, workerCap) with workerCap
  defaulting to 300 and this.perJobTimeout false), so a 3600 s job is
  still stopped at 300 s under the stock worker. Added a caution: run a
  long job through processQueue(timeout=) or JobRunner.tick(timeout=).
- LOW: the exclusive lease is renewed to grace + max(60, grace) (600 s
  at the default), which outlasts the reclaim grace (300 s), so the
  claim and the lease don't "lapse together" — the reclaim happens
  first and the old lease frees shortly after. Reworded the aside and
  the exclusive-jobs line.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): sharpen the runner cap and lease-window details

Per rev1-r1:
- processQueue() already honours a job's own timeout (perJobTimeout
  mode), so the timeout= arg is optional and only lowers the cap; only
  wheels jobs work caps at 300. Reword and link #4517, which tracks the
  CLI cap.
- The exclusive lease renews to grace + max(60s, grace) — ~2x the grace
  (600s at the default), not "a little longer". After a reclaim the old
  lease frees about one grace later, and the retry waits deferred
  without spending an attempt. Reword the aside and the exclusive-jobs
  line.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): correct the schedule-runner placeholder default to 15

Per rev1-r1 reading the #4509 branch: jobsScheduleCheckSeconds defaults to 15, not 30; processQueue() also runs schedules (through the worker); and a draining host still enqueues due slots to run elsewhere. Still marked not-built-yet until #4509 merges.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): document wheels jobs install, and the fixed worker timeout

Two merged changes:

- #4499: wheels jobs install writes a migration that creates (or tops
  up) the job tables from the running app's own schema, and
  set(jobsAutoCreateTables = false) turns off runtime CREATE/ALTER
  (then a missing table throws Wheels.Job.SchemaMissing). Restructure
  "The shared schema" to lead with install + the setting as the
  recommended multi-server path, keeping the hand-written DDL as the
  fallback; replace the "planned" aside; add an upgrade entry.
- #4518 (fixes #4517): wheels jobs work now runs each job for its own
  timeout, with --job-timeout to cap, instead of stopping every job at
  300 s. Rewrite the long-timeout caution accordingly and add an
  upgrade entry.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): tighten the jobs-install schema notes

Per rev1-r1:
- Wheels.Job.SchemaMissing is thrown only for a missing wheels_jobs
  (and wheels_job_locks for exclusive jobs); a missing host-registry or
  schedules table just turns that feature off with a logged note.
- Warn that the install migration's down() is destructive: it drops all
  four job tables, including adopted ones, and every job in them.
- The --job-timeout note: --timeout stops the whole worker only on some
  CLI runtimes (where LuCLI's root option takes it), not universally.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): soften the --timeout worker note to runtime-dependent

The #4518 line in background-jobs.mdx said --timeout stops the whole worker outright; it only does so on CLI runtimes where LuCLI's root option takes it. Match the wording used in the multi-server guide and upgrade entry.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): document the /wheels/jobs/tick route (#4516)

Fold in the merged token-gated tick route, replacing the placeholder
with a real "Drive it over HTTP: the tick route" subsection:

- POST /wheels/jobs/tick runs one JobRunner.tick() and returns JSON.
- The token is the boundary: 404 until set(jobsRunnerToken = "..."),
  403 without the right X-Wheels-Jobs-Token (constant-time), ?token=
  only with jobsRunnerTokenInQuery; any forwarding header is refused, so
  it's called on the server itself, not through a proxy. Answered before
  routing; overlapping ticks skipped.
- wheels jobs tick runs one tick from the CLI.
- Add a 4.1 to 4.2 upgrade entry.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): schedules run from the worker (#4509); tick-route job limits

- #4509: a running worker checks schedules itself — wheels jobs work,
  processQueue() and JobRunner.tick() each enqueue due slots as they
  poll, at most once every jobsScheduleCheckSeconds (default 15, 0 =
  off) per application. Rewrite "Running the schedules" so enqueueDue()
  is only for when nothing polls the queue; note the draining-server
  behaviour and tick()'s scheduled count; update the upgrade entry.
- #4516 review (rev1-r1): the tick route calls tick() with no args, so
  each call reaps, enqueues due schedules and runs at most ONE job
  (capped at 300 s), with no request parameter for either — say so and
  point volume at wheels jobs work or a CFML tick(maxJobs=n). Note GET
  and POST are both accepted.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): link #4520 from the tick-route one-job-per-call note

The route runs one job per call today; #4520 will make its batch size and timeout configurable. Both orch1 and rev1-r1 asked for the forward link.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): job timestamps on the database's UTC clock (#4486)

The last multi-server piece. Rewrite "Clocks and skew" from "each
server's own clock, keep NTP tight" to the real behaviour: wheels.JobClock
keeps job timestamps on the database's UTC clock (read ~once a minute,
skew-corrected, compared in SQL), so skewed or differently-zoned servers
no longer disagree about stale/due. Document that H2 and SQLite use the
server's own UTC (H2 needs a UTC JVM); the CFML API stays local
(enqueueAt converts, status/stats convert back); and the install
migration converts pre-4.2 rows once via this.convertLocalOffsetMinutes
in one transaction with a marker (idempotent; SQLite skipped). Add the
upgrade entry.

With every placeholder now folded, this closes the guide.

Closes #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web): spell out the UTC conversion upgrade path

Per rev1-r1, enrich the pre-4.2 timestamp conversion guidance (the
aside and the upgrade entry), from the generated migration comment and
#4521's changelog:

- The drain alternative: upgrade with the queue empty (no pending/
  processing jobs) and leave convertLocalOffsetMinutes at 0.
- The cost of doing neither on a non-UTC server: a delayed job runs
  early/late by the offset, and on a server behind UTC an in-flight job
  looks stale and is reclaimed and run again.
- Servers already on UTC (and SQLite) need neither.
- The offset sign: -300 for UTC-5, 60 for UTC+1.

Refs #4485

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(cli): wheels jobs install migration generator + jobsAutoCreateTables

1 participant