Skip to content

feat(job): lifecycle hooks, an app-wide failure hook, and a stored result - #4498

Merged
bpamiri merged 4 commits into
developfrom
peter/job-lifecycle-hooks
Oct 8, 2026
Merged

bpamiri merged 4 commits into
developfrom
peter/job-lifecycle-hooks

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #4483

Problem

Jobs had no lifecycle hooks and no stored result. A failure was silent unless every job wrapped its own perform(), and perform()'s return value was thrown away.

Change

Per-job hooks (all optional)

component extends="wheels.Job" {
    public void function beforePerform(struct data) {}                     // part of the work
    public any function perform(struct data) { return {sent = 12}; }
    public void function afterPerform(struct data, any result) {}         // part of the work
    public void function onSuccess(any result) {}                         // best-effort
    public void function onFailure(struct error, numeric attempt, boolean isFinal) {} // best-effort
}

beforePerform / afterPerform.

  • They run with perform() inside the timed run, so the job's timeout covers them.
  • A throw from either fails the attempt, exactly as a throw from perform() does.
  • afterPerform runs only when perform() returned normally.

onSuccess(result).

  • It runs once the job is recorded as completed.
  • result is perform()'s return value, absent when it returned nothing.

onFailure(error, attempt, isFinal).

  • It runs after a failed attempt is recorded.
  • error is {type, message, detail}. type is the original exception's type, for example Wheels.JobTimeout for a timeout.
  • isFinal is false when a retry was scheduled and true when the job is marked failed.
  • The hook is called positionally, so its parameters can have any names. Not final, though: Adobe CF doesn't accept a parameter with that name.

What's best-effort. onSuccess and onFailure are best-effort: a throw is logged and never changes the job's outcome.

What fires no hooks. A fenced attempt fires none, because its outcome wasn't the one recorded.

Tenant context. For tenant jobs, the tenant context is still restored while the hooks run.

Reaped jobs

When the reaper marks a crashed worker's job failed (its last attempt), onFailure(isFinal = true) fires on the reaping server.

  • Error. The error type is Wheels.JobTimeout.
  • Best-effort. It never blocks the reap loop.
  • No tenant context. The job class is loaded on that server and no tenant context is restored, because the row's data isn't read.
  • Retries. A reap that schedules a retry fires nothing.

App-wide failure hook

set(jobsOnFailure = "app.lib.JobAlerts.record");
// app/lib/JobAlerts.cfc
public void function record(required struct event) { ... }
  • When. It is called on every failure, final or not, after the job's own onFailure.
  • Format. The setting is a "component.path.method" string, not a closure; function values in application-scope settings break Adobe CF.
  • Argument. The method gets one struct as its event argument: {jobId, jobClass, queue, attempt, maxRetries, isFinal, error}. Job data is never included, because it can hold secrets.
  • When it can't be called. A hook that can't be called is logged once per application and never affects the job.

result column

  • wheels_jobs.result (NVARCHAR(4000) on SQL Server, whose VARCHAR silently mangles non-ASCII text; VARCHAR(4000), or VARCHAR2 on Oracle, elsewhere) is added by the same guarded auto-ALTER as the other job columns: never inside a Wheels transaction, with a back-off and one logged warning if it fails.
  • It is written in the completion update itself.
  • Format. Simple values are stored as-is, anything else as JSON, and a void return as NULL. An empty-string return is stored as NULL too: Oracle can't tell the two apart anyway.
  • Truncation. A longer value is cut to what the column counts and ends with a visible ...[truncated] marker: 4000 characters on SQL Server's NVARCHAR, and 4000 UTF-8 bytes elsewhere (Oracle's VARCHAR2 counts bytes; for the databases that count characters, bytes is the safe bound). The cut always falls on a character boundary. The trimming loop only runs for a value over the limit.

Both processing paths

The worker (wheels jobs work) and processQueue() share the hook helpers in Job.cfc. On the worker, the failure branch moved into $recordFailedAttempt(), which records the retry or failure, runs the hooks, and then clears the tenant context in a finally.

Split out

The opt-in per-attempt history (wheels_job_attempts) is now #4497, to keep this change small.

Tests

