Task Run Recorder#
The Task Run Recorder (TaskRunRecorder) is a server-side service that consumes client-emitted task run events from the events messaging topic and persists them as task runs and states in the database. It is defined in src/prefect/server/services/task_run_recorder.py and extends RunInEphemeralServers, Service .
The service buffers incoming messages into an in-memory asyncio.Queue, then flushes them in bulk β either when the queue reaches write_batch_size or on a periodic flush_interval timer . Only events with event.event.startswith("prefect.task-run") and prefect.orchestration == "client" are processed .
Bulk Upsert Pipeline#
The core write path is _record_bulk_task_run_events, which runs through four stages for every flush:
1. Parse events β task runs
Each ReceivedEvent is converted to a TaskRun via task_run_from_event. The state timestamp is set from event.occurred β the moment the event was emitted β not the server's wall clock . State IDs are derived directly from event.id .
2. In-batch deduplication via union-find
A single flush can contain multiple events for the same task run (via either id or natural key (flow_run_id, task_key, dynamic_key)). Before any DB write, events are sorted by state.timestamp and coalesced using a union-find structure so only the latest-state entry survives per conflict group . The _task_run_conflict_keys function exposes both conflict axes .
3. Deterministic sort β contiguous batching
Deduplicated rows are sorted by their upsert key so all concurrent recorder instances acquire row-level locks in the same order β preventing deadlocks . Rows are then split into contiguous batches sharing the same (column_key_set, conflict_target) signature. Contiguous-only batching (an ordered list rather than a dict) is critical: grouping by signature non-contiguously would re-order rows and undo the sort-order guarantee .
4. Upsert with timestamp guard
Each batch is issued as a single INSERT ... ON CONFLICT DO UPDATE ... WHERE task_run.state_timestamp < excluded.state_timestamp, so a row is only updated when the incoming state is newer . The conflict target is either id or the natural-key unique index (task_run_unique_upsert_columns), chosen per batch .
After all task run upserts, every state for every event (not just survivors) is inserted via _insert_task_run_states with ON CONFLICT DO NOTHING on id, so duplicate state rows are silently dropped .
Conflict Target Resolution & Canonical ID Fallback#
The conflict_target helper determines which unique index to use for each row. It queries existing rows before the upsert loop and applies this logic:
- If the natural key
(flow_run_id, task_key, dynamic_key)already exists in the DB β use"natural-key". - Else if
task_run.idalready exists β use"id". - Canonical ID fallback: If neither direct key matches but another key in the same conflict group does (e.g., a batch contains both the old ID and new natural-key variant of the same run), it rewrites
task_run.idto the already-persisted canonical ID and uses"id". This preventsIntegrityErrors from cross-key conflicts within a batch. - Defaults: natural-key for flow-run-bound tasks,
"id"for autonomous tasks .
After all batches, canonical_task_run_id is propagated back to every original event β including deduplicated aliases β so state rows reference the correct persisted ID .
Retry & Error Handling#
record_bulk_task_run_events retries the entire batch once on IntegrityError to handle TOCTOU races between concurrent recorder instances β when two batches' existence-check SELECTs race ahead of each other's INSERTs . A second failure re-raises the error.
The consumer layer (flush) adds a second tier: individual events are re-queued up to max_persist_retries (default 5) times before being dropped and logged at ERROR level . Periodic flush errors are caught and logged without killing the flush loop .
Key Entry Points#
| Symbol | Location | Purpose |
|---|---|---|
TaskRunRecorder | L532β597 | Service class; lifecycle management |
consumer | L443β529 | Async context manager; batching & flushing |
record_bulk_task_run_events | L183β207 | Public entry point with retry wrapper |
_record_bulk_task_run_events | L210β435 | Full bulk upsert implementation |
task_run_from_event | L106β131 | Event β TaskRun deserialization |
_insert_task_run_states | L74β103 | Bulk state insert with conflict ignore |