# Data Jobs and Outbox Architecture This document records the technical boundary for Planet v1 data jobs, database outbox, and Earth refresh. For each data product's business purpose and end-to-end flow, see [Business Architecture and Data Flows](/home/ray/dev/linkong/planet/docs/technical/en/platform-data-flows.md). ## Architecture Boundary - PostgreSQL is the durable v1 job ledger and outbox. Kafka, Celery, and RQ are intentionally not part of v1. - `collection_tasks` records collection, data clearing, cache clearing, and non-database Earth refresh jobs. - `earth_data_change_events` records fact-table or derived-table changes and is the reliable source for Earth sync. - `LISTEN/NOTIFY` is only the low-latency wakeup path; the listener still polls unconsumed outbox rows. - Redis is mainly cache, auth helper, OTP / rate limit, temporary logs, and WebSocket support. It is not the durable queue. ## Database Change Sync ```mermaid flowchart LR Write["Fact or derived table write"] --> Trigger["PostgreSQL trigger"] Trigger --> Outbox["earth_data_change_events"] Trigger --> Notify["planet_earth_data_changes"] Outbox --> Listener["earth_db_change_listener"] Notify --> Listener Listener --> Adapter["earth_layer_adapters"] Adapter --> Cache["cache invalidation"] Cache --> WS["earth_updates"] ``` 1. Collection, clearing, location resolution, or projection jobs write fact or derived tables. 2. Statement triggers write outbox rows for `INSERT` / `UPDATE` / `DELETE`. 3. The listener wakes through notify or finds pending rows through polling. 4. The listener maps `table + source` to Earth layers, refresh strategy, and cache patterns through `earth_layer_adapters.py`. 5. The listener merges short-window same-layer events, invalidates cache, and broadcasts `earth_updates`. 6. The outbox row is marked consumed only after successful broadcast; failed rows stay retryable. DB changes no longer create default `earth_refresh` jobs, so they are not blocked by long same-source collection or clearing jobs. `earth_refresh` remains for manual cache clearing and non-DB refresh hints. ## Data Job Queue `collection_tasks` is the unified job ledger. Workers claim `queued` jobs with PostgreSQL `FOR UPDATE SKIP LOCKED`; write jobs for the same `source` run serially, while different sources may run in parallel. | task_type | Purpose | | --- | --- | | `collect` | Run a built-in datasource collector | | `clear_data` | Delete collected rows and declared derived rows for the source | | `clear_cache` | Delete Earth / dashboard cache for the source | | `earth_refresh` | Invalidate Earth layer cache and broadcast a refresh hint for non-DB changes | API handlers only create jobs and return `task_id`. Execution, progress, cancellation, and terminal state are written back by workers and pushed to the frontend through the `datasource_tasks` channel. Cancellation means “keep committed batches”: clicking stop marks the job as `cancelling` and cancels the in-memory coroutine. Already committed batches remain; unfinished batches follow the collector or cleanup rollback path. ## Earth Sync Event Model The unified event model is `earth.layer.changed`: ```json { "event": "earth.layer.changed", "action": "database_changed", "source": "celestrak_tle", "table": "collected_data", "operation": "DELETE", "layers": ["satellites"], "refresh_strategy": "clear_then_reload", "records_processed": 11125, "occurred_at": "2026-05-25T10:20:30Z" } ``` | strategy | Purpose | | --- | --- | | `clear_then_reload` | Clear local frontend layer objects first, then force a refetch. Prefer this for deletes. | | `reload` | Keep old objects until fresh data returns. Use it for location, metadata, or non-destructive updates. | | `delta` | Used only for `earth_interactables`; upsert or remove objects by id. | APIs must return HTTP 200 with an empty collection for real zero-data states; 5xx is reserved for real endpoint failures. After a delete event, if refetch fails, the frontend should keep the cleared state and show a lightweight error instead of restoring stale objects. ## Layer Adapter Contract `earth_layer_adapters.py` is the single registry for sources, derived tables, Earth layers, cache patterns, and refresh strategy. New layers should be added through an adapter entry, not through one-off button handlers, collector branches, or frontend special cases. Each adapter must declare: - Which source or table feeds which Earth layer. - Which Earth cache key patterns must be invalidated. - Which owned derived tables must be removed during `clear_data`. - The default refresh strategy for that layer. When a source is cleared, the `clear_data` job first deletes `collected_data.source = `, then deletes adapter-owned derived rows. Direct derived-table edits also trigger the outbox, so background jobs, admin APIs, and SQL repair scripts reach Earth as long as they mutate fact or derived tables. ## Operations and Troubleshooting Check whether outbox rows are piling up: ```sql SELECT id, table_name, operation, source, occurred_at FROM earth_data_change_events WHERE consumed_at IS NULL ORDER BY id LIMIT 20; ``` Check that triggers exist: ```sql SELECT tgname, tgrelid::regclass FROM pg_trigger WHERE tgname LIKE 'tr_planet_%_changed_%' ORDER BY 2, 1; ``` Useful log events: - `earth.db_changes.connected`: the listener connected to PostgreSQL and started listening. - `earth.db_changes.outbox_polled`: polling found unconsumed outbox rows. - `earth.db_changes.broadcasted`: an Earth refresh broadcast was produced. - `data_job.started` / `data_job.completed`: job execution state. If Earth does not update, check in order: fact table changed, outbox was consumed, adapter covers the `source/table`, the listener is online, frontend WebSocket is connected, and the visualization API returns HTTP 200 with either an empty collection or fresh data. ## Kafka-ready Boundaries Business code avoids depending on a concrete queue implementation by preserving these boundaries: - `JobQueue`: submit, claim, cancel, and complete data jobs. - `DataChangeBus`: publish database fact changes. - `EarthLayerAdapterRegistry`: declare source, layer, cache, and derived-data relationships. Kafka becomes appropriate when: - Several independent services must consume the same data-change stream. - AIS, BGP, or sensor streams become sustained high-throughput inputs. - Consumer groups, replay, and service decoupling are required. Spark becomes appropriate when: - Historical data reaches tens or hundreds of millions of rows and PostgreSQL aggregation becomes expensive. - Cross-source, long-window, spatiotemporal analysis is needed. - Raw data lands in Parquet / Iceberg / Delta and the system starts producing offline derived data products. For second-level continuous stream processing, evaluate Flink first. Spark is a better fit for batch or micro-batch analytics.