Change Signal Unification¶
Problem¶
The inbound question "how do we learn a source changed" is encoded in three places with overlapping value sets:
| Signal | Values | Runtime role |
|---|---|---|
change_signal (Table/Source) |
ttl probe ttl_probe native debezium kafka |
none (captured, inert) |
MV freshness_mode |
ttl probe ttl_probe |
refresh gate |
live.strategy |
poll native debezium kafka |
subscription provider |
change_signal is the union: ∩ freshness_mode = {ttl, probe, ttl_probe} (identical),
∩ live.strategy = {native, debezium, kafka} (identical). poll is ttl/probe with a
watermark. The same axis is expressed three times, and change_signal, the field designed to be authoritative, is read nowhere.
Target model¶
Three orthogonal axes:
| Axis | Field | Decides |
|---|---|---|
| Inbound detection | change_signal (resolve table.change_signal or source.change_signal) |
how we learn the source changed |
| Append vs replace + subscribable | Table.watermark_column |
set → incremental append + subscribable; unset → full replace |
| Is it landed | materialize / reachability / cache_ttl |
whether a local copy exists |
freshness_mode and live.strategy derive from change_signal; they are no longer
configured independently.
Derivation (provisa/core/change_signal.py)¶
resolve(table, source) -> str # table.change_signal or source.change_signal
is_poll(sig) = sig in {ttl, probe, ttl_probe}
is_push(sig) = sig in {native, debezium, kafka}
to_freshness_mode(sig) -> str | None # poll → same value; push → None (event-driven, no gate)
to_provider(sig, source_type) -> str # native → source_type; debezium → "debezium"; kafka → "kafka"; poll → source_type
Push signals skip the freshness gate: their landed copy is updated by applying incoming events, not by polling.
Rewired sites¶
- MV/landing build derives
freshness_modefromchange_signalviato_freshness_mode(resolve(...))at theMVDefinition(...)construction sites (api/app.py,api/admin/schema.py), threadingprobe_query. Previously these omittedfreshness_modeand always defaultedttl. - Provider dispatch keys off
change_signal.api/data/subscribe.py:_resolve_provider_typeresolves viato_provider(resolve_effective(...))rather than readinglive.strategydirectly. - Poll-job reconcile keys off
change_signal+Table.watermark_column.live/reconcile.pybuilds a poll spec whenis_poll(sig); a watermark selects append vs replace landing. - Validation keys off
change_signalincore/config_loader.py: debezium/kafka require a sourcecdcblock; push signals require a CDC-capable source or materialization store.
Delete / deprecate¶
live.strategyandlive.watermark_column(core/models.py) are superseded bychange_signalandTable.watermark_column. They are retained on the model as a legacy read-through: provider dispatch (api/data/subscribe.py) and poll reconcile (live/reconcile.py) go throughchange_signal.resolve_effective(...), which falls back tosignal_from_strategy(live.strategy)only when nochange_signalis set.change_signalis authoritative when present.- MV
freshness_modebecomes an internal derived value only.
Landing (consumption layer, shipped)¶
change_signal.select_landing_shape(sig, watermark_column) maps the signal to a landing shape,
applied by the DB-agnostic Core ops in federation/materialize_exec.py (driven by federation/store_writer.py), not by engine CTAS in mv/refresh.py:
- Replace:
DELETE+INSERT, for a poll signal with no watermark. - Append:
INSERT ... WHERE watermark > cursor, for a poll signal with awatermark_columnset. - CDC-apply: consume the debezium/kafka provider and upsert/tombstone by primary key into the
landed table via the dialect-agnostic
Connection.upsert. This is what makes landed deletes work. (A subscription still delivers inserts and updates only.)
Migration¶
V1, no migrations. Derivation happens at the read sites rather than by dropping the fields:
change_signal is authoritative, and live.strategy/live.watermark_column remain on the model
as a legacy fallback read through resolve_effective / signal_from_strategy. Existing JSONB rows
carrying live.strategy keep working: the resolver maps strategy → change_signal when the
latter is unset.
Sequencing (each phase leaves the tree green)¶
- Derivation module + rewire 1 (freshness_mode) + replace-path test.
- Rewire 2/3/4 (provider + reconcile + validation) + publish-downstream test.
- New append + CDC-apply landing + append/delete test.
- Delete
live.strategy/live.watermark_column.
Tests¶
Coverage is in unit tests (tests/unit/test_change_signal.py, test_materialize_landing.py,
test_subscribe_publish.py); the integration tests named in early planning
(test_landing_replace / append_delete / publish_downstream) were not created.
- Derivation:
change_signal → freshness_mode / provider / landing shapefor six values plus inherit. - Publish downstream: a Debezium/Kafka event through the resolved provider reaches the SSE stream and a Kafka sink.
- Landing replace: poll signal, no watermark, full rebuild.
- Landing append + CDC-apply: watermark signal → incremental insert;
op=dtombstone removes the row.