This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 1cbcc306be6 Stop BigQueryStreamingBufferEmptySensor reporting empty 
too early (#69118)
1cbcc306be6 is described below

commit 1cbcc306be68472ecbfdd1a6c488f2d507b67d94
Author: Anas Khan <[email protected]>
AuthorDate: Sat Oct 31 12:00:23 2026 +0530

    Stop BigQueryStreamingBufferEmptySensor reporting empty too early (#69118)
    
    Require consecutive empty readings before the BigQuery streaming buffer 
sensor succeeds, and defer reschedule confirmations to the trigger with the 
remaining timeout.
    
    ---------
    
    Co-authored-by: Shahar Epstein <[email protected]>
---
 .../providers/google/cloud/sensors/bigquery.py     |  73 +++++++++--
 .../providers/google/cloud/triggers/bigquery.py    |  33 ++++-
 .../example_bigquery_streaming_buffer_sensor.py    |  19 +--
 .../unit/google/cloud/sensors/test_bigquery.py     | 140 ++++++++++++++++++++-
 .../unit/google/cloud/triggers/test_bigquery.py    |  82 +++++++++++-
 5 files changed, 313 insertions(+), 34 deletions(-)

diff --git 
a/providers/google/src/airflow/providers/google/cloud/sensors/bigquery.py 
b/providers/google/src/airflow/providers/google/cloud/sensors/bigquery.py
index 65531b857bb..877517b31a6 100644
--- a/providers/google/src/airflow/providers/google/cloud/sensors/bigquery.py
+++ b/providers/google/src/airflow/providers/google/cloud/sensors/bigquery.py
@@ -28,15 +28,18 @@ from google.api_core.exceptions import NotFound
 from google.cloud.bigquery import DatasetReference, TableReference
 
 from airflow.exceptions import AirflowProviderDeprecationWarning
-from airflow.providers.common.compat.sdk import AirflowException, 
BaseSensorOperator, conf
+from airflow.providers.common.compat.sdk import AirflowException, 
BaseSensorOperator, conf, timezone
 from airflow.providers.google.cloud.hooks.bigquery import BigQueryHook
 from airflow.providers.google.cloud.triggers.bigquery import (
     BigQueryStreamingBufferEmptyTrigger,
     BigQueryTableExistenceTrigger,
     BigQueryTablePartitionExistenceTrigger,
 )
+from airflow.providers.google.version_compat import AIRFLOW_V_3_0_PLUS
 
 if TYPE_CHECKING:
+    from datetime import datetime
+
     from airflow.providers.common.compat.sdk import Context
 
 
@@ -339,12 +342,13 @@ class 
BigQueryStreamingBufferEmptySensor(BaseSensorOperator):
     streaming buffer`` errors.
 
     .. warning::
-        The sensor reads ``table.streaming_buffer`` from BigQuery's table
-        metadata, which is eventually consistent. For a short window right
-        after a streaming insert the buffer metadata is still absent, so the
-        sensor may report the buffer empty before it actually is. Known
-        limitation tracked at
-        https://github.com/apache/airflow/issues/66963
+        BigQuery's ``table.streaming_buffer`` metadata is eventually 
consistent.
+        The sensor mitigates this by requiring multiple consecutive empty
+        observations before reporting the buffer empty (configurable via
+        ``empty_confirmations``), but this may delay success by up to one
+        ``poke_interval`` compared to earlier releases. ``mode="reschedule"`` 
with
+        ``empty_confirmations > 1`` needs a running triggerer once an empty 
reading is seen;
+        ``empty_confirmations=1`` keeps the worker-only behaviour.
 
     :param project_id: Google Cloud project containing the table.
     :param dataset_id: Dataset of the table to monitor.
@@ -352,6 +356,9 @@ class 
BigQueryStreamingBufferEmptySensor(BaseSensorOperator):
     :param gcp_conn_id: Airflow connection ID for GCP.
     :param impersonation_chain: Optional service account to impersonate, or a
         chained list of accounts. See the Google provider docs for details.
+    :param empty_confirmations: Number of consecutive empty readings required
+        before considering the streaming buffer empty. Must be at least 1.
+        Defaults to 2.
     :param deferrable: Run in deferrable mode using
         :class:`BigQueryStreamingBufferEmptyTrigger`.
     """
@@ -374,6 +381,7 @@ class 
BigQueryStreamingBufferEmptySensor(BaseSensorOperator):
         table_id: str,
         gcp_conn_id: str = "google_cloud_default",
         impersonation_chain: str | Sequence[str] | None = None,
+        empty_confirmations: int = 2,
         deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
         **kwargs,
     ) -> None:
@@ -382,12 +390,17 @@ class 
BigQueryStreamingBufferEmptySensor(BaseSensorOperator):
 
         super().__init__(**kwargs)
 
+        if empty_confirmations < 1:
+            raise ValueError("empty_confirmations must be at least 1")
+
         self.project_id = project_id
         self.dataset_id = dataset_id
         self.table_id = table_id
         self.gcp_conn_id = gcp_conn_id
         self.impersonation_chain = impersonation_chain
+        self.empty_confirmations = empty_confirmations
         self.deferrable = deferrable
+        self._consecutive_empty = 0
 
     def execute(self, context: Context) -> None:
         if not self.deferrable:
@@ -395,8 +408,11 @@ class 
BigQueryStreamingBufferEmptySensor(BaseSensorOperator):
             return
         if self.poke(context=context):
             return
+        self._defer_to_trigger(timeout=self.timeout)
+
+    def _defer_to_trigger(self, timeout: float) -> None:
         self.defer(
-            timeout=timedelta(seconds=self.timeout),
+            timeout=timedelta(seconds=timeout),
             trigger=BigQueryStreamingBufferEmptyTrigger(
                 project_id=self.project_id,
                 dataset_id=self.dataset_id,
@@ -404,6 +420,7 @@ class 
BigQueryStreamingBufferEmptySensor(BaseSensorOperator):
                 poll_interval=self.poke_interval,
                 gcp_conn_id=self.gcp_conn_id,
                 impersonation_chain=self.impersonation_chain,
+                empty_confirmations=self.empty_confirmations,
             ),
             method_name="execute_complete",
         )
@@ -432,4 +449,42 @@ class 
BigQueryStreamingBufferEmptySensor(BaseSensorOperator):
             table = 
hook.get_client(project_id=self.project_id).get_table(table_ref)
         except NotFound as err:
             raise ValueError(f"Table {table_uri} not found") from err
-        return table.streaming_buffer is None
+
+        if table.streaming_buffer is not None:
+            self._consecutive_empty = 0
+            return False
+
+        self._consecutive_empty += 1
+        self.log.info(
+            "Streaming buffer reported empty (%s/%s confirmations) for table: 
%s",
+            self._consecutive_empty,
+            self.empty_confirmations,
+            table_uri,
+        )
+        if self._consecutive_empty >= self.empty_confirmations:
+            return True
+        if self.reschedule and not self.deferrable:
+            # A reschedule rebuilds the operator and Airflow 3 clears XComs on 
each run start, so the
+            # confirmation count cannot survive it; the trigger repeats the 
full confirmation count instead.
+            first_reschedule_date = self._get_first_reschedule_date(context)
+            elapsed = (
+                (timezone.utcnow() - first_reschedule_date).total_seconds() if 
first_reschedule_date else 0
+            )
+            if self.timeout - elapsed > 0:
+                self._defer_to_trigger(timeout=self.timeout - elapsed)
+        return False
+
+    def _get_first_reschedule_date(self, context: Context) -> datetime | None:
+        if AIRFLOW_V_3_0_PLUS:
+            return context["ti"].get_first_reschedule_date(context)
+        # Same lookup as the Airflow 2.11 BaseSensorOperator.execute; 
`_orig_start_date` only exists in Airflow 2.
+        from airflow.sensors.base import _orig_start_date  # type: 
ignore[attr-defined]
+
+        ti = context["ti"]
+        return _orig_start_date(
+            dag_id=ti.dag_id,
+            task_id=ti.task_id,
+            run_id=ti.run_id,
+            map_index=ti.map_index,
+            try_number=(ti.max_tries or 0) - (self.retries or 0) + 1,
+        )
diff --git 
a/providers/google/src/airflow/providers/google/cloud/triggers/bigquery.py 
b/providers/google/src/airflow/providers/google/cloud/triggers/bigquery.py
index 359caa3a07a..25b5fc529f2 100644
--- a/providers/google/src/airflow/providers/google/cloud/triggers/bigquery.py
+++ b/providers/google/src/airflow/providers/google/cloud/triggers/bigquery.py
@@ -902,6 +902,8 @@ class BigQueryStreamingBufferEmptyTrigger(BaseTrigger):
     :param poll_interval: Seconds between polls.
     :param impersonation_chain: Optional service account to impersonate, or a
         chained list of accounts.
+    :param empty_confirmations: Number of consecutive empty polls required
+        before reporting the buffer empty. Must be at least 1.
     """
 
     def __init__(
@@ -912,14 +914,18 @@ class BigQueryStreamingBufferEmptyTrigger(BaseTrigger):
         gcp_conn_id: str,
         poll_interval: float = 30.0,
         impersonation_chain: str | Sequence[str] | None = None,
+        empty_confirmations: int = 2,
     ):
         super().__init__()
+        if empty_confirmations < 1:
+            raise ValueError("empty_confirmations must be at least 1")
         self.project_id = project_id
         self.dataset_id = dataset_id
         self.table_id = table_id
         self.gcp_conn_id = gcp_conn_id
         self.poll_interval = poll_interval
         self.impersonation_chain = impersonation_chain
+        self.empty_confirmations = empty_confirmations
 
     def serialize(self) -> tuple[str, dict[str, Any]]:
         return (
@@ -931,6 +937,7 @@ class BigQueryStreamingBufferEmptyTrigger(BaseTrigger):
                 "gcp_conn_id": self.gcp_conn_id,
                 "poll_interval": self.poll_interval,
                 "impersonation_chain": self.impersonation_chain,
+                "empty_confirmations": self.empty_confirmations,
             },
         )
 
@@ -942,6 +949,12 @@ class BigQueryStreamingBufferEmptyTrigger(BaseTrigger):
 
     async def run(self) -> AsyncIterator[TriggerEvent]:
         table_uri = f"{self.project_id}:{self.dataset_id}.{self.table_id}"
+        # The ``streamingBuffer`` table metadata BigQuery returns is eventually
+        # consistent, so a single absent reading can be a false "empty" right
+        # after a streaming insert. Yield success only after
+        # ``empty_confirmations`` consecutive empty polls, each 
``poll_interval``
+        # apart.
+        consecutive_empty = 0
         try:
             hook = self._get_async_hook()
             async with ClientSession() as session:
@@ -955,11 +968,21 @@ class BigQueryStreamingBufferEmptyTrigger(BaseTrigger):
                         table_id=self.table_id,
                     )
                     if is_empty:
