The asynchronous task ecosystem is a cornerstone of DynaStore's resilience and scalability. It provides a robust framework for offloading long-running operations from the synchronous API request loop.
Implementation details: see the Tasks module README.
The tasks module is the heart of the asynchronous ecosystem. It acts as a centralized, transactional ledger and status tracker. It does not execute jobs itself.
- Ownership: Exclusively owns the global
tasks.tasksandtasks.eventstables (Date RANGE partitioned). - Task Registration:
enqueue/create_taskensures monthly partitions exist and inserts aPENDINGrecord securely, with optional deduplication viadedup_key. - Status Tracking: Exposes
update_taskand synchronousupdate_task_syncto allow remote workers to report progress. - Monitoring API: Handles backend lookups for the generic read-only monitoring statuses.
All tasks live in a single global tasks.tasks table partitioned by timestamp. This replaces the former per-tenant "{schema}".tasks tables, which could not scale beyond a few hundred catalogs without requiring expensive schema discovery on every dispatcher cycle.
tasks.tasks
├── task_id UUID (PK with timestamp)
├── schema_name VARCHAR(255) -- tenant identifier ("s_abc123" or "system")
├── scope VARCHAR(50) -- CATALOG | SYSTEM | ASSET
├── execution_mode VARCHAR -- SYNCHRONOUS | ASYNCHRONOUS
├── task_type VARCHAR -- e.g. "ingestion", "gcp_provision"
├── status VARCHAR -- PENDING → ACTIVE → COMPLETED/FAILED/DEAD_LETTER
├── dedup_key VARCHAR(512) -- optional, UNIQUE partial index
├── inputs/outputs JSONB
├── locked_until TIMESTAMPTZ -- visibility timeout for heartbeat
├── owner_id VARCHAR(255) -- which instance claimed it
├── retry_count/max_retries INT
└── timestamp TIMESTAMPTZ -- creation time, partition key
The schema_name column identifies the tenant; scope distinguishes CATALOG / SYSTEM / ASSET tasks. A partial unique index on dedup_key prevents duplicate tasks from the same event.
The tasks.events table provides a durable outbox for domain events:
tasks.events
├── event_id UUID (PK with created_at)
├── event_type VARCHAR -- e.g. "task.failed", "catalog.deleted"
├── scope VARCHAR(50) -- PLATFORM | CATALOG | COLLECTION | ASSET
├── schema_name VARCHAR(255)
├── payload JSONB
├── status VARCHAR -- PENDING → PROCESSING → (deleted on ack)
├── dedup_key VARCHAR(512)
└── created_at TIMESTAMPTZ -- partition key
Consumed events are deleted after successful processing (acked). Failed events are retried up to 5 times before being moved to DEAD_LETTER.
One instance per service type (by NAME env var) wins a session-level
PostgreSQL advisory lock (pg_try_advisory_lock) and becomes the LISTEN
leader. It holds a single asyncpg LISTEN socket on new_task_queued,
task_status_changed, and dynastore_events_channel.
Follower instances emit a periodic signal-bus event to wake the dispatcher
for a claim sweep. Both paths emit signal_bus events so the Dispatcher
loop is identical regardless of instance role.
Benefits:
- One LISTEN connection per service type regardless of replica count.
- No relay table needed — the global tasks table is the single source of truth.
- No thundering-herd: SKIP LOCKED ensures exactly-once task claiming.
At startup, a CapabilityMap is built by querying each runner's
can_handle() method. The dispatcher's claim_next() query filters
by (execution_mode, task_type) pairs so each service only claims tasks
it can actually execute.
UPDATE tasks.tasks
SET status = 'ACTIVE', locked_until = :locked_until, owner_id = :owner_id
WHERE (timestamp, task_id) = (
SELECT timestamp, task_id FROM tasks.tasks
WHERE status = 'PENDING'
AND (locked_until IS NULL OR locked_until <= NOW())
AND (
(execution_mode = 'ASYNCHRONOUS' AND task_type = ANY(:async_types))
OR
(execution_mode = 'SYNCHRONOUS' AND task_type = ANY(:sync_types))
)
ORDER BY timestamp ASC LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING task_id, schema_name, scope, task_type, execution_mode, ...;| Runner | Priority | can_handle() |
Mode |
|---|---|---|---|
GcpCloudRunRunner |
10 | task_type in self._job_map_cache |
ASYNC |
BackgroundRunner |
100 | get_task_instance(task_type) is not None |
ASYNC |
SyncRunner |
100 | get_task_instance(task_type) is not None |
SYNC |
On-premise environments have no GcpCloudRunRunner — tasks fall through to BackgroundRunner transparently.
A single coroutine fires one UPDATE … WHERE task_id = ANY(…) per 30 s
interval across the global table, replacing per-task heartbeat transactions.
After all modules initialize, the event consumer starts automatically if
any module registered async event listeners (has_listeners()). No env
var is needed — modules opt in declaratively by registering listeners.
Two instances shipping the same module both start consumers; FOR UPDATE SKIP LOCKED ensures only one processes each event.
sequenceDiagram
participant App as Application Service
participant DB as PostgreSQL (tasks.tasks)
participant Disp as Dispatcher
participant Exec as Runner (Worker)
participant HB as Heartbeat Manager
App->>DB: INSERT Task (PENDING)
Note over DB: pg_notify('new_task_queued', task_type)
Disp->>DB: claim_next() — FOR UPDATE SKIP LOCKED
DB-->>Disp: Returns task row (incl. schema_name)
Disp->>Exec: Submit to runner matching task_type
Exec->>HB: Start heartbeat
loop Task Execution
HB->>DB: UPDATE locked_until, last_heartbeat_at
Exec->>Exec: Run task logic
end
Exec->>HB: Stop heartbeat
Exec->>DB: complete_task() or fail_task()
When a task fails:
- If
retry_count < max_retries: reset toPENDINGwith exponential backoff (2^retry_countseconds) - If retries exhausted: move to
DEAD_LETTER(requires manual intervention via admin endpoint)
A global janitor runs on each dispatcher wakeup cycle (throttled):
- Stale task recovery: Finds
ACTIVEtasks with expiredlocked_until(dead workers) and requeues or dead-letters them. - Orphan cleanup: Dead-letters tasks whose
schema_nameno longer exists incatalog.catalogs(catalog was deleted).
Both tasks and events support a dedup_key column with a partial unique index:
-- Tasks: prevents duplicate tasks for non-terminal states
CREATE UNIQUE INDEX idx_tasks_dedup ON tasks.tasks (dedup_key)
WHERE dedup_key IS NOT NULL AND status NOT IN ('COMPLETED', 'FAILED', 'DEAD_LETTER');
-- Events: prevents duplicate events
CREATE UNIQUE INDEX idx_events_dedup ON tasks.events (dedup_key)
WHERE dedup_key IS NOT NULL AND status NOT IN ('DEAD_LETTER');Event handlers that create tasks include dedup_key = f"evt:{event_id}:{task_type}" to prevent duplicates even if two instances race on the same event.
| Data | Default Retention | Mechanism |
|---|---|---|
| COMPLETED tasks | 30 days | Monthly partition DROP |
| FAILED tasks | 30 days | Monthly partition DROP |
| DEAD_LETTER tasks | 90 days | Separate retention window |
| PENDING/ACTIVE (stale) | visibility_timeout | Janitor requeues or dead-letters |
| Consumed events | Immediate | DELETE after processing |
| Dead letter events | 30 days | Maintenance supervisor cleanup |
Partition management (creating monthly partitions ahead of time and dropping expired ones) is handled by the in-app maintenance supervisor loop, which runs on a leader-elected schedule inside the service.
The primary user-facing orchestration endpoint: POST /ingestion/{catalog_id}/{collection_id}.
- Validation (Fail-Fast): Contacts the catalog to ensure a physical
LayerConfigexists. - Task Creation: Tells the
tasksmodule to create a record saving the file path and metadata mapping. - Dispatch: Triggers
FastAPIbackground tasks or remote containers. - Immediate Response: Reverses out instantly with a
202 Acceptedoffering the client astatus_url.
| Entry-point key | Class | Description |
|---|---|---|
gdal |
GdalInfoTask |
Runs gdalinfo on an asset |
gcp_provision |
ProvisioningTask |
Creates/verifies GCS bucket + eventing for a catalog |
gcp_catalog_cleanup |
GcpCatalogCleanupTask |
Tears down GCP resources on catalog/collection hard-deletion |
gcs_storage_event |
GcsStorageEventTask |
Reacts to GCS Pub/Sub object events (create/delete/archive assets) |
elasticsearch |
ElasticsearchIndexTask |
Indexes items into Elasticsearch |
ingestion |
IngestionTask |
Full ingestion pipeline |
dwh_join |
DwhJoinExportTask |
Data-warehouse join export |
export_features |
ExportFeaturesTask |
Feature export |
tiles_preseed |
TilePreseedTask |
Tile cache pre-seeding |
event_dispatch |
EventDispatchTask |
Dispatches catalog lifecycle events to subscribers |
webhook_delivery |
WebhookDeliveryTask |
HTTP delivery of event payloads to subscriber webhooks |
Tasks use a Strategy Pattern to handle complex reporting without polluting ingestion processing code.
- DatabaseStatusReporter: Batches API completion back down to the DB
progresscolumn integers. - GcsDetailedReporter: Stores granular row-by-row pass/fails in memory and pushes them into an external Cloud Storage Bucket strictly at the
task_finishbound.
Every reporter consumes the same per-record dict shape:
{
"status": "SUCCESS",
"message": null,
"record": {
"type": "Feature",
"id": "019e6318-...",
"geometry": { "type": "Polygon", "coordinates": [] },
"properties": { "CODE": "305", "NAME": "Berat" },
"stats": { "area": 17999118217.71, "centroid": "0101...", "bbox": [10.62, 44.79, 13.11, 46.68] },
"system": { "geoid": "019e6318-...", "external_id": "305", "asset_id": "ALBL1_01",
"geometry_hash": "…", "attributes_hash": "…", "validity": "[2024-01-01,)",
"transaction_time": "2026-02-26T18:09:04.131762+00:00", "deleted_at": null }
}
}properties— user attributes only.stats— flat, platform-derived:geometry_stats,attribute_stats,content_hashes,spatial_cells.system— identity + lifecycle. Absent keys are omitted (predictable shape:statsandsystemare always present, possibly empty).
Rejections use the same envelope: properties echoes the offending input, system carries whichever identity fields were derived, stats is {}.
A generic worker. It boots up looking for command line constants task_name and payload.
It initiates required system constants by iterating the SCOPE-filtered module registry, mapping the required dependencies, bridging synchronous databases, and dynamically invoking mapped functions based on the payload string cleanly avoiding rewriting infrastructure for every new async script operation.