This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Events

Event-driven jobs, webhooks, pseudo-events, and route events
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>:

FormMeaning
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:

  1. 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 the X-Cronjob-Token header. data is 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.
  2. 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 with class_exists('Schwendinger\Webtrees\Module\Cronjob\Services\EventQueue').
  3. 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-events job and the module reads the new log entries at most every 5 minutes, queueing one event per entry.
  4. 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):

VariableSet whenValue
CRONJOB_EVENTevery event runthe event name (doubles as the “this is an event run” marker)
CRONJOB_EVENT_PAYLOADevent run with a non-empty payloadthe 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_PAYLOAD is 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.

1 - Pseudo-events

log-table polling

webtrees has no event bus, so core actions (logins, failed logins, logouts, record edits, errors, searches) are detected by polling the webtrees log table (app/Log.php, table log) and firing an event per new matching log entry. It is off by default: nothing runs until you enable the offered cronjob:pseudo-events job.

Built-in events (one cj_event per new log row, payload = the row):

EventLog filter (log_type, message prefix)Fires when
cronjob:log-auth-failedauth, Login faileda login attempt failed (wrong password, unknown user, unverified, not approved, no session cookies)
cronjob:log-auth-loginauth, Login:a user logged in
cronjob:log-auth-logoutauth, Logout:a user logged out
cronjob:log-errorerror, –an exception was caught and logged (the message is the trace)
cronjob:log-edit-updateedit, Update:a record was updated (GedcomRecord::updateRecord())
cronjob:log-edit-deleteedit, Delete:a record was deleted (GedcomRecord::deleteRecord())
cronjob:log-searchsearch, –a search ran (one log row per searched tree)

Payload: log_id, log_time, log_message (truncated to 250 characters, UTF-8-safe) plus gedcom_id / user_id when the log row carries them. The message prefixes are the hard-coded English strings of the current core; a core update that changes them silently stops the matching event (no fallback by design) β€” the event catalog descriptions and the test-pseudo-events suite pin the current mapping.

How it runs

  • One trigger, one cooldown. Detection runs only from the offered cronjob:pseudo-events job (*/5 * * * * by default), in the tick’s child process β€” isolated from any web request and bounded by the job’s timeout, so a heavy detector can never block or slow down a page load. A 5-minute cooldown (a data/ mtime marker) plus a lock keep repeated runs cheap and idempotent; the cooldown also caps a more frequent job cron.
  • Enabled by the job, gated per event. Enabling the cronjob:pseudo-events job turns detection on (there is no separate switch β€” the tick only runs enabled jobs). A detector whose event no enabled event-triggered job listens for is skipped entirely β€” no query, no push.
  • Checkpoint + backpressure. Each detector remembers the highest log_id it processed (state file). At most 200 rows are fired per run per detector; the checkpoint advances only as far as the last fired row, so a flood is drained over several runs β€” no data loss, only latency.
  • Baseline on first run. The first detection only records the current max log_id and fires nothing, so enabling the job does not retroactively fire on existing data. (Re-listening to a previously unlistened event catches up its backlog, capped as above.)
  • Reset, not re-fire, on table shrink. If the table shrank below a checkpoint (TRUNCATE / DB restore β€” plain row deletions cannot do this, MySQL auto-increment continues after deletes), the detector re-baselines silently.
  • Offline-safe & isolated. Skipped while data/offline.txt exists; a broken detector never breaks the others; detection is serialized (flock) and state is persisted atomically to data/cronjob/pseudo-events.json.

The log table is the source of truth: under a flood the tick’s coalescing runs a job once per event name per tick with the newest payload β€” older rows are not re-delivered, but they stay in the log table, and log_id in the payload lets a job that needs completeness re-read the range itself.

If an event name vanishes, the admin table flags such triggers with the unknown event badge.

Detected events go into the same cj_event queue as webhooks and are consumed by the tick’s normal event drain. To act on one, create an event-triggered job whose Event name matches (e.g. alert on cronjob:log-auth-failed, re-index on cronjob:log-edit-update).

2 - Route events

request-triggered

A complementary, immediate detection mechanism: instead of polling state on a schedule, the module observes the HTTP request itself. When a request to a mapped mutating route has been handled successfully (2xx/3xx), it queues the matching event into the same cj_event queue β€” a listening event job then reacts at the next tick (~60 s), with no polling latency.

Which routes are mapped β€” two rules, both derived from the live webtrees route table. Common prerequisites: path starts with /tree/{tree}/, the handler is in webtrees’ request-handler namespace (RequestHandlers in 2.2.6, Controllers in 2.3), and the request is a POST mutation. In 2.2.6 this is enforced at map time (GET page routes are not mapped); in 2.3 the route carries no HTTP method, so the POST check happens at fire-time and the GET form-load of the same route is ignored.

  1. Record routes (rule-based β€” core updates are picked up automatically): the path contains {xref} (the target record) and the handler class name starts with Edit, Delete, Add, Create, Link, Paste, Reorder or Pending. The event name is the third path segment, namespaced _route:<segment>; when several mapped routes share a segment (delete, edit-raw, accept, reject), the following path parameters are appended, hyphen-separated.
  2. Tree-level routes (small curated allowlist): the significant tree-wide mutations that carry no {xref} in the URL (import, merge, renumber, …). Each allowlist entry has an explicit event name. New tree-wide mutations in a core update need a module update (the record-route rule stays fully automatic).
EventRoute (under /tree/{tree}/)Payload
_route:edit-note-objectPOST edit-note-object/{xref}tree, xref
_route:update-recordPOST update-record/{xref}tree, xref
_route:update-factPOST update-fact/{xref}{/fact_id}tree, xref (, fact_id)
_route:delete-xref / _route:delete-xref-fact_idPOST delete/{xref}{/fact_id}tree, xref (, fact_id)
_route:edit-raw-xref / _route:edit-raw-xref-fact_idPOST edit-raw/{xref}{/fact_id}tree, xref (, fact_id)
_route:add-child-to-family, _route:add-spouse-to-family, _route:add-media-filePOST add-…/{xref}tree, xref
_route:add-child-to-individual, _route:add-parent-to-individual, _route:add-spouse-to-individualPOST add-…/{xref}tree, xref
_route:paste-factPOST paste-fact/{xref}tree, xref
_route:link-media-to-recordPOST link-media-to-record/{xref}tree, xref β€” the media object; the target record is a form field, not in the URL
_route:link-child-to-family, _route:link-spouse-to-individualPOST link-…/{xref}tree, xref
_route:reorder-children, _route:reorder-spouses, _route:reorder-media, _route:reorder-media-files, _route:reorder-namesPOST reorder-…/{xref}tree, xref
_route:accept-xref / _route:accept-xref-changePOST accept/{xref}{/change}tree, xref (, change)
_route:reject-xref / _route:reject-xref-changePOST reject/{xref}{/change}tree, xref (, change)
_route:importPOST importtree
_route:loadPOST loadtree β€” the chunked import continuation fires once per chunk
_route:merge-step1 / _route:merge-step2POST merge-step1 / merge-step2tree
_route:search-replacePOST search-replacetree
_route:renumberPOST renumbertree
_route:data-fix-update / _route:data-fix-update-allPOST data-fix/{data_fix}/update[-all]tree, data_fix
_route:accept / _route:rejectPOST accept / reject (bulk)tree
_route:change-family-membersPOST change-family-memberstree β€” the family xref is a form field

In total 38 events (27 record routes + 11 tree-level routes). The payload always contains tree (id) + tree_name plus all matched route parameters. Record routes (with {xref}) additionally carry change_pending (bool): whether the change is still pending in the change table after this request (the user’s auto-accept preference is off) or already applied (auto-accept on). A pending change is applied when an admin accepts it β€” which fires its own _route:accept* events. Tree-level routes write directly (no change rows) and carry no such flag.

Breaking change (this version). Record routes without {xref} in the URL β€” all _route:create-* events plus _route:add-unlinked-individual β€” are no longer mapped: they carry no target record. Jobs listening to those events will never fire again; the admin table flags such triggers with an unknown route event badge (a generic check over all jobs’ _route:* triggers against the live map), so re-pointing them is visible instead of silent.

_route:accept* / _route:reject* fire when a pending change is accepted or rejected β€” accepting a pending deletion is when the record is actually removed.

How it works

  • Gated twice, off by default. An event fires only while the admin toggle “Route events” (Events section) is on and at least one enabled event-triggered job listens for that event name. A request that is not mapped costs nothing (one in-memory lookup); only a successful request on a mapped route does two tiny indexed DB reads.
  • Transactional. The cj_event insert runs in the same DB transaction as the mutation (the module middleware sits inside webtrees’ request transaction), so the queued event commits or rolls back together with the change β€” no drift, no double fire on a failed save.
  • Never blocks. Detection runs after the handler, is exception-wrapped, and can never alter the response.
  • Robust to core upgrades. The map is rebuilt from the route table (keyed by handler class strings, no ::class), so a renamed/removed handler simply stops matching instead of fatalling. If the route table is not available (e.g. CLI), a static fallback map with the note route keeps the pilot working. The record-route rule is fully automatic; only the small tree-level allowlist is curated (see above), and jobs pointing at an event that no longer exists β€” a removed route event or a removed built-in pseudo-event β€” are flagged in the admin table (unknown event badge).

To act on one, create an event-triggered job whose Event name matches (e.g. _route:update-record) and enable the “Route events” toggle. Note the overlap with the cronjob:log-edit-update / cronjob:log-edit-delete pollers: every record edit/delete is also written to the log table, so the matching log event follows at the next poll as well β€” pick the mechanism whose latency/precision fits the job (route events are near-instant and precise per object, and carry change_pending; log pollers lag ~5 min but also catch changes made outside the web UI).