This is an automated email from the ASF dual-hosted git repository.
mobuchowski 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 ae50f958ea2 Surface errors from OpenLineage database info lookups
without losing lineage (#74805)
ae50f958ea2 is described below
commit ae50f958ea22243852d3b58c511aafde615b91f1
Author: Kacper Muda <[email protected]>
AuthorDate: Tue Oct 27 16:35:22 2026 +0100
Surface errors from OpenLineage database info lookups without losing
lineage (#74805)
* Don't silence AttributeError from OpenLineage database info lookup
SQL OpenLineage extraction called hook.get_openlineage_database_info()
inside `except AttributeError` blocks, meant to detect hooks that don't
implement the method. The same handler also swallowed AttributeErrors
raised from inside the method (e.g. a broken optional import in
SnowflakeHook.get_uri()), logging only at debug level. Lineage silently
came out without inputs/outputs and nothing in the task logs said why.
Look the method up with getattr() instead and call it outside any
try/except, so real errors propagate (the extractor already turns them
into a warning and still emits events). Also log failures in SQL hook
lineage at warning level, with details at debug.
Co-Authored-By: Claude <[email protected]>
* Keep partial OpenLineage SQL lineage when database info lookup fails
Reviewers pointed out that letting AttributeError escape lost all lineage
for the task, including GCS outputs and query ids that don't depend on the
failing method, and that hooks without the method were warned about.
A missing method is now detected with getattr and stays silent. An error
raised inside the method is logged as a warning and lineage falls back to
what can still be built. The same applies to the dialect, default schema
and database specific lineage lookups.
Co-Authored-By: Claude <[email protected]>
* Don't silence AttributeError in the common.compat OpenLineage SQL fallback
The fallback used when the OpenLineage provider is too old to ship
get_openlineage_facets_with_sql had the same except AttributeError blocks,
so an error raised inside the hook's database info, dialect or default
schema lookup was reported as a hook without those methods.
Co-Authored-By: Claude <[email protected]>
* Keep OpenLineage lineage when the SQLAlchemy engine or hook helpers fail
A failing get_sqlalchemy_engine() in SQLExecuteQueryOperator still dropped
all lineage of the task, and the new warnings named the hook object and the
wrong method, which made failures hard to attribute to a connection.
Co-Authored-By: Claude <[email protected]>
* Name the connection and both lookups in OpenLineage SQL debug messages
Co-Authored-By: Claude <[email protected]>
* Report a failing OpenLineage dialect lookup once per hook in SQL hook
lineage
A hook whose dialect or default schema lookup fails would log the same
warning for every SQL extra of a task. The SQLParser is now built once per
hook and reused, and the SQL parsing warning names the connection.
Co-Authored-By: Claude <[email protected]>
* Accept a missing connection id in get_sql_parser_for_hook
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Claude <[email protected]>
---
.../common/compat/openlineage/utils/sql.py | 29 ++++---
.../common/compat/openlineage/utils/test_sql.py | 60 +++++++++++++++
.../airflow/providers/common/sql/operators/sql.py | 55 ++++++++-----
.../unit/common/sql/operators/test_sql_execute.py | 89 ++++++++++++++++++++++
.../src/airflow/providers/openlineage/sqlparser.py | 45 +++++++----
.../openlineage/utils/sql_hook_lineage.py | 21 ++++-
.../tests/unit/openlineage/test_sqlparser.py | 53 +++++++++++--
.../openlineage/utils/test_sql_hook_lineage.py | 84 ++++++++++++++++++++
8 files changed, 385 insertions(+), 51 deletions(-)
diff --git
a/providers/common/compat/src/airflow/providers/common/compat/openlineage/utils/sql.py
b/providers/common/compat/src/airflow/providers/common/compat/openlineage/utils/sql.py
index 72e9e1f94f0..40a4e4a0604 100644
---
a/providers/common/compat/src/airflow/providers/common/compat/openlineage/utils/sql.py
+++
b/providers/common/compat/src/airflow/providers/common/compat/openlineage/utils/sql.py
@@ -51,22 +51,31 @@ else:
use_external_connection = True
connection = hook.get_connection(conn_id)
- try:
- database_info = hook.get_openlineage_database_info(connection)
- except AttributeError:
- log.debug("%s has no database info provided", hook)
- database_info = None
+ database_info = None
+ get_database_info = getattr(hook, "get_openlineage_database_info",
None)
+ if get_database_info is not None:
+ try:
+ database_info = get_database_info(connection)
+ except Exception as e:
+ log.warning("Failed to get OpenLineage database info for
%s: %s", conn_id, e)
+ log.debug("OpenLineage failure details:", exc_info=True)
if database_info is None:
+ log.debug("%s has no database info provided", conn_id)
return None
+ get_dialect = getattr(hook, "get_openlineage_database_dialect",
None)
+ get_default_schema = getattr(hook,
"get_openlineage_default_schema", None)
+ if get_dialect is None or get_default_schema is None:
+ log.debug("%s has no database dialect or default schema
provided", conn_id)
+ return None
try:
- sql_parser = SQLParser(
- dialect=hook.get_openlineage_database_dialect(connection),
- default_schema=hook.get_openlineage_default_schema(),
+ sql_parser = SQLParser(dialect=get_dialect(connection),
default_schema=get_default_schema())
+ except Exception as e:
+ log.warning(
+ "Failed to get OpenLineage database dialect or default
schema for %s: %s", conn_id, e
)
- except AttributeError:
- log.debug("%s failed to get database dialect", hook)
+ log.debug("OpenLineage failure details:", exc_info=True)
return None
try:
diff --git
a/providers/common/compat/tests/unit/common/compat/openlineage/utils/test_sql.py
b/providers/common/compat/tests/unit/common/compat/openlineage/utils/test_sql.py
index dfa3eace880..44fd8f5033e 100644
---
a/providers/common/compat/tests/unit/common/compat/openlineage/utils/test_sql.py
+++
b/providers/common/compat/tests/unit/common/compat/openlineage/utils/test_sql.py
@@ -16,8 +16,68 @@
# under the License.
from __future__ import annotations
+import importlib
+from unittest import mock
+
+import pytest
+
+_MODULE = "airflow.providers.common.compat.openlineage.utils.sql"
+
def test_import():
from airflow.providers.common.compat.openlineage.utils.sql import
get_openlineage_facets_with_sql
assert get_openlineage_facets_with_sql is not None
+
+
[email protected]
+def fallback_get_openlineage_facets_with_sql(monkeypatch):
+ """Load the module as if the OpenLineage provider was too old to ship
``get_openlineage_facets_with_sql``."""
+ from airflow.providers.openlineage import sqlparser
+
+ monkeypatch.delattr(sqlparser, "get_openlineage_facets_with_sql")
+ module = importlib.reload(importlib.import_module(_MODULE))
+ yield module.get_openlineage_facets_with_sql
+ monkeypatch.undo()
+ importlib.reload(module)
+
+
[email protected](f"{_MODULE}.log", autospec=True)
+def test_fallback_hook_without_database_info_method_returns_none(
+ mock_log, fallback_get_openlineage_facets_with_sql
+):
+ hook = mock.MagicMock(spec=["get_connection"])
+
+ assert fallback_get_openlineage_facets_with_sql(hook, "SELECT 1", "conn",
None) is None
+ mock_log.warning.assert_not_called()
+
+
[email protected](f"{_MODULE}.log", autospec=True)
+def test_fallback_error_from_database_info_is_logged_as_warning(
+ mock_log, fallback_get_openlineage_facets_with_sql
+):
+ hook = mock.MagicMock(spec=["get_connection",
"get_openlineage_database_info"])
+ hook.get_openlineage_database_info.side_effect = AttributeError("broken
import")
+
+ assert fallback_get_openlineage_facets_with_sql(hook, "SELECT 1", "conn",
None) is None
+ mock_log.warning.assert_called_once()
+ assert mock_log.warning.call_args.args[1] == "conn"
+ assert str(mock_log.warning.call_args.args[2]) == "broken import"
+
+
[email protected](f"{_MODULE}.log", autospec=True)
+def test_fallback_error_from_dialect_is_logged_as_warning(mock_log,
fallback_get_openlineage_facets_with_sql):
+ hook = mock.MagicMock(
+ spec=[
+ "get_connection",
+ "get_openlineage_database_info",
+ "get_openlineage_database_dialect",
+ "get_openlineage_default_schema",
+ ]
+ )
+ hook.get_openlineage_database_dialect.side_effect = AttributeError("broken
import")
+
+ assert fallback_get_openlineage_facets_with_sql(hook, "SELECT 1", "conn",
None) is None
+ mock_log.warning.assert_called_once()
+ assert mock_log.warning.call_args.args[1] == "conn"
+ assert str(mock_log.warning.call_args.args[2]) == "broken import"
diff --git
a/providers/common/sql/src/airflow/providers/common/sql/operators/sql.py
b/providers/common/sql/src/airflow/providers/common/sql/operators/sql.py
index 01148ae4507..160485e5ed9 100644
--- a/providers/common/sql/src/airflow/providers/common/sql/operators/sql.py
+++ b/providers/common/sql/src/airflow/providers/common/sql/operators/sql.py
@@ -296,32 +296,47 @@ class BaseSQLOperator(BaseOperator):
# OpenLineage provider release < 1.8.0 - we always use connection
use_external_connection = True
- connection = hook.get_connection(getattr(hook, hook.conn_name_attr))
- try:
- database_info = hook.get_openlineage_database_info(connection)
- except AttributeError:
- self.log.debug("%s has no database info provided", hook)
- database_info = None
+ conn_id = getattr(hook, hook.conn_name_attr)
+ connection = hook.get_connection(conn_id)
+ get_database_info = getattr(hook, "get_openlineage_database_info",
None)
+ database_info = None
+ if get_database_info is not None:
+ try:
+ database_info = get_database_info(connection)
+ except Exception as e:
+ self.log.warning("Failed to get OpenLineage database info for
%s: %s", conn_id, e)
+ self.log.debug("OpenLineage failure details:", exc_info=True)
if database_info is None:
self.log.debug("OpenLineage could not retrieve database
information. Skipping.")
return OperatorLineage()
+ get_dialect = getattr(hook, "get_openlineage_database_dialect", None)
+ get_default_schema = getattr(hook, "get_openlineage_default_schema",
None)
+ if get_dialect is None or get_default_schema is None:
+ self.log.debug("%s has no database dialect or default schema
provided", conn_id)
+ return None
try:
- sql_parser = SQLParser(
- dialect=hook.get_openlineage_database_dialect(connection),
- default_schema=hook.get_openlineage_default_schema(),
+ sql_parser = SQLParser(dialect=get_dialect(connection),
default_schema=get_default_schema())
+ except Exception as e:
+ self.log.warning(
+ "Failed to get OpenLineage database dialect or default schema
for %s: %s", conn_id, e
)
- except AttributeError:
- self.log.debug("%s failed to get database dialect", hook)
+ self.log.debug("OpenLineage failure details:", exc_info=True)
return None
+ sqlalchemy_engine = None
+ try:
+ sqlalchemy_engine = hook.get_sqlalchemy_engine()
+ except Exception as e:
+ self.log.debug("Failed to get sql alchemy engine: %s", e)
+
operator_lineage = sql_parser.generate_openlineage_metadata_from_sql(
sql=sql,
hook=hook,
database_info=database_info,
database=self.database,
- sqlalchemy_engine=hook.get_sqlalchemy_engine(),
+ sqlalchemy_engine=sqlalchemy_engine,
use_connection=use_external_connection,
)
@@ -337,11 +352,17 @@ class BaseSQLOperator(BaseOperator):
operator_lineage = self.get_openlineage_facets_on_start() or
OperatorLineage()
hook = self.get_db_hook()
- try:
- database_specific_lineage =
hook.get_openlineage_database_specific_lineage(task_instance)
- except AttributeError:
- self.log.debug("%s has no database specific lineage provided",
hook)
- database_specific_lineage = None
+ conn_id = getattr(hook, hook.conn_name_attr)
+ get_specific_lineage = getattr(hook,
"get_openlineage_database_specific_lineage", None)
+ database_specific_lineage = None
+ if get_specific_lineage is None:
+ self.log.debug("%s has no database specific lineage provided",
conn_id)
+ else:
+ try:
+ database_specific_lineage = get_specific_lineage(task_instance)
+ except Exception as e:
+ self.log.warning("Failed to get OpenLineage database specific
lineage for %s: %s", conn_id, e)
+ self.log.debug("OpenLineage failure details:", exc_info=True)
if database_specific_lineage is None:
if not self.check_results:
diff --git
a/providers/common/sql/tests/unit/common/sql/operators/test_sql_execute.py
b/providers/common/sql/tests/unit/common/sql/operators/test_sql_execute.py
index 564cfabd1a6..8721034474d 100644
--- a/providers/common/sql/tests/unit/common/sql/operators/test_sql_execute.py
+++ b/providers/common/sql/tests/unit/common/sql/operators/test_sql_execute.py
@@ -368,6 +368,95 @@ FORGOT TO COMMENT"""
)
+class _SQLExecuteQueryOperatorForTest(SQLExecuteQueryOperator):
+ hook: Any
+
+ def get_db_hook(self):
+ return self.hook
+
+
+def _make_openlineage_operator(hook) -> _SQLExecuteQueryOperatorForTest:
+ hook.conn_name_attr = "conn_id"
+ hook.conn_id = "sql_default"
+ op = _SQLExecuteQueryOperatorForTest(task_id=TASK_ID, sql="SELECT 1;")
+ op.hook = hook
+ return op
+
+
[email protected](SQLExecuteQueryOperator, "log", autospec=True)
+def
test_openlineage_hook_without_database_info_method_returns_empty_lineage(mock_log):
+ from airflow.providers.openlineage.extractors import OperatorLineage
+
+ op = _make_openlineage_operator(mock.MagicMock(spec=["get_connection",
"conn_name_attr", "conn_id"]))
+
+ assert op.get_openlineage_facets_on_start() == OperatorLineage()
+ mock_log.warning.assert_not_called()
+
+
[email protected](SQLExecuteQueryOperator, "log", autospec=True)
+def
test_openlineage_error_from_database_info_is_logged_and_does_not_lose_other_lineage(mock_log):
+ from airflow.providers.openlineage.extractors import OperatorLineage
+
+ hook = mock.MagicMock(spec=DbApiHook)
+ hook.get_openlineage_database_info.side_effect = AttributeError("broken
import")
+ hook.get_openlineage_database_specific_lineage.return_value =
OperatorLineage(
+ run_facets={"externalQuery": mock.sentinel.facet}
+ )
+ op = _make_openlineage_operator(hook)
+
+ assert op.get_openlineage_facets_on_start() == OperatorLineage()
+ lineage = op.get_openlineage_facets_on_complete(None)
+
+ assert lineage.run_facets == {"externalQuery": mock.sentinel.facet}
+ assert mock_log.warning.call_count == 2
+ assert mock_log.warning.call_args.args[1] == "sql_default"
+ assert str(mock_log.warning.call_args.args[2]) == "broken import"
+
+
[email protected](SQLExecuteQueryOperator, "log", autospec=True)
+def test_openlineage_error_from_dialect_is_logged_with_conn_id(mock_log):
+ hook = mock.MagicMock(spec=DbApiHook)
+ hook.get_openlineage_database_info.return_value =
mock.sentinel.database_info
+ hook.get_openlineage_database_dialect.side_effect = AttributeError("broken
import")
+ op = _make_openlineage_operator(hook)
+
+ assert op.get_openlineage_facets_on_start() is None
+ mock_log.warning.assert_called_once()
+ assert mock_log.warning.call_args.args[1] == "sql_default"
+ assert str(mock_log.warning.call_args.args[2]) == "broken import"
+
+
[email protected](SQLExecuteQueryOperator, "log", autospec=True)
+def
test_openlineage_error_from_database_specific_lineage_is_logged_and_falls_back(mock_log):
+ from airflow.providers.openlineage.extractors import OperatorLineage
+
+ hook = mock.MagicMock(spec=DbApiHook)
+ hook.get_openlineage_database_info.return_value = None
+ hook.get_openlineage_database_specific_lineage.side_effect =
AttributeError("broken import")
+ op = _make_openlineage_operator(hook)
+
+ assert op.get_openlineage_facets_on_complete(None) == OperatorLineage()
+ mock_log.warning.assert_called_once()
+ assert mock_log.warning.call_args.args[1] == "sql_default"
+ assert str(mock_log.warning.call_args.args[2]) == "broken import"
+
+
[email protected]("airflow.providers.openlineage.sqlparser.SQLParser", autospec=True)
+def
test_openlineage_sqlalchemy_engine_failure_does_not_lose_lineage(mock_sql_parser):
+ from airflow.providers.openlineage.extractors import OperatorLineage
+
+ hook = mock.MagicMock(spec=DbApiHook)
+ hook.get_sqlalchemy_engine.side_effect = RuntimeError("no dialect")
+ hook.get_openlineage_database_info.return_value =
mock.sentinel.database_info
+ op = _make_openlineage_operator(hook)
+ expected = OperatorLineage()
+
mock_sql_parser.return_value.generate_openlineage_metadata_from_sql.return_value
= expected
+
+ assert op.get_openlineage_facets_on_start() is expected
+ generate =
mock_sql_parser.return_value.generate_openlineage_metadata_from_sql
+ assert generate.call_args.kwargs["sqlalchemy_engine"] is None
+
+
def test_with_no_openlineage_provider():
import importlib
diff --git
a/providers/openlineage/src/airflow/providers/openlineage/sqlparser.py
b/providers/openlineage/src/airflow/providers/openlineage/sqlparser.py
index 8ce25f342f4..ef1e6d61b50 100644
--- a/providers/openlineage/src/airflow/providers/openlineage/sqlparser.py
+++ b/providers/openlineage/src/airflow/providers/openlineage/sqlparser.py
@@ -472,6 +472,20 @@ class SQLParser(LoggingMixin):
return hierarchy
+def get_sql_parser_for_hook(hook: DbApiHook, connection: Any, conn_id: str |
None) -> SQLParser | None:
+ get_dialect = getattr(hook, "get_openlineage_database_dialect", None)
+ get_default_schema = getattr(hook, "get_openlineage_default_schema", None)
+ if get_dialect is None or get_default_schema is None:
+ log.debug("%s has no database dialect or default schema provided",
conn_id)
+ return None
+ try:
+ return SQLParser(dialect=get_dialect(connection),
default_schema=get_default_schema())
+ except Exception as e:
+ log.warning("Failed to get OpenLineage database dialect or default
schema for %s: %s", conn_id, e)
+ log.debug("OpenLineage failure details:", exc_info=True)
+ return None
+
+
def get_openlineage_facets_with_sql(
hook: DbApiHook,
sql: str | list[str],
@@ -481,31 +495,32 @@ def get_openlineage_facets_with_sql(
*,
connection: Any | None = None,
database_info: Any | None = None,
+ sql_parser: SQLParser | None = None,
) -> OperatorLineage | None:
- # Accept pre-fetched connection and database_info to avoid redundant
hook.get_connection()
- # calls when processing multiple SQL extras from the same hook. Each
get_connection() call
+ # Accept pre-fetched connection, database_info and sql_parser to avoid
redundant lookups
+ # when processing multiple SQL extras from the same hook. Each
get_connection() call
# hits SecretsManager (miss) then the Airflow API server — passing these
in avoids that cost.
+ # A failing dialect/default schema lookup is then also reported once per
hook, not once per query.
if connection is None:
connection = hook.get_connection(conn_id)
if database_info is None:
- try:
- database_info = hook.get_openlineage_database_info(connection)
- except AttributeError:
- database_info = None
+ get_database_info = getattr(hook, "get_openlineage_database_info",
None)
+ if get_database_info is not None:
+ try:
+ database_info = get_database_info(connection)
+ except Exception as e:
+ log.warning("Failed to get OpenLineage database info for %s:
%s", conn_id, e)
+ log.debug("OpenLineage failure details:", exc_info=True)
if database_info is None:
- log.debug("%s has no database info provided", hook)
+ log.debug("%s has no database info provided", conn_id)
return None
- try:
- sql_parser = SQLParser(
- dialect=hook.get_openlineage_database_dialect(connection),
- default_schema=hook.get_openlineage_default_schema(),
- )
- except AttributeError:
- log.debug("%s failed to get database dialect", hook)
- return None
+ if sql_parser is None:
+ sql_parser = get_sql_parser_for_hook(hook, connection, conn_id)
+ if sql_parser is None:
+ return None
sqlalchemy_engine = None
if use_connection:
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 97ac22ad82a..5b395d270b6 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
@@ -47,6 +47,7 @@ from airflow.providers.openlineage.sqlparser import (
SQLParser,
from_table_meta,
get_openlineage_facets_with_sql,
+ get_sql_parser_for_hook,
)
from airflow.providers.openlineage.utils.emission_policy import DatasetFilter,
get_hook_class_name
from airflow.providers.openlineage.utils.utils import _get_parent_run_facet
@@ -141,12 +142,21 @@ def emit_lineage_from_sql_extras(
@cache
def _get_database_info(hook_id: int):
hook, conn_id = _hook_info[hook_id]
+ get_database_info = getattr(hook, "get_openlineage_database_info",
None)
+ if get_database_info is None:
+ return None
try:
- return hook.get_openlineage_database_info(_get_connection(hook_id))
+ return get_database_info(_get_connection(hook_id))
except Exception as e:
- log.debug("Failed to get OpenLineage database info for %s: %s",
conn_id, e)
+ log.warning("Failed to get OpenLineage database info for %s: %s",
conn_id, e)
+ log.debug("OpenLineage failure details:", exc_info=True)
return None
+ @cache
+ def _get_sql_parser(hook_id: int) -> SQLParser | None:
+ hook, conn_id = _hook_info[hook_id]
+ return get_sql_parser_for_hook(hook, _get_connection(hook_id), conn_id)
+
@cache
def _get_namespace(hook_id: int) -> str | None:
db_info = _get_database_info(hook_id)
@@ -170,7 +180,8 @@ def emit_lineage_from_sql_extras(
# Parse SQL to obtain lineage (inputs, outputs, facets)
query_lineage: OperatorLineage | None = None
database_info = _get_database_info(hook_id) if conn_id else None
- if sql_text and conn_id and database_info is not None:
+ sql_parser = _get_sql_parser(hook_id) if conn_id and database_info is
not None else None
+ if sql_text and conn_id and database_info is not None and sql_parser
is not None:
try:
query_lineage = get_openlineage_facets_with_sql(
hook=hook,
@@ -180,9 +191,11 @@ def emit_lineage_from_sql_extras(
use_connection=False, # Temporary solution before we
figure out timeouts for queries
connection=_get_connection(hook_id),
database_info=database_info,
+ sql_parser=sql_parser,
)
except Exception as e:
- log.debug("Failed to parse SQL for query %s: %s", query_count,
e)
+ log.warning("Failed to parse SQL for query %s on %s: %s",
query_count, conn_id, e)
+ log.debug("OpenLineage failure details:", exc_info=True)
# If parsing SQL failed, just attach SQL text as a facet
if query_lineage is None:
diff --git a/providers/openlineage/tests/unit/openlineage/test_sqlparser.py
b/providers/openlineage/tests/unit/openlineage/test_sqlparser.py
index a10cf1446ef..7716d791763 100644
--- a/providers/openlineage/tests/unit/openlineage/test_sqlparser.py
+++ b/providers/openlineage/tests/unit/openlineage/test_sqlparser.py
@@ -24,6 +24,7 @@ from openlineage.client.event_v2 import Dataset
from openlineage.client.facet_v2 import column_lineage_dataset, schema_dataset
from openlineage.common.sql import DbTableMeta
+from airflow.providers.common.sql.hooks.sql import DbApiHook
from airflow.providers.openlineage.sqlparser import (
DatabaseInfo,
GetTableSchemasParams,
@@ -414,17 +415,59 @@ class TestSQLParser:
class TestGetOpenlineageFacetsWithSql:
- def test_returns_none_when_no_database_info(self):
- hook = MagicMock()
- hook.get_openlineage_database_info.side_effect = AttributeError
+ def test_returns_none_when_hook_has_no_database_info_method(self):
+ hook = MagicMock(spec=["get_connection"])
+
+ result = get_openlineage_facets_with_sql(hook=hook, sql="SELECT 1",
conn_id="conn", database=None)
+ assert result is None
+
+ @mock.patch("airflow.providers.openlineage.sqlparser.log", autospec=True)
+ def test_error_from_database_info_is_logged_as_warning(self, mock_log):
+ hook = MagicMock(spec=DbApiHook)
+ hook.get_openlineage_database_info.side_effect =
AttributeError("broken import")
+
+ result = get_openlineage_facets_with_sql(hook=hook, sql="SELECT 1",
conn_id="conn", database=None)
+
+ assert result is None
+ mock_log.warning.assert_called_once()
+ assert mock_log.warning.call_args.args[1] == "conn"
+ assert str(mock_log.warning.call_args.args[2]) == "broken import"
+
+ @mock.patch("airflow.providers.openlineage.sqlparser.log", autospec=True)
+ def test_error_from_dialect_is_logged_as_warning(self, mock_log):
+ hook = MagicMock(spec=DbApiHook)
+ hook.get_openlineage_database_info.return_value =
DatabaseInfo(scheme="myscheme")
+ hook.get_openlineage_database_dialect.side_effect =
AttributeError("broken import")
result = get_openlineage_facets_with_sql(hook=hook, sql="SELECT 1",
conn_id="conn", database=None)
+
assert result is None
+ mock_log.warning.assert_called_once()
+ assert mock_log.warning.call_args.args[1] == "conn"
+ assert str(mock_log.warning.call_args.args[2]) == "broken import"
+
+ def test_prefetched_sql_parser_skips_dialect_lookup(self):
+ hook = MagicMock(spec=DbApiHook)
+ sql_parser = MagicMock(spec=SQLParser)
+
+ result = get_openlineage_facets_with_sql(
+ hook=hook,
+ sql="SELECT 1",
+ conn_id="conn",
+ database=None,
+ use_connection=False,
+ connection=MagicMock(),
+ database_info=DatabaseInfo(scheme="myscheme"),
+ sql_parser=sql_parser,
+ )
+
+ hook.get_openlineage_database_dialect.assert_not_called()
+ sql_parser.generate_openlineage_metadata_from_sql.assert_called_once()
+ assert result is
sql_parser.generate_openlineage_metadata_from_sql.return_value
def test_returns_none_when_no_dialect(self):
- hook = MagicMock()
+ hook = MagicMock(spec=["get_connection",
"get_openlineage_database_info"])
hook.get_openlineage_database_info.return_value =
DatabaseInfo(scheme="myscheme")
- hook.get_openlineage_database_dialect.side_effect = AttributeError
result = get_openlineage_facets_with_sql(hook=hook, sql="SELECT 1",
conn_id="conn", database=None)
assert result is None
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 31f8a7da56f..b436b8b3f3b 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
@@ -25,6 +25,7 @@ from openlineage.client.event_v2 import Dataset as
OpenLineageDataset
from openlineage.client.facet_v2 import external_query_run, sql_job
from airflow.providers.common.sql.hooks.lineage import SqlJobHookLineageExtra
+from airflow.providers.common.sql.hooks.sql import DbApiHook
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
@@ -76,12 +77,14 @@ class TestEmitLineageFromSqlExtras:
mock.patch(f"{_MODULE}._get_hook_conn_id", return_value="my_conn")
as mock_conn_id,
mock.patch(f"{_MODULE}.SQLParser.create_namespace") as mock_ns,
mock.patch(f"{_MODULE}.get_openlineage_facets_with_sql") as
mock_facets_fn,
+ mock.patch(f"{_MODULE}.get_sql_parser_for_hook") as
mock_get_parser,
mock.patch(f"{_MODULE}._create_ol_event_pair") as mock_build,
mock.patch(f"{_MODULE}.get_openlineage_listener") as mock_listener,
):
self.mock_conn_id = mock_conn_id
self.mock_ns = mock_ns
self.mock_facets_fn = mock_facets_fn
+ self.mock_get_parser = mock_get_parser
self.mock_build = mock_build
self.mock_listener = mock_listener
mock_build.return_value = (mock.sentinel.start_event,
mock.sentinel.end_event)
@@ -221,6 +224,87 @@ class TestEmitLineageFromSqlExtras:
assert call.kwargs["job_facets"]["sql"].query ==
SQLParser.normalize_sql("SELECT broken(")
assert call.kwargs["run_facets"]["externalQuery"].externalQueryId ==
"qid-1"
+ @mock.patch(f"{_MODULE}.log", autospec=True)
+ def test_sql_parsing_failure_is_logged_as_warning(self, mock_log):
+ self.mock_ns.return_value = "ns"
+ self.mock_facets_fn.side_effect = Exception("parse error")
+ mock_ti = mock.MagicMock(dag_id="dag_id", task_id="task_id")
+
+ emit_lineage_from_sql_extras(
+ task_instance=mock_ti,
+ sql_extras=[_make_extra(sql="SELECT broken(", job_id="qid-1")],
+ )
+
+ mock_log.warning.assert_called_once()
+ assert mock_log.warning.call_args.args[1:] == (1, "my_conn",
self.mock_facets_fn.side_effect)
+
+ @mock.patch(f"{_MODULE}.log", autospec=True)
+ def
test_database_info_failure_is_logged_as_warning_and_event_still_emitted(self,
mock_log):
+ hook = mock.MagicMock(spec=DbApiHook)
+ hook.get_openlineage_database_info.side_effect =
AttributeError("broken import")
+ self.mock_ns.return_value = "ns"
+ mock_ti = mock.MagicMock(dag_id="dag_id", task_id="task_id")
+
+ emit_lineage_from_sql_extras(
+ task_instance=mock_ti,
+ sql_extras=[_make_extra(sql="SELECT 1", job_id="qid-1",
hook=hook)],
+ )
+
+ mock_log.warning.assert_called_once()
+ assert mock_log.warning.call_args.args[1] == "my_conn"
+ assert str(mock_log.warning.call_args.args[2]) == "broken import"
+ # Without database info SQL is not parsed, but the query event is
still built and emitted
+ # with just the SQL facet (and no externalQuery facet, as namespace is
unknown).
+ self.mock_facets_fn.assert_not_called()
+ self.mock_build.assert_called_once()
+ call = self.mock_build.call_args
+ assert call.kwargs["inputs"] == []
+ assert call.kwargs["outputs"] == []
+ assert call.kwargs["job_facets"]["sql"].query ==
SQLParser.normalize_sql("SELECT 1")
+ assert "externalQuery" not in call.kwargs["run_facets"]
+
+ def test_sql_parser_is_built_once_per_hook(self):
+ hook = mock.MagicMock(spec=DbApiHook)
+ self.mock_ns.return_value = "ns"
+ mock_ti = mock.MagicMock(dag_id="dag_id", task_id="task_id")
+
+ emit_lineage_from_sql_extras(
+ task_instance=mock_ti,
+ sql_extras=[_make_extra(sql=f"SELECT {i}", hook=hook) for i in
range(3)],
+ )
+
+ self.mock_get_parser.assert_called_once()
+ assert self.mock_facets_fn.call_count == 3
+ for call in self.mock_facets_fn.call_args_list:
+ assert call.kwargs["sql_parser"] is
self.mock_get_parser.return_value
+
+ def test_sql_parsing_is_skipped_when_sql_parser_is_unavailable(self):
+ self.mock_get_parser.return_value = None
+ self.mock_ns.return_value = "ns"
+ mock_ti = mock.MagicMock(dag_id="dag_id", task_id="task_id")
+
+ emit_lineage_from_sql_extras(
+ task_instance=mock_ti,
+ sql_extras=[_make_extra(sql="SELECT 1"), _make_extra(sql="SELECT
2")],
+ )
+
+ self.mock_facets_fn.assert_not_called()
+ assert self.mock_build.call_count == 2
+
+ @mock.patch(f"{_MODULE}.log", autospec=True)
+ def test_hook_without_database_info_method_is_not_a_failure(self,
mock_log):
+ hook = mock.MagicMock(spec=["get_connection"])
+ self.mock_ns.return_value = None
+ mock_ti = mock.MagicMock(dag_id="dag_id", task_id="task_id")
+
+ emit_lineage_from_sql_extras(
+ task_instance=mock_ti,
+ sql_extras=[_make_extra(sql="SELECT 1", job_id="qid-1",
hook=hook)],
+ )
+
+ mock_log.warning.assert_not_called()
+ self.mock_build.assert_called_once()
+
def test_no_namespace_skips_external_query_facet(self):
"""When namespace cannot be resolved, no externalQuery facet is
added."""
self.mock_ns.return_value = None