Repository navigation
Background Jobs Developer Guide
How FreeITSM records every run of every background job, and how it decides whether each job is healthy. Built for #182; the user guide is Background jobs and the design, with the rules as agreed on the issue, is Background job health - design.
The one-line version: every job calls JobRun::start() / source() / finish() from includes/job_runs.php; includes/job_health.php reads job_runs and job_run_sources on demand and applies four rules; the registry in includes/job_registry.php says what each job is expected to do. Nothing here schedules anything.
ποΈ schema Β· π read Β· βοΈ write Β· π cross-module Β· π REST Β· π₯οΈ UI / help Β· π§ͺ tests
| File | π¨ | What happens there |
|---|---|---|
database/freeitsm.sql, includes/db_verify_schema.php
|
ποΈ |
job_runs, job_run_sources. Indexes are generated into includes/db_verify_indexes.php - run php scripts/gen_db_verify_indexes.php, never hand-edit |
api/system/db_verify.php |
ποΈ | Seeds job_runs_recording_since, job_runs_retention_months (13) and lms_reminder_cron_token
|
includes/job_registry.php |
π |
The list of jobs. Per job: command, web address or not, interval, grace, longest normal run, needed and sources callables. Also jobHealthConfig() (the admin's changes, one JSON setting job_health_config) and jobHealthRetentionMonths()
|
includes/job_runs.php |
βοΈ |
JobRun - the recorder: start, source, finish, fail, skipped, wrap, refused, clean, prune, current
|
includes/job_health.php |
π |
jobHealthOne() / jobHealthAll() / jobHealthSummary() - the rules; jobHealthMayView() / jobHealthMayConfigure() - the gates |
cron/*.php, scripts/documents_maintenance.php, scripts/directory_sync.php
|
βοΈ | The 13 jobs, each recording its own runs |
includes/domains/scheduler.php, includes/lms_reminders.php, includes/projects/alerts.php
|
βοΈ | The page-load runs (trigger page), and domainJobFinish() / lmsRemindersJobFinish() shared with the cron |
api/domains/settings.php, api/lms/reminder_settings.php, api/assets/proxmox_sync.php, api/assets/vcloud_sync.php, api/system/run_directory_sync.php
|
βοΈ | The Run now paths (trigger manual) |
includes/workflow_scheduled.php |
βοΈ |
workflowScheduledRun() now reports its three parts, which become the job's sources |
reporting/settings/manifest.php, includes/capabilities.php
|
π |
Cap::REPORTING_JOB_HEALTH, Cap::REPORTING_JOB_SETTINGS - everyday-work permissions with restrict => null (always apply) |
api/reporting/job_health.php |
πβοΈ | The tab's API: `view=health |
reporting/logs/index.php, lang/en/reporting.php (jobs.*) |
π₯οΈ | The Background jobs tab (?tab=jobs) |
watchtower/index.php, system/index.php
|
π₯οΈ | The card and the notice - server-rendered from jobHealthSummary()
|
assets/js/mobile.js |
π₯οΈ | The two job tables as cards on a phone (data-log-type="jobs" / "job-runs") |
api/v1/resources/jobs.php, api/v1/lib/routes.php, api/v1/lib/permissions.php, api/v1/spec.json
|
π |
GET /jobs/health, permission job_health.read
|
reporting/help.php, system/help/roles.php
|
π₯οΈ | In-app help |
includes/feature_bingo/cards/reporting.php |
π₯οΈ | Three cards |
tests/job-health.php |
π§ͺ | The rules and the recorder, plus REST with a base URL |
What you do not touch to add a job: the tab, the API, Watchtower or the REST address. They read the registry.
require_once __DIR__ . '/../includes/job_runs.php';
$trigger = JobRun::scheduledTrigger(); // 'cron' from the CLI, 'web' over HTTP
// Turned away at the door - NOT a run:
JobRun::refused($conn, 'my_job', 'wrong token');
// Did no work - recorded, but never read when deciding whether a job is failing:
JobRun::skipped($conn, 'my_job', $trigger, 'Minimum interval: last run 12s ago.');
$runId = JobRun::start($conn, 'my_job', $trigger);
foreach ($sources as $s) {
// ... work ...
JobRun::source($conn, $runId, 'my_job', 'thing:' . $s['id'], $s['name'], $ok, $message);
}
JobRun::finish($conn, $runId, null, ['done' => $n], "Did {$n} things."); // null outcome = work it out from the sources-
finish()with anulloutcome: every source failed is failed, some failed is partial, otherwise ok. Pass an outcome to override (the SLA job passespartialwhen some emails failed). -
JobRun::wrap($conn, $job, 'manual', fn($runId) => ..., $analystId)does start/finish/fail around a closure - for page and manual paths. -
Every method swallows its own errors. An install that has not run Database Verification has no
job_runstable and its SLA emails must still go. -
A run that dies stays
running. A shutdown handler marks it failed where PHP gets the chance, and the next finished run of the same job marks any older running row pastmax_runtimeas failed ("Never finished") - so a crash becomes a recorded failure, and stuck only shows while nothing has finished since. -
JobRun::current()is the newest run started in the request.TaskHistory::record()stores it on events written by the system, so an occurrence the scheduler creates says which run made it (#181).
trigger_kind |
Set by | Counts for freshness? |
|---|---|---|
cron |
JobRun::scheduledTrigger() under CLI |
β |
web |
JobRun::scheduledTrigger() over HTTP (the token address) |
β |
page |
the opportunistic page-load runs | β |
manual |
Run now, Sync now, one directory run by hand | β |
All in jobHealthOne(). In order of the issue's clarifications:
| Rule | Implementation |
|---|---|
| 1. Freshness reads only scheduled runs |
last_scheduled_success = newest ok/partial run with trigger_kind IN ('cron','web'). Never seen if none; overdue if older than interval + grace (grace = max(registry grace, interval / 2)). |
| 2. Failure reads only real runs | "Failing" = the newest run whose outcome is ok, partial or failed - skipped rows are never in the query. |
| 3. Recovery is per source | The newest job_run_sources row per source_key; a failed one counts while the key is in the registry's sources($conn) list (gone or switched off = no longer expected). A job with no sources callable uses its newest real run's partial outcome instead. |
| 4. Not used here |
job_health_config[job].not_used - returns not_used before anything else, with who and when. |
| Stuck | Newest running row older than max_runtime, with no real run started after it. |
| Not needed | The registry's needed($conn) - the same test the feature uses (e.g. repeating tasks: an active mode='schedule' series). |
A job can have several problems (failing and overdue); problems lists them, and status is the most severe (stuck > failing > overdue = never seen > partial).
Every lookup the health check makes used to turn an error into false / empty: a database that could not be read made every job Not needed and jobHealthSummary() said healthy - reproduced exactly as reported, with a PDO subclass whose every read throws. A monitor reading ?strict=1 would have stayed green through the very failure it watches for.
Now only a missing table (SQLSTATE 42S02 - the feature has never been set up on this install) is an answer. Any other failure throws JobHealthUnknown (includes/job_registry.php), and the job's status is unknown - severity 6, the worst, and in JOB_HEALTH_UNHEALTHY, so the Watchtower card, the System notice and ?strict=1 (503) all react to it.
| Lookup | Helper | problems[0].what |
|---|---|---|
| Whether the job is needed |
jobNeedOne() / jobSettingStrict() in each needed callable |
requirement / setting
|
| Which sources still exist |
jobSourceKeys() in each sources callable |
sources |
| Each source's newest result | the job_run_sources read in jobHealthJudge()
|
source health |
| Run history | the job_runs reads |
history |
| Intervals and not used here |
jobHealthConfig($conn, strict: true) in jobHealthAll()
|
configuration (every job) |
jobHealthUnknownResult() builds the result. It names what could not be read, never the database's message - that goes to the server log, not to a monitor or a page. Not needed and source removed now require a lookup that succeeded and said so.
π΄ TRAP: never catch-and-default in a
neededorsourcescallable. UsejobNeedOne(),jobSourceKeys()andjobSettingStrict(); they draw the missing-table line for you.jobSetting()(lenient) is for display only.
-
Registry - add it to
jobRegistry()with its command, interval, grace,max_runtimeand aneededcallable that survives a missing table (usejobNeedOne()). Addsourcesif it works through several things. -
Strings -
reporting.jobs.name_<key>andreporting.jobs.affects_<key>inlang/en/reporting.php. -
Record -
refusedon a token refusal,skippedfor lock / interval / switched off / dry run,start/source/finisharound the work,failin the catch. Exit non-zero on failure. -
Page and Run now paths - record them too, as
page/manual. If you skip this the history is incomplete, but freshness is unaffected. - Docs - Scheduled tasks (all four tables: the list, Windows, Linux, Docker).
-
Test -
php tests/job-health.php; run the job once by hand and check the tab.
apiJobsHealth() returns status, checked_at, unhealthy (keys) and jobs (apiJobHealthRow(): no command lines, no internal ids). ?strict=1 makes it apiRespond($data, 503) while unhealthy.
π Install-wide, so the gate is too: the key's company_scope must be null or cover every tenant, and its analyst must be an administrator or reach every company. A key for one company is refused even with job_health.read.
-
TRAP: the old "last run" settings are not health.
webhook_cron_last_run,workflow_cron_last_runand friends are written before the work, for the minimum-interval guard. A crash after that line looks like success. They are kept for the guards; health readsjob_runsonly. -
TRAP:
triggeris a reserved word in MySQL. The column istrigger_kind. -
TRAP: summaries are shown to people and returned by the REST address. Never pass a raw exception with a URL or a credential straight through -
finish()/source()/fail()runJobRun::clean(), which blanksuser:pass@, query strings,password=..., and long hex or base64 runs. Do not bypass them with SQL. -
TRAP: a wrong-token call is not a run. Recording one per request would let anyone fill the table;
refused()overwrites one setting per job. -
TRAP:
neededmust mirror the feature's own test. Calendar pull is needed only where two-way sync (mode='push') is on - an install that only hands out subscription links used to be warned for nothing. - TRAP: a page-load run must never refresh freshness. That is the whole of Dr4inder's first clarification: "healthy, but only page loads have run it" let a status-code monitor stay green while cron was gone.
- TRAP: Run now on one Proxmox / vCloud server or one directory records ONE source. It can clear that source's failure (it processed it), and correctly says nothing about the others.
php tests/job-health.php [base-url] - uses a made-up job key, zz_test_job, with its own definition, so it never touches a real job's health:
- rule 1 with positive controls: page-only is never seen; a scheduled success 2 minutes ago is healthy; 2 hours ago is overdue although Run now and a page ran since;
- rule 2: failed then two skips is still failing; a real success clears it;
- rule 3: A failed, a later run processed only B, still partial naming A; A succeeding clears it; A no longer expected clears it;
- stuck, the "never finished" sweep,
clean(); - with a base URL:
/jobs/health200,?strict=1503 on an unhealthy install, a key without the permission 403. The temporary key is deleted.
-
Failed lookups (
tests/job-health.php, last section): Dr4inder's reproduction (every read throws: now unhealthy, 13 unknown), and aSelectiveFailPDOthat fails only chosen reads on the real database - a requirement lookup, the source-health read, the source list, the job settings, the run history - each unknown, with controls that a missing table (42S02) still reads as not needed and that nothing failing means nothing unknown.
- Background jobs Β· Background job health - design (#182) Β· Scheduled tasks
- Repeating task evidence - Developer Guide - which reads this history
- Roles - Developer Guide Β· REST API Β· Developer tests
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96