Skip to content

feat(job): exclusive jobs and concurrency keys on a shared lease lock - #4496

Merged
bpamiri merged 4 commits into
developfrom
peter/job-lease-lock
Oct 8, 2026
Merged

bpamiri merged 4 commits into
developfrom
peter/job-lease-lock

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #4479

Problem

There was no way to say "only one of these running at a time". Two servers could run the same report job at once. The framework already had a cross-server lease lock with owner fencing, but only inside the migrator.

Change

wheels.LeaseLock

The migrator's lease, extracted. It is one table with one row per held lock: lockname (primary key), lockowner, lockhost, acquiredat and expiresat. Times are epoch milliseconds, so every database compares them as plain numbers.

  • Statement builders. Owner, read, insert, expired-lease takeover, renew and release, plus the table DDL.
  • Execution methods. tryAcquire, renew, release, ownerOf, read, run against a datasource.
  • Owner fencing. Every change after the insert is fenced on the owner. A holder whose lease was taken over can neither renew nor release the new holder's lock.

The migrator is unchanged

The migrator keeps its own lock table and every one of its lock methods. Their bodies now build their SQL with LeaseLock and still run it through $migrationLockQuery(), which carries the migrator's datasource credentials and its test seams. The statements, parameters and their order are the same as before, and its clock (GetTickCount()) is unchanged.

Exclusive jobs

component extends="wheels.Job" {
    function config() {
        super.config();
        this.exclusive = true;          // one run of this class at a time, on any server
    }
}

component extends="wheels.Job" {
    // or a key, from the job's data: one run per account
    public string function concurrencyKeyFor(struct data = {}) {
        return "account-" & arguments.data.accountId;
    }
}

Lease names.

  • this.exclusive = true leases job:<jobClass>.
  • A concurrency key leases key:<key>, and a key implies exclusive.
  • The key comes from this.concurrencyKey = "...", or from a concurrencyKeyFor(struct data) method. The method wins, and both can be set.
  • A name longer than the table's 100 characters is hashed.

Lifetime.

  • When it's taken. The lease is taken after the claim and before perform(), on both the worker path and processQueue().
  • Owner. The owner is the attempt's claim token.
  • Length. It lasts the claim timeout plus Max(60, claimTimeout): the same window after which the row itself can be reaped, so it never ends inside the claim timeout.
  • Release. It is released afterwards (owner-fenced), on success or failure.
  • After a timeout. It is not released then: perform() may still be running in its thread, so the lease is left to expire.

Busy lease. When another run holds the lease, perform() doesn't run:

  • the job goes back to pending with runAt = now + Min(remaining lease, 30 s);
  • the attempt its claim counted is given back, so waiting on a busy key never uses up retries;
  • it counts as skipped, not failed: processNext() returns skipped and deferred, and processQueue() counts it in skipped.

Lost lease. If the lease was taken over by the time the job finishes, a warning is logged and the job is still recorded as completed, because it already ran.

  • processNext() returns leaseLost.
  • The worker counts lost leases in leasesLost.
  • processQueue() returns a leasesLost count.

Table. wheels_job_locks is created on first use by an exclusive job. Whether it exists is asked of the database catalog, never by a failing probe query (which would abort an enclosing PostgreSQL transaction). That is never inside a Wheels transaction, where DDL would commit the open transaction on MySQL and Oracle; there the job fails with the reason and the CREATE TABLE, and is retried.

The guarantee and its limit (for the guide)

