Skip to content

Add IcebergTableSnapshotTrigger for event-driven scheduling - #71387

Merged
vincbeck merged 6 commits into
apache:mainfrom
1fanwang:iceberg-snapshot-trigger
Aug 17, 2026
Merged

Add IcebergTableSnapshotTrigger for event-driven scheduling#71387
vincbeck merged 6 commits into
apache:mainfrom
1fanwang:iceberg-snapshot-trigger

Conversation

@1fanwang

@1fanwang 1fanwang commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

The Iceberg provider ships a hook and nothing else, so there is no way to schedule a DAG on "this table has new data". Anyone who wants it writes their own trigger.

IcebergTableSnapshotTrigger polls a table's branch head through the existing IcebergHook and emits an event when it advances, so a commit can drive an AssetWatcher:

orders = Asset(
    "orders",
    watchers=[AssetWatcher(name="orders_commits", trigger=IcebergTableSnapshotTrigger(table="sales.orders"))],
)

@dag(schedule=[orders])
def downstream(): ...

The event carries snapshot_id and previous_snapshot_id, so a task can scan the delta instead of the whole table.

Polling is what Iceberg supports: the REST catalog spec defines no subscribe, webhook or event endpoint, and none is proposed.

Two behaviors worth calling out, both found by running it on a real Airflow rather than in tests:

  • The cursor lives in the asset_state_store watermark, not on the instance. serialize() is captured once when the trigger row is written, so a value mutated during run() is lost on restart and the trigger re-reports a commit it already emitted. Read through getattr, since that attribute postdates the oldest Airflow this provider supports.
  • An absent table is waited on, like an absent branch. A watcher outlives the table it watches, and raising kills the trigger, which the triggerer then restarts about once a second until the table appears.

A trigger watched by more than one asset gets one accessor per asset, where the watermark shorthand raises. It degrades to no watermark; #71460 adds what is needed to keep a cursor there.

Are these changes tested?

Unit tests — 19 passed

Cold start emits the current head; an unchanged table emits nothing; each commit emits one event carrying the snapshot it replaced; an absent branch or table is waited on; the watermark is restored on start and written per event; a state store failure is not mistaken for the several-assets case; the triggerer's dispatch reaches the poll loop; serialization round-trips.

$ pytest providers/apache/iceberg/tests/unit/apache/iceberg/triggers/test_iceberg.py -q
19 passed

Removing triggers/iceberg.py fails collection, so the tests cannot pass vacuously.

Then on a running Airflow: Postgres metadata database, api-server, dag-processor, scheduler and triggerer as separate processes, a real Iceberg catalog behind a real iceberg_default connection, and a DAG scheduled on the watched asset. Nothing stubbed.

On a running Airflow
$ airflow db migrate && airflow connections add iceberg_default --conn-type iceberg \
    --conn-extra '{"type":"sql","uri":"sqlite:////tmp/ice-wh/catalog.db","warehouse":"file:///tmp/ice-wh"}'
$ airflow api-server & airflow dag-processor & airflow scheduler & airflow triggerer &
$ airflow dags unpause iceberg_downstream

-- the trigger is registered and wired to the asset
 id |                                   classpath
----+-------------------------------------------------------------------------------
  1 | airflow.providers.apache.iceberg.triggers.iceberg.IcebergTableSnapshotTrigger
 trigger_id |  name
------------+--------
          1 | orders

1. table absent          starts=1  restarts=0  crashes=0  events=0
2. table created         Trigger fired event  TriggerEvent<{'table': 'sales.orders', ...}>
3. dag_run                iceberg_downstream | asset_triggered__2026-08-11T21:47:04 | asset_triggered
4. asset_state_store      asset_id=1  key=snapshot_id  value=8102133228104772796  last_updated_by_kind=watcher
5. triggerer killed and restarted, no new commit
   dag_runs=1 (unchanged)   events fired since restart=0

Step 4 is the real asset_state_store row, written by the triggerer over the execution API rather than by a test double. Step 5 is why the watermark is not just a kwarg: killing the triggerer and bringing it back produced no second run for a commit already reported.

An example DAG is included, following the Redis message-queue one.

The Iceberg provider ships a hook and nothing else, so there is no way to
schedule a DAG on 'this table has new data'. Anyone wanting it writes their own
trigger.

Add a trigger that polls a table's branch head through the existing IcebergHook
and emits an event when it advances, so a table commit can drive an AssetWatcher.
Triggers sharing a catalog connection, branch and poll interval report the same
shared_stream_key, so watching many tables in one catalog costs a single poll
rather than one per table.