-                        message = f"Streaming buffer is empty for table: 
{table_uri}"
-                        self.log.info(message)
-                        yield TriggerEvent({"status": "success", "message": 
message})
-                        return
-                    self.log.info("Streaming buffer not empty, sleeping %ss", 
self.poll_interval)
+                        consecutive_empty += 1
+                        if consecutive_empty >= self.empty_confirmations:
+                            message = f"Streaming buffer is empty for table: 
{table_uri}"
+                            self.log.info(message)
+                            yield TriggerEvent({"status": "success", 
"message": message})
+                            return
+                        self.log.info(
+                            "Streaming buffer reported empty (%s/%s 
confirmations), sleeping %ss",
+                            consecutive_empty,
+                            self.empty_confirmations,
+                            self.poll_interval,
+                        )
+                    else:
+                        consecutive_empty = 0
+                        self.log.info("Streaming buffer not empty, sleeping 
%ss", self.poll_interval)
                     await asyncio.sleep(self.poll_interval)
         except Exception as e:
             self.log.exception("Error while checking streaming buffer for 
table %s", table_uri)
diff --git 
a/providers/google/tests/system/google/cloud/bigquery/example_bigquery_streaming_buffer_sensor.py
 
b/providers/google/tests/system/google/cloud/bigquery/example_bigquery_streaming_buffer_sensor.py
index 68035ac944e..3b07f5c5062 100644
--- 
a/providers/google/tests/system/google/cloud/bigquery/example_bigquery_streaming_buffer_sensor.py
+++ 
b/providers/google/tests/system/google/cloud/bigquery/example_bigquery_streaming_buffer_sensor.py
@@ -26,7 +26,6 @@ test can run for a long time end-to-end and is therefore 
opt-in: set
 from __future__ import annotations
 
 import os
-import time
 from datetime import datetime
 
 import pytest
@@ -65,7 +64,7 @@ TABLE_NAME = 
f"partitioned_table_{DAG_ID}_{ENV_ID}".replace("-", "_")
 # DML on rows still in the streaming buffer is rejected by BigQuery, hence the
 # sensor in the streaming-insert -> sensor -> DML chain below.
 STREAMING_UPDATE_QUERY = f"UPDATE {DATASET_NAME}.{TABLE_NAME} SET value = 200 
WHERE value = 100"
-STREAMING_DELETE_QUERY = f"DELETE FROM {DATASET_NAME}.{TABLE_NAME} WHERE value 
= 200"
+STREAMING_DELETE_QUERY = f"DELETE FROM {DATASET_NAME}.{TABLE_NAME} WHERE value 
IN (100, 200)"
 
 SCHEMA = [
     {"name": "value", "type": "INTEGER", "mode": "REQUIRED"},
@@ -109,21 +108,10 @@ with DAG(
             rows=[{"value": 100, "ds": ds}],
             fail_on_error=True,
         )
-        # BigQuery's streamingBuffer table metadata is eventually consistent: 
for
-        # a few seconds after a streaming insert the row is in the buffer but
-        # table.streaming_buffer is still None. Wait for the metadata to catch 
up
-        # so check_streaming_buffer_empty does not falsely report "empty" 
before
-        # the buffer is reported at all. Remove once the sensor handles this
-        # itself; tracked at https://github.com/apache/airflow/issues/66963
-        client = hook.get_client(project_id=PROJECT_ID)
-        table_uri = f"{PROJECT_ID}.{DATASET_NAME}.{TABLE_NAME}"
-        for _ in range(30):
-            if client.get_table(table_uri).streaming_buffer is not None:
-                return
-            time.sleep(2)
-        raise RuntimeError("BigQuery streaming buffer metadata did not appear 
within 60s")
 
     streaming_insert_task = streaming_insert()
+    # A fresh insert right before the deferrable sensor, so it also sees the 
just-inserted state.
+    streaming_insert_def_task = 
streaming_insert.override(task_id="streaming_insert_def")()
 
     # [START howto_sensor_bigquery_streaming_buffer_empty]
     check_streaming_buffer_empty = BigQueryStreamingBufferEmptySensor(
@@ -181,6 +169,7 @@ with DAG(
         >> streaming_insert_task
         >> check_streaming_buffer_empty
         >> stream_update
+        >> streaming_insert_def_task
         >> check_streaming_buffer_empty_def
         >> stream_delete
         >> delete_dataset
diff --git a/providers/google/tests/unit/google/cloud/sensors/test_bigquery.py 
b/providers/google/tests/unit/google/cloud/sensors/test_bigquery.py
index eb953d6af03..7435b55e215 100644
--- a/providers/google/tests/unit/google/cloud/sensors/test_bigquery.py
+++ b/providers/google/tests/unit/google/cloud/sensors/test_bigquery.py
@@ -16,13 +16,15 @@
 # under the License.
 from __future__ import annotations
 
+from datetime import UTC, datetime, timedelta
 from unittest import mock
 
 import pytest
+import time_machine
 from google.api_core.exceptions import NotFound
 from google.cloud.bigquery import TableReference
 
-from airflow.providers.common.compat.sdk import AirflowException, TaskDeferred
+from airflow.providers.common.compat.sdk import AirflowException, 
AirflowSensorTimeout, TaskDeferred
 from airflow.providers.google.cloud.sensors.bigquery import (
     BigQueryRoutineExistenceSensor,
     BigQueryStreamingBufferEmptySensor,
@@ -35,6 +37,8 @@ from airflow.providers.google.cloud.triggers.bigquery import (
     BigQueryTablePartitionExistenceTrigger,
 )
 
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
+
 TEST_PROJECT_ID = "test_project"
 TEST_DATASET_ID = "test_dataset"
 TEST_TABLE_ID = "test_table"
@@ -350,6 +354,7 @@ class TestBigQueryStreamingBufferEmptySensor:
         sensor = _make_streaming_sensor(
             gcp_conn_id=TEST_GCP_CONN_ID,
             impersonation_chain=TEST_IMPERSONATION_CHAIN,
+            empty_confirmations=1,
         )
         mock_table = mock.MagicMock(streaming_buffer=None)
         mock_hook.return_value.get_client.return_value.get_table.return_value 
= mock_table
@@ -380,6 +385,135 @@ class TestBigQueryStreamingBufferEmptySensor:
 
         assert sensor.poke(mock.MagicMock()) is False
 
+    @pytest.mark.parametrize(
+        ("empty_confirmations", "expected_results"),
+        [
+            pytest.param(2, [False, True], id="default"),
+            pytest.param(3, [False, False, True], id="three_confirmations"),
+        ],
+    )
+    @mock.patch("airflow.providers.google.cloud.sensors.bigquery.BigQueryHook")
+    def test_poke_requires_consecutive_empty_confirmations(
+        self,
+        mock_hook,
+        empty_confirmations,
+        expected_results,
+    ):
+        sensor = 
_make_streaming_sensor(empty_confirmations=empty_confirmations)
+        mock_hook.return_value.get_client.return_value.get_table.return_value 
= mock.MagicMock(
+            streaming_buffer=None
+        )
+
+        # A single absent reading is ambiguous (metadata lag), so the sensor 
only
+        # reports empty after the configured number of consecutive empty 
readings.
+        for expected in expected_results:
+            assert sensor.poke(mock.MagicMock()) is expected
+
+    @mock.patch("airflow.providers.google.cloud.sensors.bigquery.BigQueryHook")
+    def test_poke_resets_confirmations_when_buffer_reappears(self, mock_hook):
+        sensor = _make_streaming_sensor(empty_confirmations=2)
+        get_table = mock_hook.return_value.get_client.return_value.get_table
+        get_table.side_effect = [
+            mock.MagicMock(streaming_buffer=None),
+            mock.MagicMock(streaming_buffer={"estimatedRows": 5}),
+            mock.MagicMock(streaming_buffer=None),
+            mock.MagicMock(streaming_buffer=None),
+        ]
+
+        assert sensor.poke(mock.MagicMock()) is False  # 1st empty
+        assert sensor.poke(mock.MagicMock()) is False  # buffer reappears, 
counter resets
+        assert sensor.poke(mock.MagicMock()) is False  # empty again, count 
restarts at 1
+        assert sensor.poke(mock.MagicMock()) is True  # 2nd consecutive empty
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, 
reason="get_first_reschedule_date is Airflow 3 only")
+    @pytest.mark.parametrize(
+        ("elapsed", "expected_timeout"),
+        [
+            pytest.param(None, 100, id="first_try"),
+            pytest.param(30, 70, id="after_reschedules"),
+        ],
+    )
+    @mock.patch("airflow.providers.google.cloud.sensors.bigquery.BigQueryHook")
+    def 
test_execute_defers_to_trigger_with_remaining_timeout_in_reschedule_mode(
+        self, mock_hook, elapsed, expected_timeout
+    ):
+        now = datetime(2026, 1, 1, tzinfo=UTC)
+        sensor = _make_streaming_sensor(
+            mode="reschedule", poke_interval=10, timeout=100, 
empty_confirmations=3
+        )
+        mock_hook.return_value.get_client.return_value.get_table.return_value 
= mock.MagicMock(
+            streaming_buffer=None
+        )
+        ti = mock.MagicMock(spec=["get_first_reschedule_date"])
+        ti.get_first_reschedule_date.return_value = (
+            None if elapsed is None else now - timedelta(seconds=elapsed)
+        )
+
+        with time_machine.travel(now, tick=False), pytest.raises(TaskDeferred) 
as exc:
+            sensor.execute({"ti": ti})
+
+        trigger = exc.value.trigger
+        assert isinstance(trigger, BigQueryStreamingBufferEmptyTrigger)
+        assert trigger.empty_confirmations == 3
+        assert trigger.poll_interval == 10
+        assert exc.value.timeout == timedelta(seconds=expected_timeout)
+        assert exc.value.method_name == "execute_complete"
+
+    @pytest.mark.skipif(AIRFLOW_V_3_0_PLUS, reason="_orig_start_date only 
exists in Airflow 2")
+    @mock.patch("airflow.sensors.base._orig_start_date", autospec=True)
+    @mock.patch("airflow.providers.google.cloud.sensors.bigquery.BigQueryHook")
+    def 
test_poke_defers_with_remaining_timeout_in_reschedule_mode_on_airflow_2(
+        self, mock_hook, mock_orig_start_date
+    ):
+        now = datetime(2026, 1, 1, tzinfo=UTC)
+        mock_orig_start_date.return_value = now - timedelta(seconds=30)
+        sensor = _make_streaming_sensor(mode="reschedule", poke_interval=10, 
timeout=100, retries=2)
+        mock_hook.return_value.get_client.return_value.get_table.return_value 
= mock.MagicMock(
+            streaming_buffer=None
+        )
+        ti = mock.MagicMock(
+            spec=["dag_id", "task_id", "run_id", "map_index", "max_tries"],
+            dag_id="dag",
+            task_id="task-id",
+            run_id="run",
+            map_index=-1,
+            max_tries=3,
+        )
+
+        with time_machine.travel(now, tick=False), pytest.raises(TaskDeferred) 
as exc:
+            sensor.poke({"ti": ti})
+
+        mock_orig_start_date.assert_called_once_with(
+            dag_id="dag", task_id="task-id", run_id="run", map_index=-1, 
try_number=2
+        )
+        assert exc.value.timeout == timedelta(seconds=70)
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, 
reason="get_first_reschedule_date is Airflow 3 only")
+    @mock.patch(
+        
"airflow.providers.google.cloud.sensors.bigquery.BigQueryStreamingBufferEmptySensor.defer",
+        autospec=True,
+    )
+    @mock.patch("airflow.providers.google.cloud.sensors.bigquery.BigQueryHook")
+    def 
test_execute_times_out_instead_of_deferring_when_reschedule_timeout_elapsed(
+        self, mock_hook, mock_defer
+    ):
+        sensor = _make_streaming_sensor(mode="reschedule", poke_interval=10, 
timeout=100)
+        mock_hook.return_value.get_client.return_value.get_table.return_value 
= mock.MagicMock(
+            streaming_buffer=None
+        )
+        now = datetime(2026, 1, 1, tzinfo=UTC)
+        ti = mock.MagicMock(spec=["get_first_reschedule_date"])
+        ti.get_first_reschedule_date.return_value = now - 
timedelta(seconds=500)
+
+        with time_machine.travel(now, tick=False), 
pytest.raises(AirflowSensorTimeout):
+            sensor.execute({"ti": ti})
+
+        mock_defer.assert_not_called()
+
+    def test_init_rejects_non_positive_empty_confirmations(self):
+        with pytest.raises(ValueError, match="empty_confirmations must be at 
least 1"):
+            _make_streaming_sensor(empty_confirmations=0)
+
     @mock.patch("airflow.providers.google.cloud.sensors.bigquery.BigQueryHook")
     def test_poke_raises_value_error_when_table_not_found(self, mock_hook):
         mock_hook.return_value.get_client.return_value.get_table.side_effect = 
NotFound("missing")
@@ -401,7 +535,7 @@ class TestBigQueryStreamingBufferEmptySensor:
             streaming_buffer=None
         )
 
-        _make_streaming_sensor(deferrable=True).execute(mock.MagicMock())
+        _make_streaming_sensor(deferrable=True, 
empty_confirmations=1).execute(mock.MagicMock())
 
         mock_defer.assert_not_called()
 
@@ -411,6 +545,7 @@ class TestBigQueryStreamingBufferEmptySensor:
             gcp_conn_id=TEST_GCP_CONN_ID,
             impersonation_chain=TEST_IMPERSONATION_CHAIN,
             deferrable=True,
+            empty_confirmations=3,
         )
         mock_hook.return_value.get_client.return_value.get_table.return_value 
= mock.MagicMock(
             streaming_buffer={"estimatedRows": 1}
@@ -428,6 +563,7 @@ class TestBigQueryStreamingBufferEmptySensor:
         assert trigger.project_id == TEST_PROJECT_ID
         assert trigger.dataset_id == TEST_DATASET_ID
         assert trigger.table_id == TEST_TABLE_ID
+        assert trigger.empty_confirmations == 3
 
     def test_execute_complete_returns_message_on_success(self):
         sensor = _make_streaming_sensor(deferrable=True)
diff --git a/providers/google/tests/unit/google/cloud/triggers/test_bigquery.py 
b/providers/google/tests/unit/google/cloud/triggers/test_bigquery.py
index 2c23d3050e0..27e93522ed9 100644
--- a/providers/google/tests/unit/google/cloud/triggers/test_bigquery.py
+++ b/providers/google/tests/unit/google/cloud/triggers/test_bigquery.py
@@ -1178,8 +1178,19 @@ class TestBigQueryStreamingBufferEmptyTrigger:
             "gcp_conn_id": TEST_GCP_CONN_ID,
             "poll_interval": POLLING_PERIOD_SECONDS,
             "impersonation_chain": TEST_IMPERSONATION_CHAIN,
+            "empty_confirmations": 2,
         }
 
+    def test_init_rejects_non_positive_empty_confirmations(self):
+        with pytest.raises(ValueError, match="empty_confirmations must be at 
least 1"):
+            BigQueryStreamingBufferEmptyTrigger(
+                project_id=TEST_GCP_PROJECT_ID,
+                dataset_id=TEST_DATASET_ID,
+                table_id=TEST_TABLE_ID,
+                gcp_conn_id=TEST_GCP_CONN_ID,
+                empty_confirmations=0,
+            )
+
     
@mock.patch("airflow.providers.google.cloud.triggers.bigquery.BigQueryTableAsyncHook")
     def test_async_hook_receives_impersonation_chain(self, mock_hook_cls, 
streaming_buffer_trigger):
         streaming_buffer_trigger._get_async_hook()
@@ -1191,12 +1202,77 @@ class TestBigQueryStreamingBufferEmptyTrigger:
     @pytest.mark.asyncio
     @mock.patch(f"{_TRIGGER_PATH}._is_streaming_buffer_empty")
     @mock.patch(f"{_TRIGGER_PATH}._get_async_hook")
-    async def test_run_yields_success_when_buffer_empty(
-        self, _mock_hook, mock_is_empty, streaming_buffer_trigger
-    ):
+    async def test_run_yields_success_when_buffer_empty(self, _mock_hook, 
mock_is_empty):
+        trigger = BigQueryStreamingBufferEmptyTrigger(
+            project_id=TEST_GCP_PROJECT_ID,
+            dataset_id=TEST_DATASET_ID,
+            table_id=TEST_TABLE_ID,
+            gcp_conn_id=TEST_GCP_CONN_ID,
+            poll_interval=POLLING_PERIOD_SECONDS,
+            impersonation_chain=TEST_IMPERSONATION_CHAIN,
+            empty_confirmations=1,
+        )
         mock_is_empty.return_value = True
+        actual = await trigger.run().asend(None)
+
+        table_uri = f"{TEST_GCP_PROJECT_ID}:{TEST_DATASET_ID}.{TEST_TABLE_ID}"
+        assert actual == TriggerEvent(
+            {"status": "success", "message": f"Streaming buffer is empty for 
table: {table_uri}"}
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("empty_confirmations", "side_effect", "expected_polls"),
+        [
+            pytest.param(2, [True, True], 2, id="default"),
+            pytest.param(3, [True, True, True], 3, id="three_confirmations"),
+        ],
+    )
+    
@mock.patch("airflow.providers.google.cloud.triggers.bigquery.asyncio.sleep", 
new_callable=AsyncMock)
+    @mock.patch(f"{_TRIGGER_PATH}._is_streaming_buffer_empty")
+    @mock.patch(f"{_TRIGGER_PATH}._get_async_hook")
+    async def test_run_waits_for_consecutive_empty_confirmations(
+        self,
+        _mock_hook,
+        mock_is_empty,
+        mock_sleep,
+        empty_confirmations,
+        side_effect,
+        expected_polls,
+    ):
+        # A single empty reading must not yield success; success only after
+        # ``empty_confirmations`` consecutive empty polls.
+        mock_is_empty.side_effect = side_effect
+
+        trigger = BigQueryStreamingBufferEmptyTrigger(
+            project_id=TEST_GCP_PROJECT_ID,
+            dataset_id=TEST_DATASET_ID,
+            table_id=TEST_TABLE_ID,
+            gcp_conn_id=TEST_GCP_CONN_ID,
+            empty_confirmations=empty_confirmations,
+        )
+
+        actual = await trigger.run().asend(None)
+
+        assert mock_is_empty.await_count == expected_polls
+
+        table_uri = f"{TEST_GCP_PROJECT_ID}:{TEST_DATASET_ID}.{TEST_TABLE_ID}"
+        assert actual == TriggerEvent(
+            {"status": "success", "message": f"Streaming buffer is empty for 
table: {table_uri}"}
+        )
+
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.bigquery.asyncio.sleep", 
new_callable=AsyncMock)
+    @mock.patch(f"{_TRIGGER_PATH}._is_streaming_buffer_empty")
+    @mock.patch(f"{_TRIGGER_PATH}._get_async_hook")
+    async def test_run_resets_confirmations_when_buffer_reappears(
+        self, _mock_hook, mock_is_empty, mock_sleep, streaming_buffer_trigger
+    ):
+        # empty, then non-empty (resets), then two consecutive empties -> 
success.
+        mock_is_empty.side_effect = [True, False, True, True]
         actual = await streaming_buffer_trigger.run().asend(None)
 
+        assert mock_is_empty.await_count == 4
         table_uri = f"{TEST_GCP_PROJECT_ID}:{TEST_DATASET_ID}.{TEST_TABLE_ID}"
         assert actual == TriggerEvent(
             {"status": "success", "message": f"Streaming buffer is empty for 
table: {table_uri}"}

Reply via email to