Exclusivity is only as strong as the lease.

  • Without renewal. The lease covers the claim timeout plus a grace period. A run that outlives it can overlap a second run. Examples: a run stuck in a blocked JDBC call that a timeout can't interrupt, or one that doesn't cooperate with renewal. That overlap is logged as a lost lease.
  • Renewal. heartbeat() (feat(job): heartbeat() + heartbeat-aware reaping; idempotent gating of retries after a reap #4477, now merged) renews the lease: each heartbeat extends it by the lease's window from now (claim timeout + Max(60, claimTimeout)). A run that keeps heartbeating keeps its lease. fix(job): let heartbeats shorten dead-worker detection for long jobs #4502 will move this to the heartbeat grace window.
  • Clocks. Lease times come from each app server's clock (System.currentTimeMillis()), so servers sharing the table need synchronised clocks (NTP). Skew between two servers shortens or lengthens a lease by that much.

Out of scope

  • Moving the uniqueKey upgrade's lock from the migrator lock to LeaseLock.
  • A CLI to list or clear job leases.
  • Exclusivity at enqueue time. That is uniqueKey.

Tests

JobLeaseSpec (12 specs).

LeaseLock:

  • one owner takes a free lease, and no other until it is released;
  • an expired lease is taken over, and the old owner can neither renew nor release it;
  • renewal extends it, and an epoch-millisecond value round-trips;
  • the migrator's statements target its own table.

Exclusive jobs:

  • the lease is held during perform() and released after it;
  • a lease held by another server stops the run, and the job waits as pending with its attempt given back;
  • a permanently busy key never uses up retries (4 polls, maxRetries = 1);
  • processQueue() counts a busy lease as skipped;
  • an expired lease is taken over and the job runs;
  • a lease taken over during perform() still completes the job and reports leaseLost;
  • lease names follow the order concurrencyKeyFor() → this.concurrencyKey → exclusive (with both set, the method wins), and a long name is hashed.

On develop all 12 error, because none of this API exists yet. Behaviourally, develop has no lease, so an exclusive job runs even while another server holds one.

The migrator's own lock specs (MigrationLockSpec, the whole migrator directory) are the byte-for-byte guard. They run unchanged and stay green on every database.

Local results:

  • Full core suite, Lucee 7 + SQLite: 7152 pass, 0 fail, 0 error (582 bundles).
  • Jobs and migrator 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 and migrator specs, BoxLang: green on SQLite, PostgreSQL, CockroachDB and SQL Server.
  • Jobs and migrator specs, Adobe 2023: green on SQLite and MySQL.
  • Jobs and migrator specs, Adobe 2025: green on SQLite and PostgreSQL.
  • Complexity gate: passes.

Merge notes

Docs

The guide section (exclusive jobs, concurrency keys, the lease-loss caveat and the clock requirement) goes into the 4.2 guide (#4485). Changelog fragment included.

🤖 Generated with Claude Code

Develop merge (4.2.0)

Merged develop (#4490, #4491, #4492, #4493, #4494, #4495), with a merge commit. As with the others, Job.cfc and JobWorker.cfc start from develop and this PR's own changes go on top. Six hunks needed placing by hand:

A separate commit wires heartbeat() to renew the lease, with a spec showing the lease's expiry moving forward after a heartbeat.

Local results:

  • Full core suite, Lucee 7 + SQLite: 7210 pass, 0 fail, 0 error (589 bundles).
  • Jobs and migrator specs, Lucee 7: green on all seven databases. The jobs specs are also green on a second PostgreSQL/CockroachDB run against the same database.
  • Jobs specs, BoxLang: green on SQLite, PostgreSQL and CockroachDB.
  • Jobs specs, Adobe 2023: green on SQLite and MySQL.
  • Jobs specs, Adobe 2025: green on SQLite and PostgreSQL.
  • Complexity gate: passes.

Extract the migrator's cross-server lease into wheels.LeaseLock: statement
builders plus execution methods over one lock table, every change after the
insert fenced on the owner. The migrator keeps its table and lock methods and
builds the same SQL with it through its own query function.

A job with this.exclusive = true, a this.concurrencyKey, or a
concurrencyKey(data) method now holds a lease in wheels_job_locks while it
runs. A busy lease puts the job back to pending (attempt given back, counted
as skipped); a lease lost by the end is logged and counted, and the finished
job is not failed.

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

220 680 tests   215 787 ✅  1h 39m 54s ⏱️
 17 670 suites    4 893 💤
     30 files          0 ❌

Results for commit 37fb206.

♻️ This comment has been updated with latest results.

@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review: draft, head 8258542. REQUEST CHANGES: one MUST-FIX, in a spec file only. The lease design and the Migrator extraction are sound. Not for merge before the 4.2.0 cut anyway.

MUST-FIX

1. JobLeaseSpec doesn't parse on RustCFML, which zeroes the whole RustCFML core suite. Both RustCFML (Linux) in PR CI and the rustcfml matrix leg fail with:

Parse error in 'vendor/wheels/tests/specs/jobs/JobLeaseSpec.cfc' [line 34, col 9]: Expected LBrace, found Dot
  • The spec names a variable lock (var lock = $lock();). Two statements then begin with it (lock.tryAcquire(...) at lines 34 and 46).
  • RustCFML reads a statement-leading lock as the script lock { } keyword. The uses inside expect(...) parse because they're in expression position.
  • Lucee, Adobe and BoxLang accept it, so only that lane shows it.
  • Rename the variable (e.g. leaseLock) throughout the spec. The framework code itself only uses local.lock, which is fine.

LOW

  • "The method wins" doesn't hold when a job sets both (Job.cfc $jobLeaseName).
    • Methods and this. properties share the this scope. A job that defines concurrencyKey(data) and sets this.concurrencyKey = "..." in config() has its method overwritten by the string.
    • So IsSimpleValue() is true and the static key is used. A BoxLang probe confirmed it (StructKeyExists true, IsSimpleValue true), and CFML scoping works the same way on Lucee and Adobe.
    • The spec covers each form alone, never both.
    • Either document "set one, not both", or give the method a name that can't collide (e.g. concurrencyKeyFor(data)).
  • Table probe inside a transaction ($ensureJobLockTable). $jobLockTableExists() runs a SELECT that fails when the table is missing. Inside a PostgreSQL transaction that failure aborts the transaction before the helpful Wheels.JobLockTableMissing is thrown. Probing via $dbinfo or checking the transaction first would keep the transaction alive. Jobs rarely run inside one, so this is minor.

Verified

  • Migrator unchanged. Every lock statement now comes from LeaseLock with identical SQL text:

    • release, renew, insert, takeover, owner, read, the DDL, and the expired-only clear (AND expiresat < :now appended only without force);
    • the same parameter names and cf_sql_varchar/cf_sql_bigint types;
    • still run through $migrationLockQuery(), so the migrator's credentials and the Adobe 2023 null-DML handling are kept.

    MigrationLockSpec is 24/24 on all 32 legs.

  • The lease can't leak.

    • $runPerformWithTimeout() catches every perform() error and returns an outcome, so the release always runs.
    • It's skipped only after a timeout, on purpose: the thread may still be running, and the lease is left to expire.
    • An acquire failure never holds a lease.
  • The attempt is given back: attempts = attempts - 1 … WHERE status='processing' AND attempts > 0 plus the claim-token guard. Specced for four busy polls with maxRetries = 1, ending with attempts at 0 and the job pending.

  • Lease vs claim window. The lease runs claim + Max(60, claim) from acquire, matching the reaper's per-row grace. So a reaped re-run finds the lease busy and waits instead of overlapping the still-running attempt. A lost lease is logged and counted, and doesn't fail the job.

  • tryAcquire. It inserts only when no row exists (so PostgreSQL rarely hits the duplicate-key abort) and rethrows a real INSERT failure. Takeover is owner-fenced with a read-back.

  • DDL guarded. wheels_job_locks DDL is refused inside a Wheels transaction with a clear message.

  • Cross-engine invariants.

    • The catch in $releaseJobLease writes through var state (invariant 11).
    • No loops in finally (invariant 12).
    • LeaseLock is standalone, not a mixin.
    • Row counts come from the result option, the same way the job fencing code already reads them.
  • Clock. Job leases use System.currentTimeMillis().

Compat matrix 37724822970: 32/32 Lucee/Adobe/BoxLang legs green, 0 fail / 0 error, on every database (Oracle, CockroachDB and H2 included). JobLeaseSpec is 12/12 and MigrationLockSpec 24/24 on every leg. The rustcfml leg fails (MUST-FIX 1).

PR CI: 18 pass, 1 fail (RustCFML (Linux), MUST-FIX 1), 2 skipped.

…lock-table check

- The data-driven key method is now concurrencyKeyFor(struct data): a method
  named concurrencyKey was replaced by a this.concurrencyKey set in config(),
  so the method couldn't win. Both can now be set, and the method wins.
- wheels_job_locks existence is asked of the database catalog. A failing
  probe SELECT aborted an enclosing PostgreSQL transaction; the probe now
  only runs outside a Wheels transaction, for an unknown database.
- The lease spec no longer names a variable `lock`, a keyword on one engine.

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 e2bf678. APPROVE (ready), with one pre-merge check below. Still a draft for v4.3.0; it stays held until after the 4.2.0 cut.

Earlier findings, all resolved

  • MUST-FIX, RustCFML parse error. The variable is renamed leaseLock in JobLeaseSpec, and local.lock/arguments.lock in Job.cfc too, so no statement starts with lock. any more. The RustCFML core suite now runs: 7129 pass, 0 fail, 0 error.
  • LOW, method vs property. The method is now concurrencyKeyFor(data), so a this.concurrencyKey can no longer overwrite it. $jobLeaseName checks the method first (!IsSimpleValue), then the simple this.concurrencyKey. KeyedProbeJob now sets both, so the precedence spec exercises the real collision case.
  • LOW, table probe in a transaction. $jobLockTableExists() now asks each database's catalog, keyed on $detectDatabaseType(). The names match its return values (postgresql, mysql, sqlserver, oracle, h2, sqlite), and CockroachDB maps to postgresql, where current_schema() is valid. A catalog error falls back to the probe SELECT, which now runs only outside a Wheels transaction.

INFO

  • H2's catalog query has no schema filter, so a wheels_job_locks in another schema would count as present.
  • Unknown database inside a transaction. On a database outside the six, inside a Wheels transaction, the table is reported missing even when it exists. That fails closed with Wheels.JobLockTableMissing, which is safe.
  • Lucee 7 + SQLite (LuCLI) and Tutorial E2E were skipped on this push (path-filtered). The LuCLI lane passed at 8258542, and the delta touches no CLI code.

Pre-merge check (narrowing gap)

The narrowed run covers sqlite, mysql, postgres and sqlserver. The new catalog branches for Oracle (user_tables), H2 (INFORMATION_SCHEMA.TABLES) and CockroachDB (the postgresql branch) are therefore read but not yet exercised. They look right, but a wrong-yet-valid query (0 rows) would make every exclusive job on that database try CREATE TABLE, fail "already exists", and rethrow. Before merge, run at least one engine on oracle, h2 and cockroachdb (e.g. lucee7) against this head. That's a test-only run, not a code change.

Evidence

  • Narrowed matrix 37727087190 at e2bf678 (5 JVM engines × sqlite/mysql/postgres/sqlserver plus RustCFML):
    • 20/20 legs green, 0 fail / 0 error, with JobLeaseSpec 12/12 and MigrationLockSpec 24/24 on every leg;
    • RustCFML 7129/0/0 (the earlier head failed to parse).
  • Full matrix 37724822970 at 8258542: 32/32 JVM legs green on every database. The delta since then is the rename, the method name, and the catalog check (above).
  • PR CI at e2bf678: 17 pass, 0 fail, 4 skipped (path-filtered and reviewer jobs).

@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Pre-merge check, head e2bf678: green. Run 37728725254 (lucee7 + boxlang × oracle / h2 / cockroachdb; BoxLang has no H2 leg) exercises the catalog branches the narrowed run didn't reach:

leg pass fail err JobLeaseSpec MigrationLockSpec
lucee7-oracle 7124 0 0 12/12 24/24
lucee7-h2 7117 0 0 12/12 24/24
lucee7-cockroachdb 7042 0 0 12/12 24/24
boxlang-oracle 7124 0 0 12/12 24/24
boxlang-cockroachdb 7042 0 0 12/12 24/24

The lease specs build wheels_job_locks and re-check it before every case. A catalog query that missed an existing table would have sent them into CREATE TABLE → "already exists" → rethrow, and errored them. They pass, so the Oracle (user_tables), H2 (INFORMATION_SCHEMA.TABLES) and CockroachDB (postgresql branch) queries find the table. The approval stands, with no further pre-merge conditions from review.

@bpamiri bpamiri modified the milestones: v4.3.0, v4.2.0 Oct 8, 2026
bpamiri added a commit that referenced this pull request Oct 8, 2026
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>
bpamiri added a commit that referenced this pull request Oct 8, 2026
#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>
bpamiri and others added 2 commits October 8, 2026 09:24
Signed-off-by: Peter Amiri <peter@alurium.com>

# Conflicts:
#	vendor/wheels/Job.cfc
#	vendor/wheels/JobWorker.cfc
Now that heartbeat() exists, an exclusive run (this.exclusive or a concurrency
key) hands its lease to the job instance, and each heartbeat extends it by the
lease's window from now. A lease already taken over is logged once and left
alone; the run's end still reports it as lost.

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 16:24
bpamiri added a commit that referenced this pull request Oct 8, 2026
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>
@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review at 37fb206: develop merge (delta) + heartbeat lease renewal (new code). APPROVE (ready to merge).

(1) Develop merge f52ea78, delta against the approved e2bf678

  • Files identical to the approved head. LeaseLock.cfc, the Migrator.cfc change and JobLeaseSpec.cfc produce byte-identical change sets against their bases. So the Migrator parity holds and MigrationLockSpec is unchanged.
  • Logic moved into feat(job): per-host concurrency cap, drain/resume and JobRunner.tick() #4491's split, behaviour unchanged.
    • The busy-lease defer and the lost-lease counting now live in JobWorker.$runClaimedJob(): busy → $deferJobForLease() (attempt given back, claim-token fenced) → skipped + deferred. leaseLost → this.leasesLost++, counted whatever the outcome.
    • $executeJob() passes the worker cap as claimTimeout (so the lease window is still claim + Max(60, claim)), and windowSeconds is now carried on the lease struct.
    • The busy path clears the tenant context before returning.
  • processQueue() (now worker-based, fix(job): run processQueue() through the job worker #4495) counts deferred as skipped and tallies leasesLost separately. Deferred jobs don't count toward limit, and the defer pushes runAt ≥ 1 s, so the loop is bounded (rev1-r3 checked this too).
    • LOW: a single processQueue(limit = n) call can claim and defer many exclusive jobs whose lease is held elsewhere, since deferred jobs don't count toward n. It's bounded by the number of due rows. Fine as is; worth a sentence in the guide fold.
  • this.exclusive sits beside this.idempotent, and the two don't interact.

(2) 37fb206, heartbeat() renews the lease (reviewed as code)

  • Fenced renewal. $renewJobLease() calls LeaseLock.renew(), which is UPDATE … SET expiresat WHERE lockname AND lockowner = :owner. A run can only extend its own lease, never a successor's.
  • Ordering. A fenced heartbeat (claim reaped) throws Wheels.Job.Fenced before renewing, so a stale attempt never extends anything.
  • Renewal failure.
    • If the lease was already taken over, renew() returns false. That's logged once (lostLogged) and left alone; the run's end then fails release() and reports leaseLost, as before.
    • A query error is caught and logged, and heartbeat() doesn't throw for it, so a lease-table hiccup can't fail the job.
  • Expiry vs cadence. Each renewal sets now + windowSeconds (the original claim + Max(60, claim)). With perform() hard-capped at the attempt's timeout (≤ claim), a heartbeating run never sees its lease expire. Without heartbeats, behaviour is unchanged.
  • Scope.
    • $setLeaseContext() runs only when the lease is held, on the per-run instance ($instantiateJobClass creates a fresh object each run), so no lease context leaks between runs or into non-exclusive jobs.
    • Outside a worker, heartbeat() still returns early.
  • Cross-engine. The catch only logs and returns. leaseState is a var struct written in the try (invariant 11 is fine). No loops in finally.
  • Spec. HeartbeatLeaseJob records expiresat before and after heartbeat() (with a 50 ms sleep) and asserts it moved forward and was released afterwards: 13/13 JobLeaseSpec on every leg.

Evidence

  • Matrix 37808590705 at 37fb206 (5 JVM engines × sqlite/mysql/postgres/sqlserver/oracle/cockroachdb):
    • 30/30 legs green, 0 fail / 0 error, 589 bundles;
    • JobLeaseSpec 13/13 and MigrationLockSpec 24/24 on every leg;
    • RustCFML 7185/0/0.
  • H2 isn't in this run. LeaseLock and the H2 catalog branch are byte-identical to e2bf678, which passed lucee7-h2 in pre-merge run 37728725254.
  • PR CI at 37fb206: 17 pass, 0 fail, 4 skipped (reviewer jobs and the path-filtered LuCLI / Tutorial lanes). Merge state is clean.

Docs (INFO, not blocking): rev1-r3's guide points (the new meaning of skipped, leasesLost, the exclusive-jobs section) are queued for the #4501 fold with dev1-design.

@bpamiri
bpamiri merged commit e63973b into develop Oct 8, 2026
61 checks passed
@bpamiri
bpamiri deleted the peter/job-lease-lock branch October 8, 2026 18:01
bpamiri added a commit that referenced this pull request Oct 8, 2026
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>
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(job): extract the cross-node lease lock; this.exclusive / concurrencyKey ("no overlap")

1 participant