JobLifecycleHooksSpec (14 specs):

  • Success path: the order before → perform → after → onSuccess, and the result stored.
  • Stored results: a complex result stored as JSON, a void return stored as NULL, a long result truncated with the marker, a non-ASCII result stored intact, and a long non-ASCII result cut on a character boundary (whole on SQL Server's NVARCHAR).
  • Failure hooks: onFailure plus the app-wide hook with isFinal = false on a retry and isFinal = true on the last attempt. That covers the event's fields, the original error type and detail, and no job data.
  • Hooks that throw: a throwing beforePerform fails the attempt without running perform(), and a throwing onSuccess leaves the job completed.
  • Unreachable app hook: the failure is still recorded.
  • processQueue(): runs the hooks and stores the result.
  • Reaper: a reaped last attempt fires the hooks with isFinal = true and Wheels.JobTimeout, and a reap that retries fires nothing.

On develop:

  • 2 fail: no hooks run, and a throwing beforePerform doesn't fail the job.
  • 9 error: there is no result column.

Local results:

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

Merge notes

Docs

The guide section (hooks, the app-wide hook, the stored result) 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, #4496) with a merge commit. Job.cfc and JobWorker.cfc start from develop and this PR's own changes go on top. The pieces that needed placing by hand:

A separate commit makes an interrupted job (non-idempotent, reaped) fire onFailure(isFinal = true) and jobsOnFailure, with a spec.

Local results:

  • Full core suite, Lucee 7 + SQLite: 7225 pass, 0 fail, 0 error (590 bundles).
  • Jobs specs, Lucee 7: green on all seven databases. PostgreSQL and SQL Server were also green on a second run against the same database.
  • Jobs specs, BoxLang: green on SQLite, SQL Server and Oracle.
  • Jobs specs, Adobe 2023: green on SQLite and MySQL.
  • Jobs specs, Adobe 2025: green on SQLite and SQL Server.
  • Complexity gate: passes.

…sult

beforePerform(data) / afterPerform(data, result) run with perform() under its
timeout; a throw fails the attempt. onSuccess(result) runs once completion is
recorded, onFailure(error, attempt, isFinal) after each recorded failure,
including a reaped last attempt; both are best-effort. set(jobsOnFailure =
"component.path.method") hears every failure, with no job data.

perform()'s return value is stored in wheels_jobs.result (VARCHAR(4000), added
by the guarded auto-ALTER): simple values as-is, complex as JSON, cut to 4000
UTF-8 bytes with a visible marker.

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
@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 review @ 62ffcad: code APPROVE (draft, v4.3.0; final = ready after matrix 37728260161 + PR CI, held until the 4.2.0 cut)

Hook ordering and isolation

  • Inside the timed run: beforePerform, then perform, then afterPerform (with result only when perform returned something) all run in $runPerformWithTimeout's thread. A throw from either hook fails the attempt, and the new spec shows perform doesn't run when beforePerform throws.
  • onSuccess(result): fires only after the completion UPDATE has matched the claim, and runs in its own try, so a throw is logged and the job stays completed.
  • Failed attempts: $recordFailedAttempt records the retry or the failure first, then calls $fireJobFailure, but only when the attempt wasn't fenced. So a hook never sees, or acts on, an outcome that was discarded.
  • Hook failures never reach the row or the loop: onFailure and the global hook each have their own try. The global hook logs once per app. $recordFailedAttempt uses try/finally with no catch, which satisfies the BoxLang finally rule, and the finally clears the tenant context the failed attempt kept for the hooks.
  • The failure details reach the hooks through var structs (failure, outcome, resolved), not local. writes in a catch (invariant 11).
  • Reaper: fires onFailure(…, isFinal = true) only when its $markFailed wins (won > 0), not on a reaper retry, which matches the agreed scope. The job class is loaded best-effort and jobInstance = "" on a load failure, so the global hook still runs. Everything is wrapped so the reap loop never breaks. attempt is row.attempts, which $claimJob has already bumped, so it agrees with the worker path's currentAttempts.