Scoped to the trigger. Registering an iceberg:// asset URI scheme is left out:
that depends on how Iceberg datasets are named in OpenLineage, which is still
open, and a watcher works with any Asset name.

Signed-off-by: 1fanwang <1fannnw@gmail.com>
@1fanwang
1fanwang force-pushed the iceberg-snapshot-trigger branch from 9cb12ef to c9ce1f7 Compare August 10, 2026 22:19
@1fanwang
1fanwang marked this pull request as ready for review August 10, 2026 22:59
@1fanwang
1fanwang requested a review from Fokko as a code owner August 10, 2026 22:59
serialize() is captured once when the trigger row is written, so
last_seen_snapshot_id mutated on self is lost when the triggerer restarts. The
trigger then saw the current head as new and emitted it again, scheduling a DAG
run for a commit it had already reported.

Keep the cursor in the asset_state_store watermark the triggerer injects for
watcher triggers, and seed from the kwarg only on the first run. Read through
getattr because that attribute postdates the Airflow versions this provider
supports, and skip persistence when several assets watch one trigger, since
there is then no single cursor to keep.

Signed-off-by: 1fanwang <1fannnw@gmail.com>
@1fanwang
1fanwang force-pushed the iceberg-snapshot-trigger branch from ca881b1 to 4108f03 Compare August 11, 2026 00:00
Comment thread providers/apache/iceberg/src/airflow/providers/apache/iceberg/triggers/iceberg.py Outdated
A non-None shared_stream_key sends the triggerer to filter_shared_stream
instead of run(), and the base method raises because this trigger implements
neither it nor open_shared_stream. The trigger never polled; every test passed
because they call run() directly.

Sharing was not reachable anyway: the key omits the table, so one poll could
only serve a group by listing the whole catalog. Each trigger polls its own
table, which is what run() already did.

Also narrow the watermark guard. A state store backend raises ValueError from
the same call as the several-assets case, so catching both hid a real failure
and disabled the watermark silently.

Cover the triggerer's dispatch rather than calling run() directly, so a
reintroduced key fails instead of passing.

Signed-off-by: 1fanwang <1fannnw@gmail.com>
@1fanwang
1fanwang force-pushed the iceberg-snapshot-trigger branch from 244516f to 129ef13 Compare August 11, 2026 21:52
@1fanwang
1fanwang marked this pull request as draft August 11, 2026 22:07
A watcher outlives the table it watches, so a table created after the DAG is
written is normal. load_table raised there, which killed the trigger and had
the triggerer restart it about once a second until the table appeared.

Treat an absent table like an absent branch and keep polling.

Also read shared_stream_key through getattr in the tests, since it does not
exist on the older Airflow versions this provider supports.

Signed-off-by: 1fanwang <1fannnw@gmail.com>
@1fanwang
1fanwang force-pushed the iceberg-snapshot-trigger branch from 5e6aab8 to 1174d01 Compare August 11, 2026 22:33
Comment thread providers/apache/iceberg/src/airflow/providers/apache/iceberg/triggers/iceberg.py Outdated
Signed-off-by: 1fanwang <1fannnw@gmail.com>
@1fanwang
1fanwang marked this pull request as ready for review August 12, 2026 19:02
@1fanwang
1fanwang force-pushed the iceberg-snapshot-trigger branch from b32fd50 to e78097d Compare August 12, 2026 20:01
Signed-off-by: 1fanwang <1fannnw@gmail.com>
@1fanwang
1fanwang force-pushed the iceberg-snapshot-trigger branch from e78097d to 77b3854 Compare August 12, 2026 23:45
@eladkal
eladkal requested a review from vincbeck August 17, 2026 20:09
store = getattr(self, "asset_state_store", None)
if store is not None:
try:
stored = await asyncio.to_thread(store.get, WATERMARK_KEY)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of reinventing the async behaviour in every trigger, we should migrate this to aget, aset etc once #72127 lands.

Comment on lines +27 to +33
if AIRFLOW_V_3_0_PLUS:
from airflow.triggers.base import BaseEventTrigger, TriggerEvent
else:
from airflow.triggers.base import ( # type: ignore[assignment]
BaseTrigger as BaseEventTrigger,
TriggerEvent,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid if/else based on version as much as possible, gets hard to manage conditional code over time. An improvement: #72140

@vincbeck

Copy link
Copy Markdown
Contributor

@1fanwang can you work on these comments in a separate PR please?

@1fanwang

Copy link
Copy Markdown
Contributor Author

@1fanwang can you work on these comments in a separate PR please?

Ack @vincbeck checking now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants