You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by "aaron-y-chen (via GitHub)" <gi...@apache.org> on 2026/08/05 03:05:46 UTC
[PR] Add Kinesis Data Streams trigger [airflow]
aaron-y-chen opened a new pull request, #71135:
URL: https://github.com/apache/airflow/pull/71135
related: #52712
This is the first PR for Kinesis part of #52712, I will integrate the trigger with the common-messaging interface and add a system/e2e test.
## Summary
Add an asynchronous Kinesis Data Streams trigger for consuming records without occupying worker slots.
The trigger polls all shards, emits JSON-serializable record batches, handles iterator expiry, throttling, pagination, and in-run resharding, and checkpoints per-shard sequence numbers when an asset state store is available. New shards first discovered after a restart honor the configured initial position, so `LATEST` may skip records written during the downtime.
This is the provider-side foundation for the common-messaging integration tracked in #52712.
---
##### Was generative AI tooling used to co-author this PR?
<!--
If generative AI tooling has been used in the process of authoring this PR, please
change below checkbox to `[X]` followed by the name of the tool, uncomment the "Generated-by".
-->
- [x] Yes (please specify the tool below)
Generated-by: [GPT 5.6-sol] following [the guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "jason810496 (via GitHub)" <gi...@apache.org>.
jason810496 commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3869048377
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = store.get(self._build_checkpoint_key(), default={}) or {}
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
Review Comment:
IIUC, this means we're calling a blocking call in the trigger run coroutine.
We should follow the same pattern (`asyncio.to_thread`) in https://github.com/apache/airflow/pull/71387 to workaround.
Or wait until https://github.com/apache/airflow/pull/72127 get merged and invoke the async native method from the accessor.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on PR #71135:
URL: https://github.com/apache/airflow/pull/71135#issuecomment-5192404841
Waiting for another PR #71145 to be merged.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3986874054
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,349 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from functools import cached_property
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_EXPIRED_ITERATOR_RETRIES = 2
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ Only these types are supported because ``AT_TIMESTAMP`` requires a timestamp and sequence-number
+ types require a starting sequence number for each shard. Checkpoints handle resuming each shard.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ @cached_property
+ def _asset_store_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ async def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = (
+ await asyncio.to_thread(store.get, self._asset_store_checkpoint_key, default={}) or {}
+ )
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ async def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return
+
+ try:
+ await asyncio.to_thread(
+ store.set,
+ self._asset_store_checkpoint_key,
+ dict(sequence_numbers),
+ )
Review Comment:
I checked the implementation in #72127 and fixed it. Thanks for the reminder 😄
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3878935620
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = store.get(self._build_checkpoint_key(), default={}) or {}
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return
+
+ try:
+ store.set(self._build_checkpoint_key(), dict(sequence_numbers))
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+
+ async def _find_shard_ids(self, client: BaseAwsConnection) -> list[str]:
+ paginator = client.get_paginator("list_shards")
+ shard_ids: list[str] = []
+ async for page in paginator.paginate(StreamName=self.stream_name):
+ shard_ids.extend(shard["ShardId"] for shard in page["Shards"])
+ return shard_ids
+
+ async def _get_shard_iterator(
+ self,
+ client: BaseAwsConnection,
+ shard_id: str,
+ after_sequence_number: str | None,
+ fallback_iterator_type: str,
+ ) -> str:
+ request: dict[str, Any] = {"StreamName": self.stream_name, "ShardId": shard_id}
+ if after_sequence_number:
+ request.update(
+ ShardIteratorType="AFTER_SEQUENCE_NUMBER",
+ StartingSequenceNumber=after_sequence_number,
+ )
+ else:
+ request["ShardIteratorType"] = fallback_iterator_type
+
+ try:
+ response = await client.get_shard_iterator(**request)
+ except client.exceptions.InvalidArgumentException:
+ if not after_sequence_number:
+ raise
+ self.log.warning(
+ "Stored Kinesis checkpoint for shard %s is no longer valid; using the configured initial position",
+ shard_id,
+ )
+ response = await client.get_shard_iterator(
+ StreamName=self.stream_name,
+ ShardId=shard_id,
+ ShardIteratorType=fallback_iterator_type,
+ )
+ return response["ShardIterator"]
+
+ async def _get_records(
+ self,
+ client: BaseAwsConnection,
+ shard_id: str,
+ shard_iterator: str,
+ after_sequence_number: str | None,
+ fallback_iterator_type: str,
+ ) -> dict[str, Any]:
+ try:
+ return await client.get_records(
+ ShardIterator=shard_iterator,
+ Limit=self.batch_size,
+ )
+ except client.exceptions.ExpiredIteratorException:
+ shard_iterator = await self._get_shard_iterator(
+ client,
+ shard_id,
+ after_sequence_number,
+ fallback_iterator_type,
+ )
+ return await client.get_records(
+ ShardIterator=shard_iterator,
+ Limit=self.batch_size,
+ )
Review Comment:
Nice catch! Let me use a for loop to handle it.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "jason810496 (via GitHub)" <gi...@apache.org>.
jason810496 commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3985085368
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,349 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from functools import cached_property
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_EXPIRED_ITERATOR_RETRIES = 2
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ Only these types are supported because ``AT_TIMESTAMP`` requires a timestamp and sequence-number
+ types require a starting sequence number for each shard. Checkpoints handle resuming each shard.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ @cached_property
+ def _asset_store_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ async def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = (
+ await asyncio.to_thread(store.get, self._asset_store_checkpoint_key, default={}) or {}
+ )
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ async def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return
+
+ try:
+ await asyncio.to_thread(
+ store.set,
+ self._asset_store_checkpoint_key,
+ dict(sequence_numbers),
+ )
Review Comment:
We will support `.aget` method in 3.3.2. So we need the compatible layer for `.aget` and `asyncio.to_thread(store.set, ...` as fallback.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "jason810496 (via GitHub)" <gi...@apache.org>.
jason810496 merged PR #71135:
URL: https://github.com/apache/airflow/pull/71135
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3878776249
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
Review Comment:
Initially, I followed the pattern used by other triggers and made this a `@property` (for example, `amazon/aws/triggers/sqs.py`).
Constructing kinesis hook performs no external IO, and `get_async_conn()` is called only once in `run()`. Therefore, the kinesis client is not created eagerly, and caching the hook would not change its lifecycle here.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "jason810496 (via GitHub)" <gi...@apache.org>.
jason810496 commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3985085368
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,349 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from functools import cached_property
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_EXPIRED_ITERATOR_RETRIES = 2
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ Only these types are supported because ``AT_TIMESTAMP`` requires a timestamp and sequence-number
+ types require a starting sequence number for each shard. Checkpoints handle resuming each shard.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ @cached_property
+ def _asset_store_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ async def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = (
+ await asyncio.to_thread(store.get, self._asset_store_checkpoint_key, default={}) or {}
+ )
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ async def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return
+
+ try:
+ await asyncio.to_thread(
+ store.set,
+ self._asset_store_checkpoint_key,
+ dict(sequence_numbers),
+ )
Review Comment:
We will support `.aget` method in the upcoming 3.3.2 version. So we need the compatible layer for `.aget` and `asyncio.to_thread(store.set, ...` as fallback.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on PR #71135:
URL: https://github.com/apache/airflow/pull/71135#issuecomment-5340033098
Thanks for the reviews, I believe this version is more concrete than before :)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3811455689
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,315 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Kinesis permits at most five
+ ``GetRecords`` calls per second per shard. When reading a backlog with ``TRIM_HORIZON``, draining
+ ``N`` records from one shard takes roughly ``ceil(N / batch_size) * waiter_delay`` seconds when
+ calls return full batches; with the defaults, 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if waiter_delay < 1:
+ raise ValueError("waiter_delay must be at least 1 second")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = store.get(self._build_checkpoint_key(), default={}) or {}
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return
+
+ try:
+ store.set(self._build_checkpoint_key(), dict(sequence_numbers))
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+
+ async def _find_shard_ids(self, client: BaseAwsConnection) -> list[str]:
+ paginator = client.get_paginator("list_shards")
+ shard_ids: list[str] = []
+ async for page in paginator.paginate(StreamName=self.stream_name):
+ shard_ids.extend(shard["ShardId"] for shard in page["Shards"])
+ return shard_ids
+
+ async def _get_shard_iterator(
+ self,
+ client: BaseAwsConnection,
+ shard_id: str,
+ after_sequence_number: str | None,
+ fallback_iterator_type: str,
+ ) -> str:
+ request: dict[str, Any] = {"StreamName": self.stream_name, "ShardId": shard_id}
+ if after_sequence_number:
+ request.update(
+ ShardIteratorType="AFTER_SEQUENCE_NUMBER",
+ StartingSequenceNumber=after_sequence_number,
+ )
+ else:
+ request["ShardIteratorType"] = fallback_iterator_type
+
+ try:
+ response = await client.get_shard_iterator(**request)
+ except client.exceptions.InvalidArgumentException:
+ if not after_sequence_number:
+ raise
+ self.log.warning(
+ "Stored Kinesis checkpoint for shard %s is no longer valid; using the configured initial position",
+ shard_id,
+ )
+ response = await client.get_shard_iterator(
+ StreamName=self.stream_name,
+ ShardId=shard_id,
+ ShardIteratorType=fallback_iterator_type,
+ )
+ return response["ShardIterator"]
+
+ @staticmethod
+ def _build_event_records(shard_id: str, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ event_records = []
+ for record in records:
+ timestamp = record.get("ApproximateArrivalTimestamp")
+ event_record = {
+ "ShardId": shard_id,
+ "SequenceNumber": record["SequenceNumber"],
+ "PartitionKey": record["PartitionKey"],
+ "ApproximateArrivalTimestamp": timestamp.isoformat() if timestamp else None,
+ "Data": base64.b64encode(record["Data"]).decode("ascii"),
+ }
+ event_records.append(event_record)
+ return event_records
+
+ async def run(self) -> AsyncIterator[TriggerEvent]:
+ loaded_sequence_numbers = self._load_checkpoint()
+
+ async with await self.hook.get_async_conn() as client:
+ shard_ids = await self._find_shard_ids(client)
+ known_shard_ids = set(shard_ids)
+ sequence_numbers = {
+ shard_id: sequence_number
+ for shard_id, sequence_number in loaded_sequence_numbers.items()
+ if shard_id in known_shard_ids
+ }
+ checkpoint_dirty = sequence_numbers != loaded_sequence_numbers
+ iterators: dict[str, str] = {}
+ fallback_iterator_types: dict[str, str] = {}
+
+ for shard_id in shard_ids:
+ iterators[shard_id] = await self._get_shard_iterator(
+ client,
+ shard_id,
+ sequence_numbers.get(shard_id),
+ self.shard_iterator_type,
+ )
+
+ while True:
+ for shard_id, shard_iterator in list(iterators.items()):
+ try:
+ response = await client.get_records(
+ ShardIterator=shard_iterator,
+ Limit=self.batch_size,
+ )
+ except client.exceptions.ExpiredIteratorException:
Review Comment:
I checked the [doc](https://docs.aws.amazon.com/streams/latest/dev/troubleshooting-consumers.html) and there's actually a bug if set `waiter_delay` 300s or more. The whole process will become:
`get_records -> sleep for 600s -> next get_records raises ExpiredIteratorException -> acquire a new iterator but defer using it until the next sweep -> sleep again -> repeat`
So your concern makes sense, let me try to fix it.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3750332806
##########
providers/amazon/tests/unit/amazon/aws/triggers/test_kinesis.py:
##########
@@ -0,0 +1,658 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import datetime
+from types import SimpleNamespace
+from unittest import mock
+from unittest.mock import AsyncMock
+
+import pytest
+
+from airflow.providers.amazon.aws.triggers.kinesis import KinesisTrigger
+from airflow.triggers.base import TriggerEvent
+
+MODULE = "airflow.providers.amazon.aws.triggers.kinesis"
+STREAM_NAME = "test-stream"
+AWS_CONN_ID = "test-aws-conn"
+REGION_NAME = "us-east-1"
+SHARD_ID = "shardId-000000000000"
+CHILD_SHARD_ID = "shardId-000000000001"
+
+
+class ExpiredIteratorException(Exception):
+ pass
+
+
+class InvalidArgumentException(Exception):
+ pass
+
+
+class ProvisionedThroughputExceededException(Exception):
+ pass
+
+
+class StopPolling(Exception):
+ pass
Review Comment:
The first three are test stand-ins for real Kinesis exceptions. StopPolling is a test-only sentinel raised by the mocked `asyncio.sleep()` to stop the trigger's infinite polling loop deterministically.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "jason810496 (via GitHub)" <gi...@apache.org>.
jason810496 commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3869057345
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = store.get(self._build_checkpoint_key(), default={}) or {}
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return
+
+ try:
+ store.set(self._build_checkpoint_key(), dict(sequence_numbers))
+ except ValueError:
Review Comment:
This is the second PR that touched the Task Store within the trigger. https://github.com/apache/airflow/pull/71387 is the first one.
cc @amoghrajesh to double check regarding the usage.
Just in case using Task Store within trigger isn't an expected pattern.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3879255452
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
Review Comment:
Yes, intentionally. In this PR, I'd prefer to keep the initial-position API limited to `LATEST` and `TRIM_HORIZON`, since they do not require additional arguments.
Supporting `AT_TIMESTAMP` would require adding and serializing a timestamp parameter, while `AT_SEQUENCE_NUMBER` would require a per-shard sequence-number mapping because this trigger polls all shards. Both would add public API surface with their own semantics around checkpoint recovery and resharding, so I think they are better considered separately.
I'll make the reasoning explicit in the docstring; validation already rejects the other values with the supported set in the message.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on PR #71135:
URL: https://github.com/apache/airflow/pull/71135#issuecomment-5199306039
Okay, all CI failures have been fixed.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "vincbeck (via GitHub)" <gi...@apache.org>.
vincbeck commented on PR #71135:
URL: https://github.com/apache/airflow/pull/71135#issuecomment-5320287575
@seanghaeli @ramitkataria
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "ramitkataria (via GitHub)" <gi...@apache.org>.
ramitkataria commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3805960775
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,315 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Kinesis permits at most five
+ ``GetRecords`` calls per second per shard. When reading a backlog with ``TRIM_HORIZON``, draining
+ ``N`` records from one shard takes roughly ``ceil(N / batch_size) * waiter_delay`` seconds when
+ calls return full batches; with the defaults, 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if waiter_delay < 1:
Review Comment:
We validate `waiter_delay > 0` here. Is there an upper bound worth enforcing too? I'm thinking about the relationship between this value and the 5 minute iterator TTL.
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,315 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Kinesis permits at most five
+ ``GetRecords`` calls per second per shard. When reading a backlog with ``TRIM_HORIZON``, draining
+ ``N`` records from one shard takes roughly ``ceil(N / batch_size) * waiter_delay`` seconds when
+ calls return full batches; with the defaults, 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if waiter_delay < 1:
+ raise ValueError("waiter_delay must be at least 1 second")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = store.get(self._build_checkpoint_key(), default={}) or {}
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return
+
+ try:
+ store.set(self._build_checkpoint_key(), dict(sequence_numbers))
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+
+ async def _find_shard_ids(self, client: BaseAwsConnection) -> list[str]:
+ paginator = client.get_paginator("list_shards")
+ shard_ids: list[str] = []
+ async for page in paginator.paginate(StreamName=self.stream_name):
+ shard_ids.extend(shard["ShardId"] for shard in page["Shards"])
+ return shard_ids
+
+ async def _get_shard_iterator(
+ self,
+ client: BaseAwsConnection,
+ shard_id: str,
+ after_sequence_number: str | None,
+ fallback_iterator_type: str,
+ ) -> str:
+ request: dict[str, Any] = {"StreamName": self.stream_name, "ShardId": shard_id}
+ if after_sequence_number:
+ request.update(
+ ShardIteratorType="AFTER_SEQUENCE_NUMBER",
+ StartingSequenceNumber=after_sequence_number,
+ )
+ else:
+ request["ShardIteratorType"] = fallback_iterator_type
+
+ try:
+ response = await client.get_shard_iterator(**request)
+ except client.exceptions.InvalidArgumentException:
+ if not after_sequence_number:
+ raise
+ self.log.warning(
+ "Stored Kinesis checkpoint for shard %s is no longer valid; using the configured initial position",
+ shard_id,
+ )
+ response = await client.get_shard_iterator(
+ StreamName=self.stream_name,
+ ShardId=shard_id,
+ ShardIteratorType=fallback_iterator_type,
+ )
+ return response["ShardIterator"]
+
+ @staticmethod
+ def _build_event_records(shard_id: str, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ event_records = []
+ for record in records:
+ timestamp = record.get("ApproximateArrivalTimestamp")
+ event_record = {
+ "ShardId": shard_id,
+ "SequenceNumber": record["SequenceNumber"],
+ "PartitionKey": record["PartitionKey"],
+ "ApproximateArrivalTimestamp": timestamp.isoformat() if timestamp else None,
+ "Data": base64.b64encode(record["Data"]).decode("ascii"),
+ }
+ event_records.append(event_record)
+ return event_records
+
+ async def run(self) -> AsyncIterator[TriggerEvent]:
+ loaded_sequence_numbers = self._load_checkpoint()
+
+ async with await self.hook.get_async_conn() as client:
+ shard_ids = await self._find_shard_ids(client)
+ known_shard_ids = set(shard_ids)
+ sequence_numbers = {
+ shard_id: sequence_number
+ for shard_id, sequence_number in loaded_sequence_numbers.items()
+ if shard_id in known_shard_ids
+ }
+ checkpoint_dirty = sequence_numbers != loaded_sequence_numbers
+ iterators: dict[str, str] = {}
+ fallback_iterator_types: dict[str, str] = {}
+
+ for shard_id in shard_ids:
+ iterators[shard_id] = await self._get_shard_iterator(
+ client,
+ shard_id,
+ sequence_numbers.get(shard_id),
+ self.shard_iterator_type,
+ )
+
+ while True:
+ for shard_id, shard_iterator in list(iterators.items()):
+ try:
+ response = await client.get_records(
+ ShardIterator=shard_iterator,
+ Limit=self.batch_size,
+ )
+ except client.exceptions.ExpiredIteratorException:
Review Comment:
On `ExpiredIteratorException` we fetch a fresh iterator and `continue`. Can you walk me through when that new iterator actually gets used? As I read it, the next `get_records` for this shard happens on the following sweep, `waiter_delay` seconds later. What does that look like with `waiter_delay=600`, given iterators expire after 5 minutes?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "jason810496 (via GitHub)" <gi...@apache.org>.
jason810496 commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3869048377
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = store.get(self._build_checkpoint_key(), default={}) or {}
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
Review Comment:
IIUC, this means we're calling a blocking call in the trigger run coroutine.
We should follow the same pattern (`asyncio.to_thread`) in https://github.com/apache/airflow/pull/71387 to workaround.
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = store.get(self._build_checkpoint_key(), default={}) or {}
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return
+
+ try:
+ store.set(self._build_checkpoint_key(), dict(sequence_numbers))
+ except ValueError:
Review Comment:
This is the second PR that touched the Task Store within the trigger. https://github.com/apache/airflow/pull/71387 is the first one.
cc @amoghrajesh to double check regarding the usage.
Just in case using Task Store within trigger isn't a expected pattern.
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
Review Comment:
Not sure do we need to define as cached property or not? We might need to check the lifecycle of the Kinesis client. IIRC, this might be related to the https://lists.apache.org/thread/owp7869xmr5cyks9gx3lp66x53w1owyg discussion ( https://github.com/apache/airflow/pull/71701 PR).
Just in case Kinesis client invoke eager external calls during construction.
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
Review Comment:
Would it be better to make this as a cache property? (e.g. asset_store_checkpoint_key
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on PR #71135:
URL: https://github.com/apache/airflow/pull/71135#issuecomment-5187350532
Those failures seem to be caused by the same issue:
```
flit build: error: unrecognized arguments: --no-setup-py
```
There is already a PR to fix it: #71118
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on PR #71135:
URL: https://github.com/apache/airflow/pull/71135#issuecomment-5311236666
Hi @jason810496, I'd appreciate it if you could take a look when you have time. :)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "amoghrajesh (via GitHub)" <gi...@apache.org>.
amoghrajesh commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3869603398
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
Review Comment:
`AT_TIMESTAMP` and `AT_SEQUENCE_NUMBER` are rejected?
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = store.get(self._build_checkpoint_key(), default={}) or {}
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return
+
+ try:
+ store.set(self._build_checkpoint_key(), dict(sequence_numbers))
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+
+ async def _find_shard_ids(self, client: BaseAwsConnection) -> list[str]:
+ paginator = client.get_paginator("list_shards")
+ shard_ids: list[str] = []
+ async for page in paginator.paginate(StreamName=self.stream_name):
+ shard_ids.extend(shard["ShardId"] for shard in page["Shards"])
+ return shard_ids
+
+ async def _get_shard_iterator(
+ self,
+ client: BaseAwsConnection,
+ shard_id: str,
+ after_sequence_number: str | None,
+ fallback_iterator_type: str,
+ ) -> str:
+ request: dict[str, Any] = {"StreamName": self.stream_name, "ShardId": shard_id}
+ if after_sequence_number:
+ request.update(
+ ShardIteratorType="AFTER_SEQUENCE_NUMBER",
+ StartingSequenceNumber=after_sequence_number,
+ )
+ else:
+ request["ShardIteratorType"] = fallback_iterator_type
+
+ try:
+ response = await client.get_shard_iterator(**request)
+ except client.exceptions.InvalidArgumentException:
+ if not after_sequence_number:
+ raise
+ self.log.warning(
+ "Stored Kinesis checkpoint for shard %s is no longer valid; using the configured initial position",
+ shard_id,
+ )
+ response = await client.get_shard_iterator(
+ StreamName=self.stream_name,
+ ShardId=shard_id,
+ ShardIteratorType=fallback_iterator_type,
+ )
+ return response["ShardIterator"]
+
+ async def _get_records(
+ self,
+ client: BaseAwsConnection,
+ shard_id: str,
+ shard_iterator: str,
+ after_sequence_number: str | None,
+ fallback_iterator_type: str,
+ ) -> dict[str, Any]:
+ try:
+ return await client.get_records(
+ ShardIterator=shard_iterator,
+ Limit=self.batch_size,
+ )
+ except client.exceptions.ExpiredIteratorException:
+ shard_iterator = await self._get_shard_iterator(
+ client,
+ shard_id,
+ after_sequence_number,
+ fallback_iterator_type,
+ )
+ return await client.get_records(
+ ShardIterator=shard_iterator,
+ Limit=self.batch_size,
+ )
Review Comment:
A second `ExpiredIteratorException` will propagate through and kill the trigger.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on PR #71135:
URL: https://github.com/apache/airflow/pull/71135#issuecomment-5564182320
Thanks for the reviews! I believe it's more solid than before :)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "vincbeck (via GitHub)" <gi...@apache.org>.
vincbeck commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3750203472
##########
providers/amazon/tests/unit/amazon/aws/triggers/test_kinesis.py:
##########
@@ -0,0 +1,658 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import datetime
+from types import SimpleNamespace
+from unittest import mock
+from unittest.mock import AsyncMock
+
+import pytest
+
+from airflow.providers.amazon.aws.triggers.kinesis import KinesisTrigger
+from airflow.triggers.base import TriggerEvent
+
+MODULE = "airflow.providers.amazon.aws.triggers.kinesis"
+STREAM_NAME = "test-stream"
+AWS_CONN_ID = "test-aws-conn"
+REGION_NAME = "us-east-1"
+SHARD_ID = "shardId-000000000000"
+CHILD_SHARD_ID = "shardId-000000000001"
+
+
+class ExpiredIteratorException(Exception):
+ pass
+
+
+class InvalidArgumentException(Exception):
+ pass
+
+
+class ProvisionedThroughputExceededException(Exception):
+ pass
+
+
+class StopPolling(Exception):
+ pass
Review Comment:
Why you need this?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3878777477
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
Review Comment:
I think it's a good idea. I'll do it.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "amoghrajesh (via GitHub)" <gi...@apache.org>.
amoghrajesh commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3869582537
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = store.get(self._build_checkpoint_key(), default={}) or {}
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return
+
+ try:
+ store.set(self._build_checkpoint_key(), dict(sequence_numbers))
+ except ValueError:
Review Comment:
It's a good pattern to use (but slight correction: its asset state store not task state store), he Triggerer itself injects it at: https://github.com/apache/airflow/blob/main/airflow-core/src/airflow/jobs/triggerer_job_runner.py#L1405-L1408, so a `BaseEventTrigger` attached to a watched asset is designed to read and write asset state. Watermarking a stream cursor is one of the use case.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3878726638
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,336 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Must be less than the five-minute
+ shard iterator lifetime. Kinesis permits at most five ``GetRecords`` calls per second per shard.
+ When reading a backlog with ``TRIM_HORIZON``, draining ``N`` records from one shard takes roughly
+ ``ceil(N / batch_size) * waiter_delay`` seconds when calls return full batches; with the defaults,
+ 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if not 0 < waiter_delay < 300:
+ raise ValueError("waiter_delay must be between 1 and 299 seconds")
+
+ self.stream_name = stream_name
+ self.aws_conn_id = aws_conn_id
+ self.shard_iterator_type = shard_iterator_type
+ self.batch_size = batch_size
+ self.waiter_delay = waiter_delay
+ self.region_name = region_name
+ self.verify = verify
+ self.botocore_config = botocore_config
+ self._checkpoint_warning_logged = False
+
+ def serialize(self) -> tuple[str, dict[str, Any]]:
+ return (
+ self.__class__.__module__ + "." + self.__class__.__qualname__,
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "shard_iterator_type": self.shard_iterator_type,
+ "batch_size": self.batch_size,
+ "waiter_delay": self.waiter_delay,
+ "region_name": self.region_name,
+ "verify": self.verify,
+ "botocore_config": self.botocore_config,
+ },
+ )
+
+ @property
+ def hook(self) -> KinesisHook:
+ return KinesisHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
+
+ def _build_checkpoint_key(self) -> str:
+ identity = json.dumps(
+ {
+ "stream_name": self.stream_name,
+ "aws_conn_id": self.aws_conn_id,
+ "region_name": self.region_name,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return f"{_CHECKPOINT_KEY_PREFIX}:{hashlib.sha256(identity).hexdigest()}"
+
+ def _log_checkpoint_warning_once(self, message: str) -> None:
+ if self._checkpoint_warning_logged:
+ return
+ self.log.warning(message)
+ self._checkpoint_warning_logged = True
+
+ def _load_checkpoint(self) -> dict[str, str]:
+ store = getattr(self, "asset_state_store", None)
+ if store is None:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing is unavailable; using an in-memory cursor"
+ )
+ return {}
+
+ try:
+ checkpoint = store.get(self._build_checkpoint_key(), default={}) or {}
+ except ValueError:
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpointing requires a single watched asset; using an in-memory cursor"
+ )
+ return {}
+
+ if not isinstance(checkpoint, dict) or not all(
+ isinstance(shard_id, str) and isinstance(sequence_number, str)
+ for shard_id, sequence_number in checkpoint.items()
+ ):
+ self._log_checkpoint_warning_once(
+ "Kinesis checkpoint data is invalid; using the configured initial position"
+ )
+ return {}
+ return dict(checkpoint)
+
+ def _save_checkpoint(self, sequence_numbers: dict[str, str]) -> None:
+ store = getattr(self, "asset_state_store", None)
Review Comment:
Yes, if we don't want to block the event loop, we need to make it async. Let me go the workaround way first. If #72127 gets merged before then this PR, I'll revisit it afterward.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "aaron-y-chen (via GitHub)" <gi...@apache.org>.
aaron-y-chen commented on code in PR #71135:
URL: https://github.com/apache/airflow/pull/71135#discussion_r3811631536
##########
providers/amazon/src/airflow/providers/amazon/aws/triggers/kinesis.py:
##########
@@ -0,0 +1,315 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+from collections.abc import AsyncIterator
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.amazon.aws.hooks.kinesis import KinesisHook
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_0_PLUS
+
+if AIRFLOW_V_3_0_PLUS:
+ from airflow.triggers.base import BaseEventTrigger, TriggerEvent
+else:
+ from airflow.triggers.base import ( # type: ignore
+ BaseTrigger as BaseEventTrigger,
+ TriggerEvent,
+ )
+
+if TYPE_CHECKING:
+ from airflow.providers.amazon.aws.hooks.base_aws import BaseAwsConnection
+
+_CHECKPOINT_KEY_PREFIX = "kinesis_shard_sequence_numbers"
+_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS = frozenset({"LATEST", "TRIM_HORIZON"})
+
+
+class KinesisTrigger(BaseEventTrigger):
+ """
+ Wait asynchronously for records on an Amazon Kinesis Data Stream.
+
+ The trigger is long-running and emits one event for each non-empty shard response. Record data is
+ base64-encoded in the event payload and must be decoded by the consumer. Delivery is best-effort:
+ a triggerer failure can cause records to be repeated or missed around the failure window.
+
+ When Airflow provides an asset state store for a single watched asset, the trigger checkpoints the
+ last sequence number read from each shard. The same asset and stream identity share one logical cursor;
+ do not configure multiple watchers that require independent progress for the same stream on one asset.
+
+ :param stream_name: Name of the Kinesis Data Stream to watch.
+ :param aws_conn_id: AWS connection id.
+ :param shard_iterator_type: Position used when a shard has no checkpoint. ``LATEST`` only sees records
+ that arrive after the watcher starts; ``TRIM_HORIZON`` starts from the oldest retained record.
+ :param batch_size: Maximum records per ``GetRecords`` call and trigger event. Must be between 1 and
+ 10,000. Record data is base64-encoded before it is stored in the metadata database, so use a
+ conservative value for large records.
+ :param waiter_delay: Seconds between complete polling sweeps. Kinesis permits at most five
+ ``GetRecords`` calls per second per shard. When reading a backlog with ``TRIM_HORIZON``, draining
+ ``N`` records from one shard takes roughly ``ceil(N / batch_size) * waiter_delay`` seconds when
+ calls return full batches; with the defaults, 10,000 records take about 1,000 seconds.
+ :param region_name: AWS region for the Kinesis client.
+ :param verify: Whether to verify SSL certificates, or the path to a CA bundle.
+ :param botocore_config: Botocore configuration passed to the Kinesis client.
+ """
+
+ def __init__(
+ self,
+ stream_name: str,
+ aws_conn_id: str | None = "aws_default",
+ shard_iterator_type: str = "LATEST",
+ batch_size: int = 100,
+ waiter_delay: int = 10,
+ region_name: str | None = None,
+ verify: bool | str | None = None,
+ botocore_config: dict | None = None,
+ ) -> None:
+ super().__init__()
+ if shard_iterator_type not in _ITERATOR_TYPES_WITHOUT_EXTRA_ARGS:
+ raise ValueError(
+ "shard_iterator_type must be one of "
+ f"{sorted(_ITERATOR_TYPES_WITHOUT_EXTRA_ARGS)}; got {shard_iterator_type!r}"
+ )
+ if not 1 <= batch_size <= 10_000:
+ raise ValueError("batch_size must be between 1 and 10000")
+ if waiter_delay < 1:
Review Comment:
Thanks, I bounded `waiter_delay`, I think it should be fixed.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add Kinesis Data Streams trigger [airflow]
Posted by "o-nikolas (via GitHub)" <gi...@apache.org>.
o-nikolas commented on PR #71135:
URL: https://github.com/apache/airflow/pull/71135#issuecomment-5626616351
@ramitkataria and @amoghrajesh is this ready for some approvals and merge? (CC: @vincbeck, I see that you also had a brief look)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@airflow.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org