Portability

  • onFailure(error, attempt, isFinal) is called positionally, so apps can name the parameters freely (final is reserved on Adobe).
  • The global hook is a component.path.method string, resolved with CreateObject and Invoke(…, {event = …}). Malformed strings (fewer than 2 parts) are skipped, and resolution or call errors are logged once. The event carries {jobId, jobClass, queue, attempt, maxRetries, isFinal, error} and no job data, as agreed. Both processQueue (Job.$processJob) and the worker path call the same $fireJobSuccess / $fireJobFailure.

The result column

  • It's VARCHAR(4000) (Oracle VARCHAR2(4000), at the maximum). The column ensure is guarded against open transactions, backs off, and warns once. CREATE TABLE includes it, and the completion UPDATE writes it only when the column exists.
  • Truncation counts UTF-8 bytes (CharsetDecode), which matches Oracle's default byte semantics and stays at or under 4000 characters for MySQL and PostgreSQL. The ...[truncated] marker fits inside the limit.

Notes, not blocking:

  • An empty-string result is stored as NULL (null = !Len(text)), the same as a void perform(). That's probably fine, but worth a line in the docs.
  • SQL Server: VARCHAR takes the database's code page, so non-ASCII text in a result (from SerializeJSON of user data, say) can be stored as ? on a non-UTF-8 collation. NVARCHAR(4000) on SQL Server would avoid that at no cost (the byte budget still fits). Worth considering before 4.3 ships.
  • The truncation loop trims one character at a time and re-encodes each time: quadratic, but only on results over 4000 bytes, so it's fine in practice.
  • result isn't a reserved word on any of the 7 databases, as far as I know. The matrix confirms.

Specs (JobLifecycleHooksSpec, 12 cases): ordering, JSON, NULL and truncation results, retry versus final isFinal, a throwing beforePerform, a throwing onSuccess, an uncallable global hook, processQueue, and the reaper's final versus retry.

The final reads every leg of matrix 37728260161 (in progress) and PR CI (7 pending).

…h column counts

