Events
The in-process event bus — every lifecycle event type, its payload, listener registration, and ordering guarantees.
The in-process event bus — every lifecycle event type, its payload, listener registration, and ordering guarantees.
flexiq emits a lifecycle event for everything that happens to a job, a worker, a queue, or a workflow run. The in-process event bus dispatches those to Python callbacks in the same process: lowest latency, no serialization, no HTTP.
For delivery to an external service — with persistence, signing, and a replay log — use webhooks, which are driven by the same event set.
The EventType enum defines all available lifecycle events. Each member's
.value is the dotted, lowercase wire string used in webhook payloads,
delivery logs, and the REST API — e.g.
EventType.JOB_FAILED.value == "job.failed":
| Event | Fires when | Payload fields |
|---|---|---|
JOB_ENQUEUED | A job is added to the queue | job_id, task_name, queue |
JOB_COMPLETED | A job finishes successfully | job_id, task_name, queue, duration_ms |
JOB_FAILED | A job raises an exception (before retry) | job_id, task_name, queue, error, duration_ms |
JOB_RETRYING | A failed job will be retried | job_id, task_name, error, retry_count, duration_ms |
JOB_DEAD | A job exhausts all retries and enters the DLQ | job_id, task_name, error, duration_ms |
JOB_CANCELLED | A job is cancelled | job_id, task_name, duration_ms |
WORKER_STARTED | A worker process/thread comes online | worker_id, queues |
WORKER_STOPPED | A worker process/thread shuts down | worker_id |
WORKER_ONLINE | Worker registered in storage (visible to fleet) | worker_id, queues, pool |
WORKER_OFFLINE | Dead worker reaped (no heartbeat for 30s) | worker_id |
WORKER_UNHEALTHY | Resource health transitions to unhealthy | worker_id, resources |
QUEUE_PAUSED | A named queue is paused | queue |
QUEUE_RESUMED | A paused queue is resumed | queue |
WORKFLOW_SUBMITTED | A workflow run is submitted | run_id, workflow_name (+ parent_run_id for sub-workflows) |
WORKFLOW_COMPLETED | A run reaches the completed terminal state | run_id, state, error |
WORKFLOW_COMPLETED_WITH_FAILURES | A continue-mode run finishes with mixed outcomes | run_id, state, error |
WORKFLOW_FAILED | A run reaches the failed terminal state | run_id, state, error |
WORKFLOW_CANCELLED | A run is cancelled | run_id, state, error |
WORKFLOW_GATE_REACHED | A node parks at an approval gate | run_id, node_name, message |
WORKFLOW_COMPENSATING | Saga rollback starts for a run | workflow_run_id |
WORKFLOW_COMPENSATED | All compensation waves finished cleanly | workflow_run_id, any_failed |
WORKFLOW_COMPENSATION_FAILED | A compensation wave failed | workflow_run_id, any_failed |
NODE_COMPENSATING | A compensator job is enqueued for a node | workflow_run_id, workflow_node_name, compensation_job_id, compensation_task |
NODE_COMPENSATED | A node's compensator completed | workflow_run_id, workflow_node_name, error |
NODE_COMPENSATION_FAILED | A node's compensator failed | workflow_run_id, workflow_node_name, error |
PREDICATE_DEFERRED | A predicate defers a task (enqueue- or dispatch-time) | task_name, queue, defer_seconds, phase (+ job_id at dispatch) |
PREDICATE_CANCELLED | A dispatch-time predicate cancels a job | task_name, job_id, queue, phase (+ reason) |
PREDICATE_REJECTED | An enqueue-time predicate rejects a task | task_name, queue, phase (+ reason) |
PREDICATE_SKIPPED | Reserved — see note below | task_name (+ reason) |
PREDICATE_SKIPPED is an enqueue dropped without raising, which the Node
SDK produces via Decision.skip(). Here a Cancel at enqueue raises
PredicateRejectedError and emits PREDICATE_REJECTED instead, so nothing
emits it. It stays in EventType so a webhook subscription written against
another SDK still validates and round-trips.
duration_ms is how long the job ran, so a listener needn't time it. On the
outcome events (JOB_RETRYING, JOB_DEAD, JOB_CANCELLED) it is None when
nothing measured the run — a job that failed before it ever executed.
Use queue.on_event() to subscribe a callback. Callbacks run in a
ThreadPoolExecutor so they never block the worker, and exceptions are logged
but don't affect job processing.
from flexiq import Queue
from flexiq.events import EventType
queue = Queue(db_path="tasks.db")
def on_failure(event_type: EventType, payload: dict) -> None:
print(f"Job {payload['job_id']} failed: {payload.get('error')}")
queue.on_event(EventType.JOB_FAILED, on_failure)Configure the pool size via Queue(event_workers=N) (default 4) if your
callbacks are slow.
Events fire in the order the scheduler processes results — typically the order jobs complete. For jobs that complete nearly simultaneously, ordering is not guaranteed across different workers or threads.
Within a single job's lifecycle, events always fire in this order:
JOB_ENQUEUED (at enqueue time)JOB_COMPLETED / JOB_FAILED / JOB_CANCELLED (at completion)JOB_RETRYING (if retried, before the next attempt)JOB_DEAD (if all retries exhausted)When the receiver is the same Python process and you don't need persistence, the event bus is the cheapest path:
import requests
from flexiq.events import EventType
def notify_slack(event_type: EventType, payload: dict) -> None:
requests.post(
"https://hooks.slack.com/services/T.../B.../xxx",
json={
"text": f":x: Task `{payload['task_name']}` failed\n"
f"Job ID: `{payload['job_id']}`\n"
f"Error: {payload.get('error', 'unknown')}"
},
)
queue.on_event(EventType.JOB_FAILED, notify_slack)
queue.on_event(EventType.JOB_DEAD, notify_slack)from flexiq.events import EventType
def audit_log(event_type: EventType, payload: dict) -> None:
db.execute(
"INSERT INTO audit_log (event, job_id, task_name, timestamp) VALUES (?, ?, ?, ?)",
(event_type.value, payload["job_id"], payload["task_name"], time.time()),
)
for event in [
EventType.JOB_ENQUEUED,
EventType.JOB_COMPLETED,
EventType.JOB_FAILED,
EventType.JOB_DEAD,
]:
queue.on_event(event, audit_log)Listeners live and die with the process — nothing is persisted, and a restart loses anything in flight. Reach for webhooks when the receiver is a separate service, or when you need the delivery to survive a restart and be replayable.