flowchart TD
subgraph Sources["π‘ Event Sources"]
WH["`π Webhook<br/>**GET + Token**`"]
DC["`π Direct Call<br/>**EventQueue::push**`"]
PE["π Pseudo-Events<br/>Log table poller"]
RE["π Route-Events<br/>POST on mutating routes"]
end
subgraph Queue["β³ Queue"]
CE[("`π¦ cj_event<br/>**DB Queue**`")]
end
subgraph Tick["`β± Tick *(every 60 s)*`"]
MATCH["Match events<br/>coalesced: 1 per name"]
BUDGET["`β² **60 s** wall-clock budget`"]
end
subgraph Execution["β Execution"]
JOB["`Job script<br/>child process via **proc_open**`"]
ENV["π CRONJOB_EVENT<br/>CRONJOB_EVENT_PAYLOAD"]
end
subgraph Storage["πΎ Storage"]
RUN[("cj_run<br/>run history")]
ER[("cj_event_run<br/>link")]
end
WH --> Queue
DC --> Queue
PE --> Queue
RE --> Queue
CE --> Tick
MATCH --> BUDGET
BUDGET --> JOB
JOB --> ENV
JOB --> RUN
JOB --> ER
classDef sourceStyle fill:#e3f2fd,stroke:#1565C0,stroke-width:2px
classDef queueStyle fill:#fff3e0,stroke:#E65100,stroke-width:2px
classDef tickStyle fill:#e8f5e9,stroke:#2E7D32,stroke-width:2px
classDef execStyle fill:#fce4ec,stroke:#C2185B,stroke-width:2px
classDef storageStyle fill:#f5f5f5,stroke:#616161,stroke-width:1px
class WH,DC,PE,RE sourceStyle
class CE queueStyle
class MATCH,BUDGET tickStyle
class JOB,ENV execStyle
class RUN,ER storageStyle
Besides time-based schedules, a job can be triggered by an event. webtrees has no event system, so events are plain DB rows (a small cj_event queue) that the tick drains once a minute. An event-triggered job runs for a queued event whose name matches its Event name β coalesced: per tick at most the newest event of each name is drained (one run per name per tick), and the name’s older pending duplicates are dropped β a burst of identical events never queues a burst of job runs (job scripts are idempotent/incremental by convention).
Naming scheme (analogous to job names). Event names are slugs (a-z 0-9 _ -, max 64 chars total), optionally namespaced <domain>:<slug>:
| Form | Meaning |
|---|---|
plain slug (e.g. index-dirty) | your own events β webhook or direct EventQueue::push() |
cronjob:<slug> | built-in pseudo-events (log-table pollers) |
_route:<slug> | route-triggered events (see below) |
<module>:<slug> | events announced by a module (see “Announcing events”) |
Queuing an event has four sources:
- Webhook (external systems). GET the endpoint shown in the admin page (section “Event webhook”):
GET /module/_cronjob_/Event?event=<name>&data=<json>with the token in theX-Cronjob-Tokenheader.datais an optional JSON object, stored with the event. The endpoint requires the token, is rate-limited (20/min per site), and only accepts events while the module is enabled. It is a GET, not a POST: webtrees rejects POSTs without a session CSRF token, which an external caller cannot send. - Direct call (your own modules). From PHP code in another module:
\Schwendinger\Webtrees\Module\Cronjob\Services\EventQueue::push('index-dirty', ['tree' => 'X']);β this requires the cronjob module to be installed; guard it withclass_exists('Schwendinger\Webtrees\Module\Cronjob\Services\EventQueue'). - Pseudo-events (built-in pollers). webtrees has no event bus, so core actions (logins, failed logins, logouts, record edits, errors, searches) are observed by polling the webtrees log table. Enable the offered
cronjob:pseudo-eventsjob and the module reads the new log entries at most every 5 minutes, queueing one event per entry. - Route events (built-in, request-triggered). A curated set of mutating editor routes under
/tree/{tree}/(record-specific routes with{xref}in the path, plus a small tree-level allowlist) queues its event immediately, right inside the request that made the change.
The tick matches each pending event against the enabled jobs that carry an event trigger for that name (a job can have several event triggers, or none at all), runs them, then marks the event handled (consumed once β even on failure, so a failing job does not re-run the same event forever). The drain is bounded: it takes the newest event per name (coalescing, see above) and stops after a 60 s wall-clock budget, so a saturated queue cannot starve the rest of the tick β the remaining events simply continue with the next tick. Every consuming run is additionally linked to the event in the cj_event_run cross table (an event can trigger several jobs), and the run history shows which event triggered a run. Handled events are purged after 7 days (their cj_event_run links are removed by the FK cascade); events with no matching job are discarded so the queue does not grow.
Event payload in the child process. Event runs hand the event to the job script via the process environment (the argv is reserved for the job’s static, strictly validated arguments):
| Variable | Set when | Value |
|---|---|---|
CRONJOB_EVENT | every event run | the event name (doubles as the “this is an event run” marker) |
CRONJOB_EVENT_PAYLOAD | event run with a non-empty payload | the event payload as JSON (route parameters, log-table entries, webhook data) |
$event = getenv('CRONJOB_EVENT'); // false on schedule / manual runs
$payload = json_decode((string) getenv('CRONJOB_EVENT_PAYLOAD'), true) ?: [];
$xref = $payload['xref'] ?? ''; // e.g. from a _route:* event
- Variable absent = no payload. The payload is truncated to 4000 bytes when the event is queued; a JSON cut mid-way no longer decodes, in which case
CRONJOB_EVENT_PAYLOADis not set at all (never empty, never broken JSON). - Treat the payload as untrusted input β above all for webhook
data, whose sender is external. Validate values (xrefs, names, β¦) before using them. - The payload is independent of the command type: module CLI scripts and core commands (which simply ignore the variables) both work on event runs.
- The payload is also visible in the run history (event detail), independent of the child process.