SQL Server's VARCHAR silently mangles non-ASCII text, so the result column is
NVARCHAR(4000) there, bound as nvarchar. Truncation counts characters for it and
UTF-8 bytes elsewhere (Oracle's VARCHAR2 counts bytes), always on a character
boundary. An empty-string result is stored as NULL, as Oracle stores it anyway.

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

rev1-r3 review @ 53acfa6: delta APPROVE.

  • result is now NVARCHAR(4000) on SQL Server, bound as cf_sql_nvarchar, in both CREATE TABLE and the ALTER ($resultColumnType / $resultAlterSql), and VARCHAR(4000) (VARCHAR2 on Oracle) elsewhere. So non-ASCII results survive on SQL Server.
  • The truncation limit now matches what each column counts: characters (UTF-16 units) for NVARCHAR, UTF-8 bytes elsewhere. Bytes is the safe bound on the character-counting databases.
  • The database type is memoised per instance, so detection isn't repeated on every completion.
  • The empty-string-as-NULL behaviour is documented, with Oracle's '' = NULL as the reason.
  • The two new specs (non-ASCII kept intact, and a long non-ASCII value cut correctly) cover it.

Tiny edge, not blocking: Left() counts UTF-16 units, so a cut could in theory split a surrogate pair (astral-plane emoji) right at the limit. That only affects a value already marked ...[truncated].

The final reads a run on this head (it should include sqlserver and oracle) plus full 37728260161 on 62ffcad, plus PR CI.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Wheels Test Results

184 220 tests   180 662 ✅  1h 10m 15s ⏱️
 14 750 suites    3 558 💤
     25 files          0 ❌

Results for commit 260506d.

♻️ This comment has been updated with latest results.

@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 review @ 53acfa6: APPROVE (final = ready; draft, v4.3.0, held until the 4.2.0 cut)

Run Head Result
narrowed 37729362928 this head (engines × sqlite / sqlserver / oracle / postgres, plus rustcfml) success. 20 of 20 junit legs, tests > 0 (7292–7302), 0 failures and 0 errors on every leg; the rustcfml job also succeeded. JobLifecycleHooksSpec passed 14/14 on every leg, including the two non-ASCII cases on SQL Server (NVARCHAR) and Oracle (VARCHAR2 bytes). JobWorkerSpec 53/53.
full 37728260161 62ffcad, before the NVARCHAR fix success. 32 of 32 legs, 0 failures and 0 errors. JobLifecycleHooksSpec 12/12 and JobWorkerSpec 53/53 on every leg, including Adobe 2023/2025, BoxLang, CockroachDB and MySQL.

Why the narrowed coverage is enough: the delta is the result column's type and its truncation unit per database. The narrowed set runs exactly the databases where those differ (SQL Server, Oracle) next to SQLite and PostgreSQL, on every engine.

PR CI on this head: 17 pass, 0 fail, 0 pending; 4 skipped.

@bpamiri bpamiri modified the milestones: v4.3.0, v4.2.0 Oct 8, 2026
bpamiri and others added 2 commits October 8, 2026 11:02
…e-hooks

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

# Conflicts:
#	vendor/wheels/Job.cfc
#	vendor/wheels/JobWorker.cfc
When the reaper ends a non-idempotent job as 'interrupted' (it is never run
again), the job's onFailure and the app-wide jobsOnFailure hook now fire with
isFinal = true, as they do when the reaper marks a last attempt failed.

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:05
@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 review @ 260506d: APPROVE (delta final, against my approval at 53acfa6)

The delta is the develop merge d3bbdab (base e63973b, with #4490–#4496 in) and the reaper commit 260506d.

Merge resolution, read with git show --remerge-diff:

  • Job.cfc: result sits beside heartbeatAt, both in CREATE TABLE and in the column-ensure list ($ensureHeartbeatColumn() then $ensureResultColumn()).
  • $processJob extracts $backoffSettings() and $deferBusyRun() (complexity gate). Both behave exactly like the inline code they replace: the same per-class backoff overrides, and the same defer plus tenant clear on a busy lease.
  • JobWorker.$executeJob: the result struct carries both the lease fields (feat(job): exclusive jobs and concurrency keys on a shared lease lock #4496) and the hook fields (this PR).
  • A busy lease returns inside the try before $fireJobSuccess, before the failure capture, and before the tenantPending handoff, so a deferred run fires no hooks and clears its own tenant context.
  • jobInstance is set before the busy check, which is harmless because a busy outcome never reaches a hook.
  • The worker's failure path is $runClaimedJob → $recordFailedAttempt, after the busy and fenced returns. So fenced attempts still fire no hooks, and a lost lease still reaches the hooks on the real outcome.

260506d, the reaper:

  • A non-idempotent job reaped to interrupted now calls $fireReapedFailure(..., isFinal = true), gated on won > 0 like the retries-exhausted branch. Only the reaper that wins the row fires.
  • $fireReapedFailure takes the message instead of the seconds, so each branch reports its own text. It is still best-effort and contained (load failure → hooks with no instance; any error is logged, and the reap loop continues).
  • New spec: NonIdempotentHookJob (this.idempotent = false over HookProbeJob), a stale processing row, then checkTimeouts(). It checks status = interrupted, the hook log failure:1:true:Wheels.JobTimeout and the global hook's isFinal.

Matrix 37821594006 (full, dispatched on this head), read with my leg reader: 25 legs, all tests > 0 (7365–7375), 0 legs with fail or error. JobLifecycleHooksSpec runs 15 specs on every leg, 0 bad, including the new interrupt spec. The lease specs run 13 on every leg, 0 bad.

PR CI on this head: 17 pass, 4 skipping, 0 fail/pending. The branch is up to date with develop and MERGEABLE.

@bpamiri

bpamiri commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my delta final: develop moved after I posted. It now has #4505 (f3ed23b, distribution-install-smoke.yml only), so the branch is one CI-workflow commit behind. Nothing overlaps Job.cfc, JobWorker.cfc or the jobs specs. The PR is MERGEABLE, and the approval stands.

@bpamiri
bpamiri merged commit 87bbd01 into develop Oct 8, 2026
56 checks passed
@bpamiri
bpamiri deleted the peter/job-lifecycle-hooks branch October 8, 2026 18:25
bpamiri added a commit that referenced this pull request Oct 8, 2026
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>
bpamiri added a commit that referenced this pull request Oct 8, 2026
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>
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): lifecycle hooks + global failure hook + result column

1 participant