This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 34c72c5e53a Allow excluding datasets from OpenLineage events by
pattern and hook (#71312)
34c72c5e53a is described below
commit 34c72c5e53a4a38bc57be3b702337d08dd80dc11
Author: Ramit Kataria <[email protected]>
AuthorDate: Fri Oct 23 11:58:44 2026 -0700
Allow excluding datasets from OpenLineage events by pattern and hook
(#71312)
A task that writes many objects through a hook reports one dataset per
object,
and the only existing control, hook_lineage, is all-or-nothing: silencing
the
noisy datasets also loses the ones worth tracking from the same hook.
Dataset patterns let a Dag keep the lineage that matters while dropping the
noise, and a hook scope lets that be narrowed to the hook producing it
without
touching each provider's hooks. Applying the patterns to every lineage
source
keeps one pattern behaving the same regardless of where a dataset came from.
closes: #63371
---
.../unit/google/cloud/openlineage/test_mixins.py | 3 +-
providers/openlineage/docs/emission_policy.rst | 147 +++++++++-
.../airflow/providers/openlineage/api/datasets.py | 11 +
.../providers/openlineage/api/emission_policy.py | 26 +-
.../src/airflow/providers/openlineage/api/sql.py | 4 +-
.../providers/openlineage/extractors/manager.py | 69 +++--
.../providers/openlineage/utils/emission_policy.py | 312 +++++++++++++++++++--
.../openlineage/utils/sql_hook_lineage.py | 16 +-
.../tests/unit/openlineage/api/test_datasets.py | 28 ++
.../unit/openlineage/api/test_emission_policy.py | 29 ++
.../tests/unit/openlineage/api/test_sql.py | 25 +-
.../unit/openlineage/extractors/test_manager.py | 167 ++++++++++-
.../unit/openlineage/utils/test_emission_policy.py | 253 +++++++++++++++++
.../openlineage/utils/test_sql_hook_lineage.py | 28 ++
14 files changed, 1044 insertions(+), 74 deletions(-)
diff --git
a/providers/google/tests/unit/google/cloud/openlineage/test_mixins.py
b/providers/google/tests/unit/google/cloud/openlineage/test_mixins.py
index 2a44c648ef9..3cdc868edfc 100644
--- a/providers/google/tests/unit/google/cloud/openlineage/test_mixins.py
+++ b/providers/google/tests/unit/google/cloud/openlineage/test_mixins.py
@@ -43,6 +43,7 @@ from airflow.providers.common.compat.openlineage.facet import
(
from airflow.providers.google.cloud.openlineage.facets import
BigQueryJobRunFacet
from airflow.providers.google.cloud.openlineage.mixins import
_BigQueryInsertJobOperatorOpenLineageMixin
from airflow.providers.openlineage.sqlparser import SQLParser
+from airflow.providers.openlineage.utils.emission_policy import EmissionPolicy
QUERY_JOB_PROPERTIES = {
"configuration": {
@@ -645,7 +646,7 @@ class TestBigQueryOpenLineageMixin:
return [input_table2], [output_table2]
mock_get_inputs_and_outputs.side_effect = get_inputs_and_outputs
- mock_resolve_task_emission_policy.return_value = MagicMock(emit=True)
+ mock_resolve_task_emission_policy.return_value =
EmissionPolicy.defaults()
lineage =
self.operator.get_openlineage_facets_on_complete(make_task_instance())
diff --git a/providers/openlineage/docs/emission_policy.rst
b/providers/openlineage/docs/emission_policy.rst
index 5fb49148601..06488dfcafd 100644
--- a/providers/openlineage/docs/emission_policy.rst
+++ b/providers/openlineage/docs/emission_policy.rst
@@ -100,15 +100,19 @@ and the rule is skipped with a WARNING. The same applies
to unknown keys inside
- ``dag_id`` — applies to task and/or DAG-level events for the named DAG (use
``emit_task_events``
/ ``emit_dag_events`` in ``controls`` to target one type selectively).
- ``task_id`` — only valid alongside ``dag_id``; targets a specific task.
+- ``hook``: fully-qualified class name (``module.ClassName``) of the object
that reported a
+ hook-collected dataset, usually a hook such as
+ ``airflow.providers.amazon.aws.hooks.s3.S3Hook``. It can be added to any of
the other scopes
+ and narrows the rule to the datasets that hook reports. See
:ref:`emission_policy_datasets:openlineage`.
- *(empty ``scope: {}``)* — global default; applies to every task and DAG
event.
-``scope`` must be one of: global (empty), operator-only, dag-only, or
dag+task. ``task_id``
-without ``dag_id``, or ``operator`` combined with ``dag_id`` / ``task_id``, is
rejected with
-a WARNING.
+``scope`` must be one of: global (empty), operator-only, dag-only, or
dag+task, each optionally
+with ``hook``. ``task_id`` without ``dag_id``, or ``operator`` combined with
``dag_id`` /
+``task_id``, is rejected with a WARNING.
**``match_mode``** (top-level): ``"exact"`` (default) or ``"regex"``. When
``"regex"``, every
-value inside ``scope`` (``dag_id``, ``task_id``, ``operator``) is treated as a
``re.fullmatch``
-pattern.
+value inside ``scope`` (``dag_id``, ``task_id``, ``operator``, ``hook``) is
treated as a
+``re.fullmatch`` pattern.
**Control flag keys** (all optional inside ``controls``; the dict must be
non-empty):
@@ -150,6 +154,54 @@ pattern.
``false``, only a curated subset of task attributes is sent. When
``true``, all
serializable task parameters are included, which may significantly
increase event size.
Task events only.
+ * - ``exclude_datasets``
+ - ``[]``
+ - List of regex patterns; datasets whose ``<namespace>/<name>`` matches
any of them are
+ dropped. Task events only. See
:ref:`emission_policy_datasets:openlineage`.
+
+**Valid scope and control combinations.** A rule that uses a control its scope
does not accept
+is skipped with a WARNING that names the offending keys.
+
+.. list-table::
+ :header-rows: 1
+ :widths: 30 10 14 10 18 18
+
+ * - Control
+ - Global (``{}``)
+ - ``operator``
+ - ``dag_id``
+ - ``dag_id`` + ``task_id``
+ - Any of these + ``hook``
+ * - ``emit``, ``emit_task_events``
+ - yes
+ - yes
+ - yes
+ - yes
+ - no
+ * - ``emit_dag_events``
+ - yes
+ - no
+ - yes
+ - no
+ - no
+ * - ``extract_operator_metadata``, ``include_source_code``,
``include_full_task_info``
+ - yes
+ - yes
+ - yes
+ - yes
+ - no
+ * - ``hook_lineage``
+ - yes
+ - yes
+ - yes
+ - yes
+ - yes, keeps or drops the hook's datasets; not with ``exclude_datasets``
+ * - ``exclude_datasets``
+ - yes
+ - yes
+ - yes
+ - yes
+ - yes; not with ``hook_lineage``
``locked`` (top-level, default ``false``) is an admin-only floor lock: when
``true``, the
control fields carried by this rule's ``controls`` dict cannot be overridden
by per-Dag /
@@ -220,6 +272,85 @@ In the example above:
- Source code is disabled globally for all operators (``PythonOperator`` was
already covered above,
but the global rule applies to ``BashOperator`` and any other operator with
source code).
+.. _emission_policy_datasets:openlineage:
+
+Excluding datasets (``exclude_datasets``)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+``exclude_datasets`` drops individual datasets while keeping the rest of a
task's lineage, for
+example the per-chunk objects of a multipart upload that hook lineage reports
one by one.
+
+- Patterns are always matched with ``re.fullmatch``, independent of
``match_mode``, against
+ ``<namespace>/<name>`` of the OpenLineage dataset:
``s3://my-bucket/staging/part_1`` for a
+ dataset with namespace ``s3://my-bucket`` and name ``staging/part_1``.
Airflow assets are
+ matched after translation to OpenLineage, so assets that cannot be
translated are never
+ reported, whatever the patterns.
+- They apply to every source of task lineage: extractor results,
hook-collected assets,
+ manually declared ``inlets`` / ``outlets``, runtime outlet events, the
per-query events emitted
+ from SQL hook lineage, and the ``emit_dataset_lineage`` /
``emit_query_lineage`` APIs.
+- Only datasets are dropped, never events. A SQL hook lineage query event, or
an
+ ``emit_query_lineage`` event, is still emitted when every dataset of the
query is excluded,
+ because it also carries the query text and id. ``emit_dataset_lineage``
skips its event in
+ that case, since the event exists only to carry the datasets.
+- Excluding datasets never changes which source is used. If an extractor
returns datasets that
+ are all excluded, hook lineage is not consulted as a fallback; if hook
lineage collected assets
+ that are all excluded, manually declared ``inlets`` / ``outlets`` are not
used either.
+- A rule whose ``scope`` has a ``hook`` key only applies to datasets reported
by that hook, and
+ only to hook lineage and SQL hook lineage (the other sources have no
reporting hook). Such a
+ rule may only carry ``exclude_datasets`` or ``hook_lineage``; with a
``hook`` scope,
+ ``hook_lineage: false`` drops every dataset the hook reports and
``hook_lineage: true`` keeps
+ them all. A hook-scoped ``hook_lineage`` does not affect whether hook
lineage is collected.
+
+The list is resolved per dataset, and the most specific matching rule that
sets it **replaces**
+broader ones instead of merging with them, so a narrower rule can shorten a
broader exclusion or
+clear it with ``[]``. For a dataset reported by a hook, each tier is preceded
by its
+``hook`` variant:
+
+1. ``dag_id`` + ``task_id`` + ``hook``
+2. ``dag_id`` + ``task_id``
+3. ``dag_id`` + ``hook``
+4. ``dag_id``
+5. ``operator`` + ``hook``
+6. ``operator``
+7. ``hook``
+8. Global (empty ``scope``)
+
+Every replacement is logged at INFO level, naming the winning rule and the
broader rules it
+replaced. ``locked`` keeps its usual meaning: a locked rule blocks authoring
overrides of
+``exclude_datasets`` for the datasets it applies to, but a more specific conf
rule still replaces
+its list.
+
+.. code-block:: ini
+
+ [openlineage]
+ emission_policy = [
+ {"scope": {"hook": ".*\\.S3Hook"}, "match_mode": "regex", "controls":
{"exclude_datasets": ["s3://.*/_temporary/.*"]}},
+ {"scope": {"dag_id": "chunked_dag", "task_id": "upload", "hook":
"airflow.providers.amazon.aws.hooks.s3.S3Hook"}, "controls":
{"exclude_datasets": ["s3://my-bucket/staging/part_.*"]}},
+ {"scope": {"dag_id": "noisy_dag", "hook":
"airflow.providers.google.cloud.hooks.gcs.GCSHook"}, "controls":
{"hook_lineage": false}}
+ ]
+
+In the example above:
+
+- ``_temporary`` objects reported by ``S3Hook`` are dropped in every Dag.
+- ``upload`` in ``chunked_dag`` drops the staging parts its ``S3Hook`` writes.
Its list replaces
+ the first rule's, so ``_temporary`` objects are kept for that task.
+- Every dataset ``GCSHook`` reports in ``noisy_dag`` is dropped; other hooks
in that Dag are
+ unaffected.
+
+.. note::
+
+ **Exclusion happens after the collector cap.** Hook-collected assets are
filtered when
+ OpenLineage reads the hook lineage collector, after
+ :ref:`[lineage]
max_assets_per_collector<config:lineage__max_assets_per_collector>` has
+ limited what the collector kept. Excluding assets reduces what gets
reported, but it does not
+ make room for assets the cap already discarded.
+
+.. note::
+
+ Only the event ``inputs`` / ``outputs`` lists are filtered. Facets can
still refer to an
+ excluded dataset: ``columnLineage`` on the remaining datasets, and the
task's ``inlets``,
+ ``outlets`` and runtime outlet assets in the ``airflow`` run facet.
+
**Audit logging**: whenever a control flag is non-default (suppressed or
enabled), an INFO-level log
is emitted identifying the field, the event context, and the exact rule that
caused the change.
This provides a clear audit trail for operational troubleshooting.
@@ -253,6 +384,9 @@ wins **unless** the matching conf rule is marked with
``"locked": true``.
# Suppress DAG-run events but keep task events
extend_global_openlineage_emission_policy(dag, emit_dag_events=False)
+ # Drop the staging objects a task writes, keep the rest of its lineage
+ extend_global_openlineage_emission_policy(extract,
exclude_datasets=["s3://my-bucket/staging/.*"])
+
Key semantics:
- **Resolution priority**: authoring flags > unlocked conf rules > built-in
defaults.
@@ -269,6 +403,9 @@ Key semantics:
tasks present on the Dag at the moment the call is made. Tasks defined later
(e.g. inside
``with DAG(...) as dag:`` before the operators are declared) will not
inherit them. Call
``extend_global_openlineage_emission_policy(dag, ...)`` *after* defining the
tasks, or set flags per task.
+- **``exclude_datasets`` has no hook dimension**: an authored list applies to
every dataset of
+ the task and replaces the conf lists, including hook-scoped ones, except
where a locked rule
+ applies.
- **No unset API**: pass an explicit boolean to override; passing ``None``
means "not provided"
and leaves any previously-stored value intact.
- **Legacy options cannot be locked**. ``"locked": true`` is an
``emission_policy`` feature.
diff --git
a/providers/openlineage/src/airflow/providers/openlineage/api/datasets.py
b/providers/openlineage/src/airflow/providers/openlineage/api/datasets.py
index d2c65043b89..c3e548e93ce 100644
--- a/providers/openlineage/src/airflow/providers/openlineage/api/datasets.py
+++ b/providers/openlineage/src/airflow/providers/openlineage/api/datasets.py
@@ -146,6 +146,17 @@ def emit_dataset_lineage(
)
return
+ inputs = controls.dataset_filter.exclude(inputs)
+ outputs = controls.dataset_filter.exclude(outputs)
+ if not inputs and not outputs:
+ log.info(
+ "Skipping OpenLineage RUNNING event emission for task `%s` in
dag `%s`: every dataset "
+ "passed to emit_dataset_lineage is excluded by the emission
policy.",
+ task_instance.task_id,
+ task_instance.dag_id,
+ )
+ return
+
run_facets = build_task_event_run_facets(
task_instance=task_instance,
dag_run=dag_run,
diff --git
a/providers/openlineage/src/airflow/providers/openlineage/api/emission_policy.py
b/providers/openlineage/src/airflow/providers/openlineage/api/emission_policy.py
index 898aae41a65..652b89d4e3f 100644
---
a/providers/openlineage/src/airflow/providers/openlineage/api/emission_policy.py
+++
b/providers/openlineage/src/airflow/providers/openlineage/api/emission_policy.py
@@ -55,6 +55,10 @@ Quick-start examples
extend_global_openlineage_emission_policy(dag, emit_dag_events=False)
+**Drop the per-chunk objects a task writes, keeping the rest of its lineage**::
+
+ extend_global_openlineage_emission_policy(upload,
exclude_datasets=["s3://my-bucket/staging/part_.*"])
+
**Works with XComArg** — flags are applied to the underlying operator::
result = extend_global_openlineage_emission_policy(
@@ -91,12 +95,15 @@ from airflow.providers.openlineage.utils.emission_policy
import (
EMIT,
EMIT_DAG_EVENTS,
EMIT_TASK_EVENTS,
+ EXCLUDE_DATASETS,
EXTRACT_OPERATOR_METADATA,
HOOK_LINEAGE,
INCLUDE_FULL_TASK_INFO,
INCLUDE_SOURCE_CODE,
OL_EMISSION_POLICY_PARAM,
+ ControlValue,
_merge_param,
+ find_invalid_pattern,
)
if TYPE_CHECKING:
@@ -121,6 +128,7 @@ def extend_global_openlineage_emission_policy(
include_source_code: bool | None = None,
hook_lineage: bool | None = None,
include_full_task_info: bool | None = None,
+ exclude_datasets: list[str] | None = None,
) -> T:
"""
Extend the global OpenLineage emission policy with per-task / per-DAG
overrides.
@@ -132,8 +140,8 @@ def extend_global_openlineage_emission_policy(
When called on a **DAG**, flags are applied as follows:
- Task-relevant flags (``emit``, ``emit_task_events``,
``extract_operator_metadata``,
- ``include_source_code``, ``hook_lineage``, ``include_full_task_info``)
are
- **propagated to all tasks** in the Dag at the time of the call.
+ ``include_source_code``, ``hook_lineage``, ``include_full_task_info``,
+ ``exclude_datasets``) are **propagated to all tasks** in the Dag at the
time of the call.
- DAG-run-level flags (``emit``, ``emit_dag_events``) are stored on the
Dag itself.
- ``emit_dag_events`` is meaningless on a task and logs a warning if
provided.
@@ -180,6 +188,11 @@ def extend_global_openlineage_emission_policy(
:param include_source_code: Whether to include operator source code in
Python/Bash operator events.
:param hook_lineage: Whether to use ``HookLineageCollector`` as a fallback.
:param include_full_task_info: Whether to include the full serialized
operator state.
+ :param exclude_datasets: Regex patterns matched with ``re.fullmatch``
against
+ ``"<namespace>/<name>"`` of each OpenLineage dataset of the task;
matching datasets are
+ dropped. Replaces, instead of extending, any list from the
``emission_policy`` config,
+ including lists set for specific hooks.
+ :raises ValueError: If ``exclude_datasets`` is not a list of valid regex
patterns.
:return: The same *obj* — allows use as a decorator or in chained calls.
"""
if isinstance(obj, XComArg):
@@ -192,6 +205,7 @@ def extend_global_openlineage_emission_policy(
include_source_code=include_source_code,
hook_lineage=hook_lineage,
include_full_task_info=include_full_task_info,
+ exclude_datasets=exclude_datasets,
)
return obj
@@ -206,7 +220,12 @@ def extend_global_openlineage_emission_policy(
"configuration with a global rule ('scope': {}) instead."
)
- provided: dict[str, bool] = {
+ # A bad pattern stored here would only fail during lineage extraction,
where the error is
+ # swallowed and the task silently loses its lineage.
+ if exclude_datasets is not None and (invalid :=
find_invalid_pattern(EXCLUDE_DATASETS, exclude_datasets)):
+ raise ValueError(f"extend_global_openlineage_emission_policy():
{invalid}")
+
+ provided: dict[str, ControlValue] = {
k: v
for k, v in {
EMIT: emit,
@@ -216,6 +235,7 @@ def extend_global_openlineage_emission_policy(
INCLUDE_SOURCE_CODE: include_source_code,
HOOK_LINEAGE: hook_lineage,
INCLUDE_FULL_TASK_INFO: include_full_task_info,
+ EXCLUDE_DATASETS: exclude_datasets,
}.items()
if v is not None
}
diff --git a/providers/openlineage/src/airflow/providers/openlineage/api/sql.py
b/providers/openlineage/src/airflow/providers/openlineage/api/sql.py
index bf8e17a9e7b..14f0c9f421b 100644
--- a/providers/openlineage/src/airflow/providers/openlineage/api/sql.py
+++ b/providers/openlineage/src/airflow/providers/openlineage/api/sql.py
@@ -183,8 +183,8 @@ def emit_query_lineage(
task_instance=task_instance,
job_name=job_name,
is_successful=is_successful,
- inputs=all_inputs,
- outputs=all_outputs,
+ inputs=controls.dataset_filter.exclude(all_inputs),
+ outputs=controls.dataset_filter.exclude(all_outputs),
run_facets=run_facets,
job_facets=job_facets,
start_event_time=start_time,
diff --git
a/providers/openlineage/src/airflow/providers/openlineage/extractors/manager.py
b/providers/openlineage/src/airflow/providers/openlineage/extractors/manager.py
index c6b936970a1..b3af8de1c05 100644
---
a/providers/openlineage/src/airflow/providers/openlineage/extractors/manager.py
+++
b/providers/openlineage/src/airflow/providers/openlineage/extractors/manager.py
@@ -31,7 +31,7 @@ from airflow.providers.openlineage.extractors.base import (
)
from airflow.providers.openlineage.extractors.bash import BashExtractor
from airflow.providers.openlineage.extractors.python import PythonExtractor
-from airflow.providers.openlineage.utils.emission_policy import EmissionPolicy
+from airflow.providers.openlineage.utils.emission_policy import
EmissionPolicy, get_hook_class_name
from airflow.providers.openlineage.utils.utils import (
get_runtime_outlet_assets,
get_unknown_source_attribute_run_facet,
@@ -166,17 +166,20 @@ class ExtractorManager(LoggingMixin):
# If no inputs and outputs are present - check Hook Lineage if
enabled
if (not task_metadata.inputs) and (not task_metadata.outputs):
if controls.hook_lineage:
- hook_lineage = self.get_hook_lineage(task_instance,
task_instance_state)
+ hook_lineage = self.get_hook_lineage(task_instance,
task_instance_state, controls)
if hook_lineage is not None:
task_metadata = task_metadata.merge(hook_lineage)
else: # Last resort - check manual annotations
- self.extract_inlets_and_outlets(task_metadata,
task, task_instance)
+ self.extract_inlets_and_outlets(task_metadata,
task, task_instance, controls)
else:
self.log.info(
"Skipping OpenLineage hook lineage collection for
task '%s' due to emission_policy.",
task.task_id,
)
- self.extract_inlets_and_outlets(task_metadata, task,
task_instance)
+ self.extract_inlets_and_outlets(task_metadata, task,
task_instance, controls)
+ else:
+ task_metadata.inputs =
controls.dataset_filter.exclude(task_metadata.inputs)
+ task_metadata.outputs =
controls.dataset_filter.exclude(task_metadata.outputs)
return task_metadata
except Exception as e:
@@ -195,7 +198,7 @@ class ExtractorManager(LoggingMixin):
# internally. An uncaught exception here would propagate up to
the listener's
# @print_warning decorator, silently suppressing the
task-level event.
try:
- hook_lineage = self.get_hook_lineage(task_instance,
task_instance_state)
+ hook_lineage = self.get_hook_lineage(task_instance,
task_instance_state, controls)
except Exception as e:
self.log.warning(
"Failed to extract OpenLineage hook lineage %s: %s.
Task event will be emitted without lineage.",
@@ -219,7 +222,7 @@ class ExtractorManager(LoggingMixin):
task_metadata = OperatorLineage(
run_facets=get_unknown_source_attribute_run_facet(task=task),
)
- self.extract_inlets_and_outlets(task_metadata, task, task_instance)
+ self.extract_inlets_and_outlets(task_metadata, task,
task_instance, controls)
return task_metadata
return OperatorLineage()
@@ -253,29 +256,32 @@ class ExtractorManager(LoggingMixin):
task_metadata: OperatorLineage,
task,
task_instance=None,
+ controls: EmissionPolicy | None = None,
) -> None:
+ if controls is None:
+ controls = EmissionPolicy.defaults()
if task.inlets or task.outlets:
self.log.debug("Manually extracting lineage metadata from inlets
and outlets")
- for i in task.inlets:
- if d := self.convert_to_ol_dataset(i):
- task_metadata.inputs.append(d)
- for o in task.outlets:
- if d := self.convert_to_ol_dataset(o):
- task_metadata.outputs.append(d)
+ inputs = [d for i in task.inlets if (d :=
self.convert_to_ol_dataset(i))]
+ outputs = [d for o in task.outlets if (d :=
self.convert_to_ol_dataset(o))]
# Add runtime-emitted outlets (alias resolutions + dynamic asset
events), deduped
# by (namespace, name) against both static outputs and each other.
- seen = {(d.namespace, d.name) for d in task_metadata.outputs}
+ seen = {(d.namespace, d.name) for d in task_metadata.outputs + outputs}
for asset, _alias in get_runtime_outlet_assets(task_instance):
ol = translate_airflow_asset(asset, None)
if ol is not None and (ol.namespace, ol.name) not in seen:
- task_metadata.outputs.append(ol)
+ outputs.append(ol)
seen.add((ol.namespace, ol.name))
+ task_metadata.inputs.extend(controls.dataset_filter.exclude(inputs))
+ task_metadata.outputs.extend(controls.dataset_filter.exclude(outputs))
+
def get_hook_lineage(
self,
task_instance=None,
task_instance_state: TaskInstanceState | None = None,
+ controls: EmissionPolicy | None = None,
) -> OperatorLineage | None:
"""
Extract lineage from the Hook Lineage Collector.
@@ -288,8 +294,13 @@ class ExtractorManager(LoggingMixin):
When ``task_instance`` is provided, each extra is parsed and
separate per-query
OpenLineage events are emitted.
- Returns ``None`` when nothing was collected.
+ Datasets matching the ``exclude_datasets`` patterns that *controls*
resolves for the
+ reporting hook are dropped. Returns ``None`` when nothing was
collected; an empty
+ :class:`OperatorLineage` when everything collected was excluded, so
exclusion never
+ makes the caller fall back to another lineage source.
"""
+ if controls is None:
+ controls = EmissionPolicy.defaults()
try:
from airflow.providers.common.compat.lineage.hook import
get_hook_lineage_collector
from airflow.providers.common.sql.hooks.lineage import
SqlJobHookLineageExtra
@@ -308,16 +319,8 @@ class ExtractorManager(LoggingMixin):
collected = collector.collected_assets
# Asset-based inputs/outputs - keep only assets that can be translated
to OL datasets
- inputs = [
- asset
- for asset_info in collected.inputs
- if (asset := translate_airflow_asset(asset_info.asset,
asset_info.context)) is not None
- ]
- outputs = [
- asset
- for asset_info in collected.outputs
- if (asset := translate_airflow_asset(asset_info.asset,
asset_info.context)) is not None
- ]
+ inputs = self._translate_hook_assets(collected.inputs)
+ outputs = self._translate_hook_assets(collected.outputs)
# SQL-based lineage - keep only SQL extra with query_text or job_id.
sql_extras = [
@@ -338,12 +341,26 @@ class ExtractorManager(LoggingMixin):
task_instance=task_instance,
sql_extras=sql_extras,
is_successful=task_instance_state != TaskInstanceState.FAILED,
+ dataset_filter=controls.dataset_filter,
)
if not inputs and not outputs:
return None
- return OperatorLineage(inputs=inputs, outputs=outputs)
+ dataset_filter = controls.dataset_filter
+ return OperatorLineage(
+ inputs=[d for hook, d in inputs if not
dataset_filter.is_excluded(d, hook)],
+ outputs=[d for hook, d in outputs if not
dataset_filter.is_excluded(d, hook)],
+ )
+
+ @staticmethod
+ def _translate_hook_assets(asset_infos) -> list[tuple[str | None,
Dataset]]:
+ """Translate collected assets to OL datasets, paired with the class
name of their reporter."""
+ return [
+ (get_hook_class_name(asset_info.context), dataset)
+ for asset_info in asset_infos
+ if (dataset := translate_airflow_asset(asset_info.asset,
asset_info.context)) is not None
+ ]
@staticmethod
def convert_to_ol_dataset_from_object_storage_uri(uri: str) -> Dataset |
None:
diff --git
a/providers/openlineage/src/airflow/providers/openlineage/utils/emission_policy.py
b/providers/openlineage/src/airflow/providers/openlineage/utils/emission_policy.py
index de386fce438..89a93b94035 100644
---
a/providers/openlineage/src/airflow/providers/openlineage/utils/emission_policy.py
+++
b/providers/openlineage/src/airflow/providers/openlineage/utils/emission_policy.py
@@ -39,16 +39,20 @@ Scope keys (all optional inside ``scope``):
to the named DAG. Use ``emit_task_events`` / ``emit_dag_events`` to target
one
event type selectively.
- ``task_id`` — only valid alongside ``dag_id``; targets a specific task.
+- ``hook``: fully-qualified class name of the object that reported a
hook-collected
+ dataset (usually a hook). Can be added to any of the scopes above, and
narrows the
+ rule to datasets reported by that hook. Only ``exclude_datasets`` and
``hook_lineage``
+ are valid controls for a hook-scoped rule.
- *(empty ``scope: {}``)* — global default override; applies to every task and
DAG event.
``operator`` cannot be combined with ``dag_id`` or ``task_id`` (scope is one
of:
-global, operator-only, dag-only, dag+task). ``task_id`` without ``dag_id`` and
-``operator`` combined with ``emit_dag_events`` (or ``task_id`` combined with
-``emit_dag_events``) are also rejected with a WARNING.
+global, operator-only, dag-only, dag+task, each optionally with ``hook``).
``task_id``
+without ``dag_id`` and ``operator`` combined with ``emit_dag_events`` (or
``task_id``
+combined with ``emit_dag_events``) are also rejected with a WARNING.
``match_mode`` lives at the top level: ``"exact"`` (default) or ``"regex"``.
When set to ``"regex"``, every value inside ``scope`` (``dag_id``, ``task_id``,
-``operator``) is treated as a ``re.fullmatch`` pattern.
+``operator``, ``hook``) is treated as a ``re.fullmatch`` pattern.
Control flag keys (all optional inside ``controls``; the dict must be
non-empty):
@@ -69,10 +73,20 @@ Control flag keys (all optional inside ``controls``; the
dict must be non-empty)
- ``hook_lineage`` — whether to use ``HookLineageCollector`` as a fallback
when the
extractor finds no inputs/outputs. **Has no effect when
``extract_operator_metadata``
is ``false``** — the entire extraction pipeline (including hook lineage) is
skipped.
- Only meaningful for task events. Default: ``true``.
+ Only meaningful for task events. Default: ``true``. In a hook-scoped rule
it instead
+ keeps (``true``) or drops (``false``) every dataset reported by the matching
hook, and it
+ cannot be combined with ``exclude_datasets`` in the same rule.
- ``include_full_task_info`` — whether to include the full serialized operator
state
in the ``AirflowRunFacet``. When ``false`` (default), only a curated subset
of
task attributes is sent. Only meaningful for task events. Default:
``false``.
+- ``exclude_datasets``: list of ``re.fullmatch`` patterns (independent of
``match_mode``)
+ matched against ``"<namespace>/<name>"`` of each OpenLineage dataset;
matching datasets
+ are dropped from task events and from the SQL hook lineage events of the
task. Applies
+ to extractor, hook-collected and manually declared lineage. Default: ``[]``.
+
+Unlike the boolean controls, ``exclude_datasets`` is resolved per dataset, and
the most
+specific matching rule's list **replaces** broader lists instead of merging
with them, so a
+narrower rule can shorten or clear (``[]``) a broader exclusion.
``locked: true`` is an admin floor lock that prevents per-Dag / per-task
authoring
overrides from changing the field(s) carried by this rule's ``controls`` dict.
@@ -102,6 +116,11 @@ rule wins):
For ``emit`` at the task level: ``emit_task_events`` beats ``emit`` within the
same
rule.
+For ``exclude_datasets`` on a dataset reported by a hook, each tier above is
preceded by
+its hook-scoped variant: ``dag_id`` + ``task_id`` + ``hook``, ``dag_id`` +
``task_id``,
+``dag_id`` + ``hook``, ``dag_id``, ``operator`` + ``hook``, ``operator``,
``hook``,
+global. Datasets from other sources only see the tiers without ``hook``.
+
Priority for **DAG run events**:
1. ``dag_id`` rule (using ``emit_dag_events`` or ``emit``)
@@ -138,18 +157,27 @@ import logging
import re
import warnings
from collections.abc import Callable, Sequence
-from dataclasses import dataclass, replace
-from typing import TYPE_CHECKING
+from dataclasses import dataclass, field as dataclass_field, replace
+from typing import TYPE_CHECKING, TypeVar
from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.providers.common.compat.sdk import Param
from airflow.providers.openlineage import conf as _ol_conf
if TYPE_CHECKING:
+ from openlineage.client.event_v2 import Dataset
+
from airflow.providers.openlineage.utils.utils import AnyOperator
+ DatasetT = TypeVar("DatasetT", bound=Dataset)
+
log = logging.getLogger(__name__)
+V = TypeVar("V")
+
+# A validated ``controls`` value: bool for the flags, list of patterns for
``exclude_datasets``.
+ControlValue = bool | list[str]
+
# Control flag names — the keys that may appear inside a rule's ``controls``
dict.
EMIT = "emit"
EMIT_TASK_EVENTS = "emit_task_events"
@@ -158,11 +186,13 @@ EXTRACT_OPERATOR_METADATA = "extract_operator_metadata"
INCLUDE_SOURCE_CODE = "include_source_code"
HOOK_LINEAGE = "hook_lineage"
INCLUDE_FULL_TASK_INFO = "include_full_task_info"
+EXCLUDE_DATASETS = "exclude_datasets"
# Scope keys — the keys that may appear inside a rule's ``scope`` dict.
SCOPE_DAG_ID = "dag_id"
SCOPE_TASK_ID = "task_id"
SCOPE_OPERATOR = "operator"
+SCOPE_HOOK = "hook"
# Top-level rule keys.
RULE_SCOPE = "scope"
@@ -178,7 +208,7 @@ OL_EMISSION_POLICY_PARAM = "_openlineage_emission_policy"
# Schema enforcement: only these keys may appear at each level. Unknown keys
cause
# the rule to be skipped with a WARNING (catches typos like {"scope":
{"dgg_id": "x"}}).
_ALLOWED_TOP_LEVEL_KEYS: frozenset[str] = frozenset({RULE_SCOPE,
RULE_MATCH_MODE, RULE_CONTROLS, RULE_LOCKED})
-_ALLOWED_SCOPE_KEYS: frozenset[str] = frozenset({SCOPE_DAG_ID, SCOPE_TASK_ID,
SCOPE_OPERATOR})
+_ALLOWED_SCOPE_KEYS: frozenset[str] = frozenset({SCOPE_DAG_ID, SCOPE_TASK_ID,
SCOPE_OPERATOR, SCOPE_HOOK})
_ALLOWED_CONTROL_KEYS: frozenset[str] = frozenset(
{
EMIT,
@@ -188,8 +218,12 @@ _ALLOWED_CONTROL_KEYS: frozenset[str] = frozenset(
INCLUDE_SOURCE_CODE,
HOOK_LINEAGE,
INCLUDE_FULL_TASK_INFO,
+ EXCLUDE_DATASETS,
}
)
+# The hook is only known per dataset, so a hook-scoped rule may only carry
controls that
+# act on individual datasets.
+_HOOK_SCOPE_CONTROL_KEYS: frozenset[str] = frozenset({EXCLUDE_DATASETS,
HOOK_LINEAGE})
# Fields scanned for ``locked: true`` on task-event resolution.
# ``emit`` is intentionally absent: emit-locking is driven by either ``emit``
or
@@ -213,6 +247,7 @@ _TASK_FLAG_KEYS: frozenset[str] = frozenset(
INCLUDE_SOURCE_CODE,
HOOK_LINEAGE,
INCLUDE_FULL_TASK_INFO,
+ EXCLUDE_DATASETS,
}
)
_DAG_FLAG_KEYS: frozenset[str] = frozenset({EMIT, EMIT_DAG_EVENTS})
@@ -227,6 +262,8 @@ class EmissionPolicy:
include_source_code: bool
hook_lineage: bool
include_full_task_info: bool
+ # Rules hold dicts, so hashing the filter would fail; equal policies still
hash equally without it.
+ dataset_filter: DatasetFilter = dataclass_field(default_factory=lambda:
DatasetFilter(), hash=False)
@classmethod
def defaults(cls) -> EmissionPolicy:
@@ -251,7 +288,7 @@ class Rule:
"""
scope: dict[str, str]
- controls: dict[str, bool]
+ controls: dict[str, ControlValue]
match_mode: str = "exact"
locked: bool = False
@@ -267,6 +304,10 @@ class Rule:
def has_op(self) -> bool:
return SCOPE_OPERATOR in self.scope
+ @property
+ def has_hook(self) -> bool:
+ return SCOPE_HOOK in self.scope
+
def _matches(pattern: str, value: str, match_mode: str) -> bool:
"""Return ``True`` if *value* matches *pattern* according to
*match_mode*."""
@@ -275,7 +316,133 @@ def _matches(pattern: str, value: str, match_mode: str)
-> bool:
return pattern == value
-def _read_param(obj: object, param_name: str) -> dict[str, bool]:
+def get_hook_class_name(hook: object | None) -> str | None:
+ """Return the fully-qualified class name that the ``hook`` scope key
matches, if any."""
+ if hook is None:
+ return None
+ hook_type = type(hook)
+ return f"{hook_type.__module__}.{hook_type.__name__}"
+
+
+def _get_dataset_identity(dataset: Dataset) -> str:
+ """Return the ``"<namespace>/<name>"`` string that ``exclude_datasets``
patterns match."""
+ return f"{dataset.namespace}/{dataset.name}"
+
+
+def _get_rule_exclude_patterns(rule: Rule) -> tuple[str, ...] | None:
+ """Return the ``exclude_datasets`` patterns *rule* sets, or ``None`` if it
sets none."""
+ patterns = rule.controls.get(EXCLUDE_DATASETS)
+ if isinstance(patterns, list):
+ return tuple(patterns)
+ if rule.has_hook and HOOK_LINEAGE in rule.controls:
+ return () if rule.controls[HOOK_LINEAGE] else (".*",)
+ return None
+
+
+@dataclass(frozen=True)
+class DatasetFilter:
+ """
+ ``exclude_datasets`` resolution for the datasets of one task.
+
+ Hook-scoped rules can only be matched once the reporting hook is known, so
the winning
+ patterns are resolved lazily per hook (``None`` for datasets not reported
by a hook)
+ and cached. *tiers* holds only rules that set patterns, most specific
first:
+ dag+task+hook, dag+task, dag+hook, dag, operator+hook, operator, hook,
global.
+ """
+
+ tiers: tuple[tuple[Rule, ...], ...] = ()
+ authoring_patterns: tuple[str, ...] | None = None
+ context: str = dataclass_field(default="", compare=False)
+ _cache: dict[str | None, tuple[tuple[str, ...], tuple[re.Pattern[str],
...]]] = dataclass_field(
+ default_factory=dict, init=False, compare=False, repr=False
+ )
+
+ def _get_resolved(self, hook: str | None) -> tuple[tuple[str, ...],
tuple[re.Pattern[str], ...]]:
+ if hook not in self._cache:
+ patterns = self._resolve_patterns(hook)
+ self._cache[hook] = (patterns, tuple(re.compile(p) for p in
patterns))
+ return self._cache[hook]
+
+ def get_patterns(self, hook: str | None = None) -> tuple[str, ...]:
+ """Return the exclusion patterns that apply to datasets reported by
*hook*."""
+ return self._get_resolved(hook)[0]
+
+ def is_excluded(self, dataset: Dataset, hook: str | None = None) -> bool:
+ """Return ``True`` if *dataset*, reported by *hook* (if any), matches
an exclusion pattern."""
+ compiled = self._get_resolved(hook)[1]
+ if not compiled:
+ return False
+ identity = _get_dataset_identity(dataset)
+ if any(p.fullmatch(identity) for p in compiled):
+ log.debug("OpenLineage emission policy: excluding dataset %r for
%s.", identity, self.context)
+ return True
+ return False
+
+ def exclude(self, datasets: Sequence[DatasetT], hook: str | None = None)
-> list[DatasetT]:
+ """Return *datasets* without the ones excluded for *hook*."""
+ return [d for d in datasets if not self.is_excluded(d, hook)]
+
+ def _resolve_patterns(self, hook: str | None) -> tuple[str, ...]:
+ if not self.tiers and self.authoring_patterns is None:
+ return ()
+ tiers = [
+ [
+ rule
+ for rule in tier
+ if not rule.has_hook
+ or (hook is not None and _matches(rule.scope[SCOPE_HOOK],
hook, rule.match_mode))
+ ]
+ for tier in self.tiers
+ ]
+ context = self.context if hook is None else f"datasets reported by
'{hook}' in {self.context}"
+ no_patterns: tuple[str, ...] = ()
+ patterns, winning_rule = _walk_tiers(tiers,
_get_rule_exclude_patterns, EXCLUDE_DATASETS, no_patterns)
+ if winning_rule is not None:
+ winning_tier = next(i for i, tier in enumerate(tiers) if
winning_rule in tier)
+ replaced = [rule for tier in tiers[winning_tier + 1 :] for rule in
tier]
+ _audit_log_conf_patterns(patterns, context, winning_rule, replaced)
+
+ if self.authoring_patterns is None or self.authoring_patterns ==
patterns:
+ return patterns
+ if any(rule.locked for tier in tiers for rule in tier):
+ log.warning(
+ "OpenLineage emission_policy:
extend_global_openlineage_emission_policy call for '%s' on %s"
+ " has no effect; locked by conf rule at value %r",
+ EXCLUDE_DATASETS,
+ context,
+ list(patterns),
+ )
+ return patterns
+ log.info(
+ "OpenLineage emission policy: '%s' set to %r for %s "
+ "by manual `extend_global_openlineage_emission_policy` call.",
+ EXCLUDE_DATASETS,
+ list(self.authoring_patterns),
+ context,
+ )
+ return self.authoring_patterns
+
+
+def find_invalid_pattern(field_name: str, value: object) -> str | None:
+ """
+ Return an error message if *value* is not a list of valid regex patterns,
else ``None``.
+
+ Shared by the conf parser (which skips the offending rule) and the
authoring API (which
+ raises), so every pattern that reaches dataset matching has compiled at
least once.
+ """
+ if not isinstance(value, list):
+ return f"'{field_name}' must be a list of strings, got {value!r}"
+ for pattern in value:
+ if not isinstance(pattern, str):
+ return f"'{field_name}' must contain only strings, got {pattern!r}"
+ try:
+ re.compile(pattern)
+ except re.error as exc:
+ return f"'{field_name}' pattern {pattern!r} is not a valid regex
({exc})"
+ return None
+
+
+def _read_param(obj: object, param_name: str) -> dict[str, ControlValue]:
"""Read the flags dict stored at *param_name* on *obj*, or ``{}`` if
absent."""
val = getattr(obj, "params", {}).get(param_name)
if val is None:
@@ -285,7 +452,7 @@ def _read_param(obj: object, param_name: str) -> dict[str,
bool]:
return val if isinstance(val, dict) else {}
-def _merge_param(obj: object, param_name: str, new_flags: dict[str, bool]) ->
None:
+def _merge_param(obj: object, param_name: str, new_flags: dict[str,
ControlValue]) -> None:
"""
Merge *new_flags* into the ``param_name`` param on *obj*, creating it if
absent.
@@ -321,6 +488,29 @@ def _audit_log_conf_field(field: str, value: bool,
context: str, source: Rule) -
)
+def _audit_log_conf_patterns(
+ patterns: tuple[str, ...], context: str, source: Rule, replaced:
Sequence[Rule]
+) -> None:
+ """Log the winning ``exclude_datasets`` list, and the broader rules it
replaced, if any."""
+ if replaced:
+ log.info(
+ "OpenLineage emission policy: '%s' set to %r for %s by %r,
replacing broader rule(s) %r",
+ EXCLUDE_DATASETS,
+ list(patterns),
+ context,
+ source,
+ list(replaced),
+ )
+ else:
+ log.info(
+ "OpenLineage emission policy: '%s' set to %r for %s by %r",
+ EXCLUDE_DATASETS,
+ list(patterns),
+ context,
+ source,
+ )
+
+
def _audit_log_authoring_updates(
field_changes: dict[str, bool],
context: str,
@@ -433,7 +623,12 @@ def _parse_rule(rule: object) -> Rule | None:
)
return None
for k, v in controls.items():
- if not isinstance(v, bool):
+ if k == EXCLUDE_DATASETS:
+ invalid = find_invalid_pattern(k, v)
+ if invalid:
+ log.warning("OpenLineage emission_policy rule controls.%s;
ignoring: %r", invalid, rule)
+ return None
+ elif not isinstance(v, bool):
log.warning(
"OpenLineage emission_policy rule 'controls.%s' must be bool,
got %r; ignoring: %r",
k,
@@ -456,6 +651,24 @@ def _parse_rule(rule: object) -> Rule | None:
rule,
)
return None
+ if SCOPE_HOOK in scope:
+ unsupported = set(controls) - _HOOK_SCOPE_CONTROL_KEYS
+ if unsupported:
+ log.warning(
+ "OpenLineage emission_policy rule has scope 'hook' with
controls %s, but hook-scoped rules "
+ "only support %s; ignoring: %r",
+ sorted(unsupported),
+ sorted(_HOOK_SCOPE_CONTROL_KEYS),
+ rule,
+ )
+ return None
+ if EXCLUDE_DATASETS in controls and HOOK_LINEAGE in controls:
+ log.warning(
+ "OpenLineage emission_policy rule has scope 'hook' with both
'exclude_datasets' and "
+ "'hook_lineage', which both set the datasets dropped for that
hook; ignoring: %r",
+ rule,
+ )
+ return None
match_mode = rule.get(RULE_MATCH_MODE, "exact")
if match_mode not in ("exact", "regex"):
@@ -466,7 +679,7 @@ def _parse_rule(rule: object) -> Rule | None:
)
return None
if match_mode == "regex":
- for key in (SCOPE_DAG_ID, SCOPE_TASK_ID, SCOPE_OPERATOR):
+ for key in (SCOPE_DAG_ID, SCOPE_TASK_ID, SCOPE_OPERATOR, SCOPE_HOOK):
if key in scope:
try:
re.compile(scope[key])
@@ -516,10 +729,16 @@ def _classify_task_rules(
fqcn: str,
dag_id: str,
task_id: str,
+ *,
+ hook_scoped: bool = False,
) -> tuple[list[Rule], list[Rule], list[Rule], list[Rule]]:
"""
Classify pre-validated rules into the four priority tiers for task
resolution.
+ Only rules whose scope has a ``hook`` key (``hook_scoped=True``) or has
none
+ (``hook_scoped=False``) are considered; the ``hook`` key itself is left for
+ :class:`DatasetFilter` to match per dataset.
+
Returns ``(task_rules, dag_rules, operator_rules, global_rules)``.
"""
task_rules: list[Rule] = []
@@ -528,6 +747,8 @@ def _classify_task_rules(
global_rules: list[Rule] = []
for rule in rules:
+ if rule.has_hook != hook_scoped:
+ continue
scope = rule.scope
match_mode = rule.match_mode
@@ -575,24 +796,24 @@ def _classify_dag_rules(
def _walk_tiers(
- tiers: list[list[Rule]],
- extract_value: Callable[[dict[str, bool]], bool | None],
+ tiers: Sequence[Sequence[Rule]],
+ extract_value: Callable[[Rule], V | None],
field_label: str,
- default: bool,
-) -> tuple[bool, Rule | None]:
+ default: V,
+) -> tuple[V, Rule | None]:
"""
Walk priority tiers from most-specific to least-specific, last-wins within
each tier.
- *extract_value* maps a rule's ``controls`` dict to ``bool | None``;
``None`` means
+ *extract_value* maps a rule to its value for the field; ``None`` means
"this rule does not cover this field". A contradiction WARNING is emitted
when two
rules in the same tier produce different non-``None`` values. Returns
``(default, None)`` when no rule in any tier matches.
"""
for tier_rules in tiers:
- value: bool | None = None
+ value: V | None = None
winning_rule: Rule | None = None
for rule in tier_rules:
- new_v = extract_value(rule.controls)
+ new_v = extract_value(rule)
if new_v is not None:
if winning_rule is not None and value != new_v:
log.warning(
@@ -616,7 +837,12 @@ def _resolve_field_with_source(
tiers: list[list[Rule]], field: str, default: bool
) -> tuple[bool, Rule | None]:
"""Walk tiers for a single boolean *field* in ``controls``."""
- return _walk_tiers(tiers, lambda c: c.get(field), field, default)
+
+ def _extract(rule: Rule) -> bool | None:
+ value = rule.controls.get(field)
+ return value if isinstance(value, bool) else None
+
+ return _walk_tiers(tiers, _extract, field, default)
def _resolve_emit_with_source(
@@ -633,10 +859,9 @@ def _resolve_emit_with_source(
"""
specific_key = EMIT_TASK_EVENTS if scope == "task" else EMIT_DAG_EVENTS
- def _extract(c: dict[str, bool]) -> bool | None:
- if specific_key in c:
- return c[specific_key]
- return c.get(EMIT)
+ def _extract(rule: Rule) -> bool | None:
+ value = rule.controls.get(specific_key, rule.controls.get(EMIT))
+ return value if isinstance(value, bool) else None
return _walk_tiers(tiers, _extract, f"emit ({scope})", default)
@@ -860,7 +1085,8 @@ def _apply_authoring_overrides(
return config
_audit_log_authoring_updates(changed, context)
- return replace(config, **changed)
+ # ``changed`` only holds the boolean fields, never ``dataset_filter``.
+ return replace(config, **changed) # type: ignore[arg-type]
def _extend_policy_with_task_authoring(
@@ -882,14 +1108,26 @@ def _extend_policy_with_task_authoring(
flags = _read_param(operator, OL_EMISSION_POLICY_PARAM)
if not flags:
return config
- return _apply_authoring_overrides(
+ config = _apply_authoring_overrides(
config,
locked_fields,
- flags,
+ {k: v for k, v in flags.items() if isinstance(v, bool)},
EMIT_TASK_EVENTS,
context,
extra_fields=(EXTRACT_OPERATOR_METADATA, INCLUDE_SOURCE_CODE,
HOOK_LINEAGE, INCLUDE_FULL_TASK_INFO),
)
+ # Locks on exclude_datasets depend on the hook reporting each dataset, so
the filter
+ # checks them when it resolves patterns.
+ patterns = flags.get(EXCLUDE_DATASETS)
+ if patterns is None:
+ return config
+ # Params written without the authoring API skip its validation, and a bad
pattern would
+ # otherwise raise during extraction and drop the task event.
+ invalid = find_invalid_pattern(EXCLUDE_DATASETS, patterns)
+ if invalid or not isinstance(patterns, list):
+ log.warning("OpenLineage emission_policy: ignoring authored %s for
%s", invalid, context)
+ return config
+ return replace(config, dataset_filter=replace(config.dataset_filter,
authoring_patterns=tuple(patterns)))
def _extend_policy_with_dag_authoring(
@@ -907,7 +1145,13 @@ def _extend_policy_with_dag_authoring(
flags = _read_param(dag, OL_EMISSION_POLICY_PARAM)
if not flags:
return config
- return _apply_authoring_overrides(config, locked_fields, flags,
EMIT_DAG_EVENTS, context)
+ return _apply_authoring_overrides(
+ config,
+ locked_fields,
+ {k: v for k, v in flags.items() if isinstance(v, bool)},
+ EMIT_DAG_EVENTS,
+ context,
+ )
def _resolve_task_policy_from_conf_only(
@@ -954,6 +1198,15 @@ def _resolve_task_policy_from_conf_only(
locked_fields = _compute_locked_task_fields(task_rules, dag_rules,
operator_rules, global_rules)
+ hook_tiers = _classify_task_rules(rules, fqcn, dag_id, task_id,
hook_scoped=True)
+ dataset_filter_tiers = tuple(
+ tuple(rule for rule in tier if _get_rule_exclude_patterns(rule) is not
None)
+ for pair in zip(hook_tiers, tiers)
+ for tier in pair
+ )
+ if not any(dataset_filter_tiers):
+ dataset_filter_tiers = ()
+
return (
EmissionPolicy(
emit=emit,
@@ -961,6 +1214,7 @@ def _resolve_task_policy_from_conf_only(
include_source_code=include_source_code,
hook_lineage=hook_lineage,
include_full_task_info=include_full_task_info,
+ dataset_filter=DatasetFilter(tiers=dataset_filter_tiers,
context=context),
),
locked_fields,
)
diff --git
a/providers/openlineage/src/airflow/providers/openlineage/utils/sql_hook_lineage.py
b/providers/openlineage/src/airflow/providers/openlineage/utils/sql_hook_lineage.py
index f673971ac1a..1726943750a 100644
---
a/providers/openlineage/src/airflow/providers/openlineage/utils/sql_hook_lineage.py
+++
b/providers/openlineage/src/airflow/providers/openlineage/utils/sql_hook_lineage.py
@@ -46,6 +46,7 @@ from airflow.providers.openlineage.sqlparser import (
from_table_meta,
get_openlineage_facets_with_sql,
)
+from airflow.providers.openlineage.utils.emission_policy import DatasetFilter,
get_hook_class_name
from airflow.providers.openlineage.utils.utils import _get_parent_run_facet
if TYPE_CHECKING:
@@ -95,7 +96,12 @@ def _parse_query_into_datasets(
return inputs, outputs
-def emit_lineage_from_sql_extras(task_instance, sql_extras: list,
is_successful: bool = True) -> None:
+def emit_lineage_from_sql_extras(
+ task_instance,
+ sql_extras: list,
+ is_successful: bool = True,
+ dataset_filter: DatasetFilter | None = None,
+) -> None:
"""
Process ``sql_job`` extras and emit per-query OpenLineage events.
@@ -103,8 +109,11 @@ def emit_lineage_from_sql_extras(task_instance,
sql_extras: list, is_successful:
* Parse SQL via :func:`get_openlineage_facets_with_sql` to obtain inputs,
outputs and facets (schema enrichment, column lineage, etc.).
+ * Drop the inputs/outputs that *dataset_filter* excludes for the reporting
hook.
* Emit a separate START + COMPLETE/FAIL event pair (child job of the task).
"""
+ if dataset_filter is None:
+ dataset_filter = DatasetFilter()
if not sql_extras:
return None
@@ -191,13 +200,14 @@ def emit_lineage_from_sql_extras(task_instance,
sql_extras: list, is_successful:
),
)
+ hook_class_name = get_hook_class_name(hook)
events.extend(
_create_ol_event_pair(
task_instance=task_instance,
job_name=f"{task_instance.dag_id}.{task_instance.task_id}.hll_query.{query_count}",
is_successful=is_successful,
- inputs=query_lineage.inputs,
- outputs=query_lineage.outputs,
+ inputs=dataset_filter.exclude(query_lineage.inputs,
hook_class_name),
+ outputs=dataset_filter.exclude(query_lineage.outputs,
hook_class_name),
run_facets=query_lineage.run_facets,
job_facets=query_lineage.job_facets,
)
diff --git a/providers/openlineage/tests/unit/openlineage/api/test_datasets.py
b/providers/openlineage/tests/unit/openlineage/api/test_datasets.py
index b02cc7fafc4..f8d06b0ee05 100644
--- a/providers/openlineage/tests/unit/openlineage/api/test_datasets.py
+++ b/providers/openlineage/tests/unit/openlineage/api/test_datasets.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import dataclasses
from datetime import UTC, datetime
from unittest import mock
@@ -24,6 +25,7 @@ from openlineage.client.event_v2 import Dataset, RunEvent,
RunState
from airflow.providers.openlineage.api.datasets import emit_dataset_lineage
from airflow.providers.openlineage.plugins.adapter import OpenLineageAdapter
+from airflow.providers.openlineage.utils.emission_policy import DatasetFilter,
EmissionPolicy
_MODULE = "airflow.providers.openlineage.api.datasets"
_CORE = "airflow.providers.openlineage.api.core"
@@ -276,3 +278,29 @@ def
test_passes_include_full_task_info_to_run_facets(patched_emit):
mock_build_run_facets.assert_called_once()
assert mock_build_run_facets.call_args.kwargs["include_full_task_info"] is
True
+
+
+def _policy_excluding(patterns: tuple[str, ...]) -> EmissionPolicy:
+ return dataclasses.replace(
+ EmissionPolicy.defaults(),
dataset_filter=DatasetFilter(authoring_patterns=patterns)
+ )
+
+
+def test_excludes_datasets_from_event(patched_emit):
+ ti = _make_task_instance()
+ with mock.patch(f"{_MODULE}.resolve_task_emission_policy",
return_value=_policy_excluding(("ns/drop",))):
+ emit_dataset_lineage(
+ inputs=[Dataset(namespace="ns", name="drop"),
Dataset(namespace="ns", name="keep")],
+ task_instance=ti,
+ )
+
+ (event,) = patched_emit.call_args.args
+ assert event.inputs == [Dataset(namespace="ns", name="keep")]
+
+
+def test_noop_when_every_dataset_excluded(patched_emit):
+ ti = _make_task_instance()
+ with mock.patch(f"{_MODULE}.resolve_task_emission_policy",
return_value=_policy_excluding(("ns/.*",))):
+ emit_dataset_lineage(inputs=[Dataset(namespace="ns", name="a")],
task_instance=ti)
+
+ patched_emit.assert_not_called()
diff --git
a/providers/openlineage/tests/unit/openlineage/api/test_emission_policy.py
b/providers/openlineage/tests/unit/openlineage/api/test_emission_policy.py
index 57ff1c124a9..4eb77714b28 100644
--- a/providers/openlineage/tests/unit/openlineage/api/test_emission_policy.py
+++ b/providers/openlineage/tests/unit/openlineage/api/test_emission_policy.py
@@ -279,3 +279,32 @@ class TestUnsupportedObjects:
"""Even with no flags, a wrong argument type fails loud rather than
silently no-op."""
with pytest.raises(TypeError):
extend_global_openlineage_emission_policy(object())
+
+
+class TestExcludeDatasets:
+ def test_stored_on_task(self):
+ _, task = _make_dag_and_task()
+ extend_global_openlineage_emission_policy(task,
exclude_datasets=["gs://b/.*"])
+ assert _task_flags(task) == {"exclude_datasets": ["gs://b/.*"]}
+
+ def test_dag_call_propagates_to_tasks_only(self):
+ dag_obj, task = _make_dag_and_task()
+ extend_global_openlineage_emission_policy(dag_obj,
exclude_datasets=["gs://b/.*"])
+ assert _task_flags(task) == {"exclude_datasets": ["gs://b/.*"]}
+ assert _dag_flags(dag_obj) == {}
+
+ def test_xcomarg_delegates_to_operator(self):
+ with DAG(dag_id="test_dag", schedule=None, start_date=now()):
+ op = PythonOperator(task_id="t", python_callable=lambda: 1)
+ extend_global_openlineage_emission_policy(XComArg(op),
exclude_datasets=["x"])
+ assert _task_flags(op) == {"exclude_datasets": ["x"]}
+
+ @pytest.mark.parametrize(
+ "patterns",
+ [pytest.param("gs://b/.*", id="not-a-list"), pytest.param(["("],
id="invalid-regex")],
+ )
+ def test_invalid_patterns_raise(self, patterns):
+ _, task = _make_dag_and_task()
+ with pytest.raises(ValueError, match="exclude_datasets"):
+ extend_global_openlineage_emission_policy(task,
exclude_datasets=patterns)
+ assert _task_flags(task) == {}
diff --git a/providers/openlineage/tests/unit/openlineage/api/test_sql.py
b/providers/openlineage/tests/unit/openlineage/api/test_sql.py
index 9c04a104eec..b04efeb8efe 100644
--- a/providers/openlineage/tests/unit/openlineage/api/test_sql.py
+++ b/providers/openlineage/tests/unit/openlineage/api/test_sql.py
@@ -16,13 +16,15 @@
# under the License.
from __future__ import annotations
+import dataclasses
from datetime import UTC, datetime
from unittest import mock
import pytest
-from openlineage.client.event_v2 import RunState
+from openlineage.client.event_v2 import Dataset, RunState
from airflow.providers.openlineage.api.sql import emit_query_lineage
+from airflow.providers.openlineage.utils.emission_policy import DatasetFilter,
EmissionPolicy
_MODULE = "airflow.providers.openlineage.api.sql"
_CORE = "airflow.providers.openlineage.api.core"
@@ -333,3 +335,24 @@ def
test_noop_when_emission_policy_blocks_emit(patched_emit):
)
patched_emit.assert_not_called()
+
+
+def _policy_excluding(patterns: tuple[str, ...]) -> EmissionPolicy:
+ return dataclasses.replace(
+ EmissionPolicy.defaults(),
dataset_filter=DatasetFilter(authoring_patterns=patterns)
+ )
+
+
+def test_excludes_datasets_from_events(patched_emit):
+ ti = _make_task_instance()
+ with mock.patch(f"{_MODULE}.resolve_task_emission_policy",
return_value=_policy_excluding(("ns/drop",))):
+ emit_query_lineage(
+ query_id="qid-1",
+ query_source_namespace="snowflake://ACCT",
+ inputs=[Dataset(namespace="ns", name="drop"),
Dataset(namespace="ns", name="keep")],
+ task_instance=ti,
+ )
+
+ start_event, end_event = (c.args[0] for c in patched_emit.call_args_list)
+ assert [d.name for d in start_event.inputs] == ["keep"]
+ assert [d.name for d in end_event.inputs] == ["keep"]
diff --git
a/providers/openlineage/tests/unit/openlineage/extractors/test_manager.py
b/providers/openlineage/tests/unit/openlineage/extractors/test_manager.py
index d153b5b7895..7361a7be49d 100644
--- a/providers/openlineage/tests/unit/openlineage/extractors/test_manager.py
+++ b/providers/openlineage/tests/unit/openlineage/extractors/test_manager.py
@@ -17,6 +17,7 @@
# under the License.
from __future__ import annotations
+import dataclasses
import functools
import tempfile
from typing import TYPE_CHECKING, Any
@@ -40,6 +41,7 @@ from airflow.providers.openlineage.extractors.manager import (
ExtractorManager,
_is_hook_lineage_collector_created,
)
+from airflow.providers.openlineage.utils.emission_policy import DatasetFilter,
EmissionPolicy, Rule
from airflow.providers.openlineage.utils.utils import Asset
from airflow.utils.state import State, TaskInstanceState
@@ -536,7 +538,9 @@ def
test_get_hook_lineage_with_sql_extras_only(hook_lineage_collector):
)
assert result is None
- mock_sql_fn.assert_called_once_with(task_instance=mock_ti,
sql_extras=mock.ANY, is_successful=True)
+ mock_sql_fn.assert_called_once_with(
+ task_instance=mock_ti, sql_extras=mock.ANY, is_successful=True,
dataset_filter=DatasetFilter()
+ )
sql_extras = mock_sql_fn.call_args.kwargs["sql_extras"]
assert len(sql_extras) == 1
assert
sql_extras[0].value[SqlJobHookLineageExtra.VALUE__SQL_STATEMENT.value] ==
"SELECT 1"
@@ -564,7 +568,9 @@ def
test_get_hook_lineage_with_assets_and_sql_extras(hook_lineage_collector):
task_instance_state=TaskInstanceState.SUCCESS,
)
- mock_sql_fn.assert_called_once_with(task_instance=mock_ti,
sql_extras=mock.ANY, is_successful=True)
+ mock_sql_fn.assert_called_once_with(
+ task_instance=mock_ti, sql_extras=mock.ANY, is_successful=True,
dataset_filter=DatasetFilter()
+ )
sql_extras = mock_sql_fn.call_args.kwargs["sql_extras"]
assert len(sql_extras) == 1
assert (
@@ -599,7 +605,9 @@ def
test_get_hook_lineage_sql_extras_multiple_queries(hook_lineage_collector):
task_instance_state=TaskInstanceState.SUCCESS,
)
- mock_sql_fn.assert_called_once_with(task_instance=mock_ti,
sql_extras=mock.ANY, is_successful=True)
+ mock_sql_fn.assert_called_once_with(
+ task_instance=mock_ti, sql_extras=mock.ANY, is_successful=True,
dataset_filter=DatasetFilter()
+ )
sql_extras = mock_sql_fn.call_args.kwargs["sql_extras"]
assert len(sql_extras) == 2
assert
sql_extras[0].value[SqlJobHookLineageExtra.VALUE__SQL_STATEMENT.value] ==
"SELECT a from src1"
@@ -700,7 +708,9 @@ def
test_get_hook_lineage_passes_failed_state(hook_lineage_collector):
task_instance_state=TaskInstanceState.FAILED,
)
- mock_sql_fn.assert_called_once_with(task_instance=mock_ti,
sql_extras=mock.ANY, is_successful=False)
+ mock_sql_fn.assert_called_once_with(
+ task_instance=mock_ti, sql_extras=mock.ANY, is_successful=False,
dataset_filter=DatasetFilter()
+ )
sql_extras = mock_sql_fn.call_args.kwargs["sql_extras"]
assert len(sql_extras) == 1
assert
sql_extras[0].value[SqlJobHookLineageExtra.VALUE__SQL_STATEMENT.value] ==
"SELECT 1"
@@ -907,3 +917,152 @@ def
test_convert_to_ol_dataset_asset_returns_none_when_translation_fails():
return_value=None,
):
assert ExtractorManager.convert_to_ol_dataset(asset) is None
+
+
+class _FakeStorageHook:
+ pass
+
+
+class _FakeWarehouseHook:
+ pass
+
+
+def _hook_fqcn(hook_cls) -> str:
+ return f"{hook_cls.__module__}.{hook_cls.__name__}"
+
+
+def _controls(dataset_filter: DatasetFilter) -> EmissionPolicy:
+ return dataclasses.replace(EmissionPolicy.defaults(),
dataset_filter=dataset_filter)
+
+
+def _hook_exclusion(hook_cls, patterns: list[str]) -> DatasetFilter:
+ return DatasetFilter(
+ tiers=((Rule(scope={"hook": _hook_fqcn(hook_cls)},
controls={"exclude_datasets": patterns}),),)
+ )
+
+
+# gs:// because translating an asset needs the converter of the provider
owning the scheme, and
+# google is a dev dependency of this provider while amazon is not.
+def _collect_gs_parts(collector):
+ storage_hook, warehouse_hook = _FakeStorageHook(), _FakeWarehouseHook()
+ collector.add_output_asset(context=storage_hook,
uri="gs://bucket/staging/part_0")
+ collector.add_output_asset(context=storage_hook,
uri="gs://bucket/final/summary")
+ collector.add_output_asset(context=warehouse_hook,
uri="gs://bucket/staging/part_1")
+
+
+def
test_get_hook_lineage_excludes_datasets_per_reporting_hook(hook_lineage_collector):
+ _collect_gs_parts(hook_lineage_collector)
+
+ result = ExtractorManager().get_hook_lineage(
+ controls=_controls(_hook_exclusion(_FakeStorageHook,
["gs://bucket/staging/.*"]))
+ )
+
+ assert result == OperatorLineage(
+ outputs=[
+ OpenLineageDataset(namespace="gs://bucket", name="final/summary"),
+ OpenLineageDataset(namespace="gs://bucket", name="staging/part_1"),
+ ]
+ )
+
+
+def
test_get_hook_lineage_returns_empty_lineage_when_everything_excluded(hook_lineage_collector):
+ _collect_gs_parts(hook_lineage_collector)
+
+ result = ExtractorManager().get_hook_lineage(
+ controls=_controls(DatasetFilter(authoring_patterns=(".*",)))
+ )
+
+ assert result == OperatorLineage()
+
+
+def
test_get_hook_lineage_passes_dataset_filter_to_sql_extras(hook_lineage_collector):
+ hook_lineage_collector.add_extra(
+ context=MagicMock(),
+ key=SqlJobHookLineageExtra.KEY.value,
+ value={SqlJobHookLineageExtra.VALUE__SQL_STATEMENT.value: "SELECT 1"},
+ )
+ dataset_filter = DatasetFilter(authoring_patterns=("x",))
+
+ with patch(_SQL_FN_PATH, return_value=None) as mock_sql_fn:
+ ExtractorManager().get_hook_lineage(
+ task_instance=MagicMock(),
+ task_instance_state=TaskInstanceState.SUCCESS,
+ controls=_controls(dataset_filter),
+ )
+
+ assert mock_sql_fn.call_args.kwargs["dataset_filter"] is dataset_filter
+
+
+def
test_extract_metadata_excluded_hook_lineage_does_not_fall_back_to_inlets(hook_lineage_collector):
+ class FakeUnsupportedOperator(BaseOperator):
+ def execute(self, context: Context) -> Any:
+ pass
+
+ _collect_gs_parts(hook_lineage_collector)
+ task = FakeUnsupportedOperator(
+ task_id="unsupported_task", inlets=[OpenLineageDataset(namespace="ns",
name="inlet")]
+ )
+
+ metadata = ExtractorManager().extract_metadata(
+ dagrun=MagicMock(),
+ task=task,
+ task_instance_state=None,
+ task_instance=MagicMock(),
+ controls=_controls(DatasetFilter(authoring_patterns=("gs://.*",))),
+ )
+
+ assert metadata.inputs == []
+ assert metadata.outputs == []
+
+
+def test_extract_metadata_excludes_extractor_datasets():
+ class FakeSupportedOperator(BaseOperator):
+ def execute(self, context: Context) -> Any:
+ pass
+
+ def get_openlineage_facets_on_complete(self, task_instance):
+ return OperatorLineage(
+ inputs=[OpenLineageDataset(namespace="ns", name="keep")],
+ outputs=[OpenLineageDataset(namespace="ns", name="drop")],
+ )
+
+ metadata = ExtractorManager().extract_metadata(
+ dagrun=MagicMock(),
+ task=FakeSupportedOperator(task_id="supported_task"),
+ task_instance_state=None,
+ task_instance=MagicMock(),
+ controls=_controls(DatasetFilter(authoring_patterns=("ns/drop",))),
+ )
+
+ assert metadata.inputs == [OpenLineageDataset(namespace="ns", name="keep")]
+ assert metadata.outputs == []
+
+
+def test_extract_inlets_and_outlets_excludes_static_and_runtime_datasets():
+ task = PythonOperator(
+ task_id="t",
+ python_callable=lambda: 1,
+ inlets=[
+ OpenLineageDataset(namespace="ns", name="drop_in"),
+ OpenLineageDataset(namespace="ns", name="in"),
+ ],
+ outlets=[OpenLineageDataset(namespace="ns", name="out")],
+ )
+ lineage = OperatorLineage()
+
+ with (
+ patch(
+
"airflow.providers.openlineage.extractors.manager.get_runtime_outlet_assets",
+ return_value=[(Asset(uri="s3://bucket/runtime.txt", extra={}),
None)],
+ ),
+ patch(
+
"airflow.providers.openlineage.extractors.manager.translate_airflow_asset",
+ return_value=OpenLineageDataset(namespace="ns",
name="drop_runtime"),
+ ),
+ ):
+ ExtractorManager().extract_inlets_and_outlets(
+ lineage, task, MagicMock(),
_controls(DatasetFilter(authoring_patterns=("ns/drop_.*",)))
+ )
+
+ assert lineage.inputs == [OpenLineageDataset(namespace="ns", name="in")]
+ assert lineage.outputs == [OpenLineageDataset(namespace="ns", name="out")]
diff --git
a/providers/openlineage/tests/unit/openlineage/utils/test_emission_policy.py
b/providers/openlineage/tests/unit/openlineage/utils/test_emission_policy.py
index ee501d4a2b8..4784bcede1d 100644
--- a/providers/openlineage/tests/unit/openlineage/utils/test_emission_policy.py
+++ b/providers/openlineage/tests/unit/openlineage/utils/test_emission_policy.py
@@ -22,10 +22,12 @@ import warnings
from unittest import mock
import pytest
+from openlineage.client.event_v2 import Dataset
from airflow import DAG
from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.providers.openlineage.utils.emission_policy import (
+ DatasetFilter,
EmissionPolicy,
Rule,
resolve_dag_emission_policy,
@@ -2412,3 +2414,254 @@ class TestSelectiveEnableWithLockedRule:
# adds 'emit' to locked_fields but locked_fields only blocks
AUTHORING, not other conf rules.
# So the task-tier opt-in rule still applies.
assert cfg.emit is True
+
+
+_HOOK = "airflow.providers.google.cloud.hooks.gcs.GCSHook"
+_OTHER_HOOK = "airflow.providers.postgres.hooks.postgres.PostgresHook"
+
+
+def _exclude_rule(scope: dict, patterns: list[str], **extra) -> dict:
+ return {"scope": scope, "controls": {"exclude_datasets": patterns},
**extra}
+
+
+class TestExcludeDatasetsValidation:
+ @pytest.mark.parametrize(
+ "rule",
+ [
+ pytest.param({"scope": {}, "controls": {"exclude_datasets":
"gs://b/.*"}}, id="not-a-list"),
+ pytest.param({"scope": {}, "controls": {"exclude_datasets": [1]}},
id="non-string-pattern"),
+ pytest.param({"scope": {}, "controls": {"exclude_datasets":
["("]}}, id="invalid-regex"),
+ pytest.param({"scope": {"hook": _HOOK}, "controls": {"emit":
False}}, id="hook-scope-emit"),
+ pytest.param(
+ {"scope": {"hook": _HOOK}, "controls": {"exclude_datasets":
[".*"], "hook_lineage": False}},
+ id="hook-scope-both-controls",
+ ),
+ pytest.param(
+ {"scope": {"hook": "("}, "match_mode": "regex", "controls":
{"exclude_datasets": [".*"]}},
+ id="hook-scope-invalid-regex",
+ ),
+ ],
+ )
+ def test_invalid_rule_is_skipped(self, rule):
+ cfg = _resolve_task_controls([rule], operator=MockOperator(),
dag_id="dag", task_id="task")
+ assert cfg == EmissionPolicy.defaults()
+
+ def test_hook_scoped_hook_lineage_does_not_disable_task_hook_lineage(self):
+ cfg = _resolve_task_controls(
+ [{"scope": {"hook": _HOOK}, "controls": {"hook_lineage": False}}],
+ operator=MockOperator(),
+ dag_id="dag",
+ task_id="task",
+ )
+ assert cfg.hook_lineage is True
+ assert cfg.dataset_filter.get_patterns(_HOOK) == (".*",)
+
+
+class TestExcludeDatasetsResolution:
+ @pytest.mark.parametrize(
+ ("rules", "hook", "expected"),
+ [
+ pytest.param([_exclude_rule({}, ["a"])], None, ("a",),
id="global"),
+ pytest.param([_exclude_rule({}, ["a"])], _HOOK, ("a",),
id="global-applies-to-hooks"),
+ pytest.param(
+ [_exclude_rule({}, ["a"]), _exclude_rule({"dag_id": "dag",
"task_id": "task"}, ["b"])],
+ None,
+ ("b",),
+ id="task-replaces-global",
+ ),
+ pytest.param(
+ [_exclude_rule({}, ["a"]), _exclude_rule({"dag_id": "dag",
"task_id": "task"}, [])],
+ None,
+ (),
+ id="task-clears-global",
+ ),
+ pytest.param([_exclude_rule({"hook": _HOOK}, ["a"])], _HOOK,
("a",), id="hook-matches"),
+ pytest.param([_exclude_rule({"hook": _HOOK}, ["a"])], _OTHER_HOOK,
(), id="hook-other-hook"),
+ pytest.param([_exclude_rule({"hook": _HOOK}, ["a"])], None, (),
id="hook-no-hook"),
+ pytest.param(
+ [_exclude_rule({"hook": ".*GCSHook"}, ["a"],
match_mode="regex")],
+ _HOOK,
+ ("a",),
+ id="hook-regex",
+ ),
+ pytest.param(
+ [_exclude_rule({"dag_id": "other", "hook": _HOOK}, ["a"])],
+ _HOOK,
+ (),
+ id="dag-hook-other-dag",
+ ),
+ pytest.param(
+ [
+ _exclude_rule({"dag_id": "dag", "task_id": "task"},
["task"]),
+ _exclude_rule({"dag_id": "dag", "task_id": "task", "hook":
_HOOK}, ["task+hook"]),
+ ],
+ _HOOK,
+ ("task+hook",),
+ id="task-hook-beats-task",
+ ),
+ pytest.param(
+ [
+ _exclude_rule({"dag_id": "dag", "hook": _HOOK},
["dag+hook"]),
+ _exclude_rule({"dag_id": "dag", "task_id": "task"},
["task"]),
+ ],
+ _HOOK,
+ ("task",),
+ id="task-beats-dag-hook",
+ ),
+ pytest.param(
+ [
+ _exclude_rule({"dag_id": "dag"}, ["dag"]),
+ _exclude_rule({"dag_id": "dag", "hook": _HOOK},
["dag+hook"]),
+ ],
+ _HOOK,
+ ("dag+hook",),
+ id="dag-hook-beats-dag",
+ ),
+ pytest.param(
+ [
+ _exclude_rule({"operator":
"tests.mock_module.MockOperator"}, ["op"]),
+ _exclude_rule({"operator":
"tests.mock_module.MockOperator", "hook": _HOOK}, ["op+hook"]),
+ ],
+ _HOOK,
+ ("op+hook",),
+ id="operator-hook-beats-operator",
+ ),
+ pytest.param(
+ [_exclude_rule({"hook": _HOOK}, ["hook"]),
_exclude_rule({"operator": "x.Y"}, ["op"])],
+ _HOOK,
+ ("hook",),
+ id="operator-rule-not-matching",
+ ),
+ pytest.param(
+ [_exclude_rule({}, ["global"]), _exclude_rule({"hook": _HOOK},
["hook"])],
+ _HOOK,
+ ("hook",),
+ id="hook-beats-global",
+ ),
+ pytest.param(
+ [
+ {"scope": {"hook": _HOOK}, "controls": {"hook_lineage":
False}},
+ {"scope": {"dag_id": "dag", "hook": _HOOK}, "controls":
{"hook_lineage": True}},
+ ],
+ _HOOK,
+ (),
+ id="hook-lineage-true-clears-broader-hook-rule",
+ ),
+ ],
+ )
+ def test_get_patterns(self, rules, hook, expected):
+ cfg = _resolve_task_controls(rules, operator=MockOperator(),
dag_id="dag", task_id="task")
+ assert cfg.dataset_filter.get_patterns(hook) == expected
+
+ @pytest.mark.parametrize(
+ ("patterns", "dataset", "excluded"),
+ [
+ pytest.param(
+ ["gs://bucket/staging/.*"], Dataset("gs://bucket",
"staging/part_1"), True, id="match"
+ ),
+ pytest.param(
+ ["staging/.*"], Dataset("gs://bucket", "staging/part_1"),
False, id="fullmatch-only"
+ ),
+ pytest.param(
+ ["gs://bucket/staging/.*"], Dataset("gs://bucket",
"final/out"), False, id="no-match"
+ ),
+ ],
+ )
+ def test_is_excluded_matches_namespace_and_name(self, patterns, dataset,
excluded):
+ cfg = _resolve_task_controls(
+ [_exclude_rule({}, patterns)], operator=MockOperator(),
dag_id="dag", task_id="task"
+ )
+ assert cfg.dataset_filter.is_excluded(dataset) is excluded
+
+ def test_exclude_keeps_order_of_remaining_datasets(self):
+ datasets = [Dataset("gs://b", "keep_1"), Dataset("gs://b", "drop"),
Dataset("gs://b", "keep_2")]
+ cfg = _resolve_task_controls(
+ [_exclude_rule({}, ["gs://b/drop"])], operator=MockOperator(),
dag_id="dag", task_id="task"
+ )
+ assert cfg.dataset_filter.exclude(datasets) == [datasets[0],
datasets[2]]
+
+ def test_audit_log_names_replaced_broader_rules(self):
+ global_rule = _exclude_rule({}, ["a"])
+ task_rule = _exclude_rule({"dag_id": "dag", "task_id": "task"}, ["b"])
+ cfg = _resolve_task_controls(
+ [global_rule, task_rule], operator=MockOperator(), dag_id="dag",
task_id="task"
+ )
+ with
mock.patch("airflow.providers.openlineage.utils.emission_policy.log") as
mock_log:
+ cfg.dataset_filter.get_patterns(None)
+ cfg.dataset_filter.get_patterns(None)
+ mock_log.info.assert_called_once_with(
+ "OpenLineage emission policy: '%s' set to %r for %s by %r,
replacing broader rule(s) %r",
+ "exclude_datasets",
+ ["b"],
+ "task 'task' in dag 'dag'",
+ Rule(scope=task_rule["scope"], controls=task_rule["controls"]),
+ [Rule(scope={}, controls=global_rule["controls"])],
+ )
+
+
+class TestExcludeDatasetsAuthoring:
+ def test_authoring_replaces_conf_patterns_for_every_source(self):
+ from airflow.providers.openlineage.api.emission_policy import (
+ extend_global_openlineage_emission_policy,
+ )
+
+ _, task = _make_dag_and_task()
+ extend_global_openlineage_emission_policy(task,
exclude_datasets=["authored"])
+ rules = [_exclude_rule({}, ["global"]), _exclude_rule({"hook": _HOOK},
["hook"])]
+ cfg = _resolve_task_controls(rules, operator=task, dag_id="test_dag",
task_id="test_task")
+
+ assert cfg.dataset_filter.get_patterns(None) == ("authored",)
+ assert cfg.dataset_filter.get_patterns(_HOOK) == ("authored",)
+
+ def test_authoring_without_conf_rules(self):
+ from airflow.providers.openlineage.api.emission_policy import (
+ extend_global_openlineage_emission_policy,
+ )
+
+ _, task = _make_dag_and_task()
+ extend_global_openlineage_emission_policy(task,
exclude_datasets=["authored"])
+ cfg = _resolve_task_controls([], operator=task, dag_id="test_dag",
task_id="test_task")
+
+ assert cfg.dataset_filter ==
DatasetFilter(authoring_patterns=("authored",))
+ assert cfg.dataset_filter.get_patterns(None) == ("authored",)
+
+ def test_locked_rule_blocks_authoring_only_where_it_applies(self):
+ from airflow.providers.openlineage.api.emission_policy import (
+ extend_global_openlineage_emission_policy,
+ )
+
+ _, task = _make_dag_and_task()
+ extend_global_openlineage_emission_policy(task,
exclude_datasets=["authored"])
+ rules = [_exclude_rule({"hook": _HOOK}, ["locked"], locked=True)]
+ cfg = _resolve_task_controls(rules, operator=task, dag_id="test_dag",
task_id="test_task")
+
+ with
mock.patch("airflow.providers.openlineage.utils.emission_policy.log") as
mock_log:
+ assert cfg.dataset_filter.get_patterns(_HOOK) == ("locked",)
+ mock_log.warning.assert_called_once()
+ assert cfg.dataset_filter.get_patterns(_OTHER_HOOK) == ("authored",)
+ assert cfg.dataset_filter.get_patterns(None) == ("authored",)
+
+ def test_locked_rule_does_not_block_more_specific_conf_rule(self):
+ rules = [
+ _exclude_rule({}, ["locked"], locked=True),
+ _exclude_rule({"dag_id": "dag", "task_id": "task"}, ["task"]),
+ ]
+ cfg = _resolve_task_controls(rules, operator=MockOperator(),
dag_id="dag", task_id="task")
+ assert cfg.dataset_filter.get_patterns(None) == ("task",)
+
+ @pytest.mark.parametrize(
+ "patterns", [pytest.param(["("], id="invalid-regex"),
pytest.param([1], id="non-string")]
+ )
+ def test_invalid_authored_patterns_bypassing_api_are_ignored(self,
patterns):
+ _, task = _make_dag_and_task()
+ task.params["_openlineage_emission_policy"] = {"exclude_datasets":
patterns}
+ cfg = _resolve_task_controls(
+ [_exclude_rule({}, ["global"])], operator=task, dag_id="test_dag",
task_id="test_task"
+ )
+ assert cfg.dataset_filter.get_patterns(None) == ("global",)
+
+ def test_policy_with_exclude_rules_is_hashable(self):
+ cfg = _resolve_task_controls(
+ [_exclude_rule({}, ["a"])], operator=MockOperator(), dag_id="dag",
task_id="task"
+ )
+ assert hash(cfg) == hash(EmissionPolicy.defaults())
diff --git
a/providers/openlineage/tests/unit/openlineage/utils/test_sql_hook_lineage.py
b/providers/openlineage/tests/unit/openlineage/utils/test_sql_hook_lineage.py
index b2a716c97e7..bbe23dbb439 100644
---
a/providers/openlineage/tests/unit/openlineage/utils/test_sql_hook_lineage.py
+++
b/providers/openlineage/tests/unit/openlineage/utils/test_sql_hook_lineage.py
@@ -26,6 +26,7 @@ from openlineage.client.facet_v2 import external_query_run,
sql_job
from airflow.providers.common.sql.hooks.lineage import SqlJobHookLineageExtra
from airflow.providers.openlineage.extractors.base import OperatorLineage
from airflow.providers.openlineage.sqlparser import SQLParser
+from airflow.providers.openlineage.utils.emission_policy import DatasetFilter,
Rule
from airflow.providers.openlineage.utils.sql_hook_lineage import (
_get_hook_conn_id,
emit_lineage_from_sql_extras,
@@ -142,6 +143,33 @@ class TestEmitLineageFromSqlExtras:
mock.call(mock.sentinel.end_event),
]
+ def test_datasets_excluded_for_reporting_hook_only(self):
+ class FakeHook:
+ def get_connection(self, conn_id):
+ return mock.MagicMock()
+
+ def get_openlineage_database_info(self, connection):
+ return mock.MagicMock()
+
+ self.mock_ns.return_value = "postgres://host/db"
+ in_table = OpenLineageDataset(namespace="ns", name="in_table")
+ out_table = OpenLineageDataset(namespace="ns", name="out_table")
+ self.mock_facets_fn.side_effect = lambda **_:
OperatorLineage(inputs=[in_table], outputs=[out_table])
+ rule = Rule(
+ scope={"hook": f"{FakeHook.__module__}.{FakeHook.__name__}"},
+ controls={"exclude_datasets": ["ns/in_table"]},
+ )
+
+ emit_lineage_from_sql_extras(
+ task_instance=mock.MagicMock(dag_id="dag_id", task_id="task_id"),
+ sql_extras=[_make_extra(sql="SELECT 1", hook=FakeHook()),
_make_extra(sql="SELECT 2")],
+ dataset_filter=DatasetFilter(tiers=((rule,),)),
+ )
+
+ first, second = self.mock_build.call_args_list
+ assert (first.kwargs["inputs"], first.kwargs["outputs"]) == ([],
[out_table])
+ assert (second.kwargs["inputs"], second.kwargs["outputs"]) ==
([in_table], [out_table])
+
def test_multiple_queries_increment_counter(self):
self.mock_ns.return_value = "postgres://host/db"
mock_ti = mock.MagicMock(dag_id="dag_id", task_id="task_id")