Skip to content

Commit 9b8e2b9

Browse files
authored
fix(ingest/oracle): make thick_mode_lib_dir work on Linux via ctypes preload (#17265)
1 parent cce1caa commit 9b8e2b9

2 files changed

Lines changed: 216 additions & 7 deletions

File tree

metadata-ingestion/src/datahub/ingestion/source/sql/oracle.py

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import ctypes
12
import datetime
3+
import glob
24
import logging
35
import os
46
import platform
@@ -29,7 +31,7 @@
2931
from sqlalchemy.types import FLOAT, INTEGER, TIMESTAMP
3032

3133
import datahub.metadata.schema_classes as models
32-
from datahub.configuration.common import AllowDenyPattern
34+
from datahub.configuration.common import AllowDenyPattern, ConfigurationError
3335
from datahub.emitter.mce_builder import (
3436
DEFAULT_ENV,
3537
make_data_job_urn,
@@ -378,6 +380,70 @@ def normalize_db_name(name: str) -> str:
378380
"""
379381

380382

383+
# Oracle Instant Client shared libs in the order they should be preloaded.
384+
# libclntsh is last because it has DT_NEEDED entries on the others; loading
385+
# the deps first by absolute path puts them in the process namespace by SONAME
386+
# so the linker reuses them when libclntsh is opened.
387+
_ORACLE_PRELOAD_PATTERNS = (
388+
"libnnz*.so*",
389+
"libclntshcore.so*",
390+
"libons.so*",
391+
"libipc1.so*",
392+
"libmql1.so*",
393+
"libociei.so*",
394+
"libclntsh.so*",
395+
)
396+
397+
398+
def _preload_oracle_client_libs(lib_dir: str) -> None:
399+
"""Preload Oracle Instant Client libs from ``lib_dir`` so that
400+
``oracledb.init_oracle_client()`` succeeds on Linux without needing
401+
``LD_LIBRARY_PATH`` or ``ldconfig`` to be configured.
402+
403+
Background: on Linux, Oracle ships ``libclntsh.so`` without
404+
``RUNPATH=$ORIGIN``. Even when python-oracledb / ODPI-C dlopens
405+
``libclntsh.so`` via an absolute path (which is what ``lib_dir`` does),
406+
the dynamic linker still has to resolve its DT_NEEDED dependencies
407+
(``libnnz*.so``, ``libclntshcore.so``, ``libons.so``, ...) through the
408+
normal ``LD_LIBRARY_PATH`` / ``ld.so.cache`` rules. With neither
409+
configured, the load fails with DPI-1047.
410+
411+
Setting ``LD_LIBRARY_PATH`` from Python doesn't help: glibc's loader
412+
reads it once at process startup. Loading each ``.so`` by absolute path
413+
with ``RTLD_GLOBAL`` does work — once an object is mapped, the linker
414+
looks it up by SONAME for subsequent ``dlopen()`` calls and finds it.
415+
416+
See https://github.com/oracle/python-oracledb/issues/578 for the upstream
417+
discussion confirming this can only be fixed by preloading from the client
418+
side or by patching ``RUNPATH=$ORIGIN`` into ``libclntsh.so`` itself.
419+
"""
420+
if not os.path.isdir(lib_dir):
421+
raise ConfigurationError(
422+
f"thick_mode_lib_dir={lib_dir!r} does not exist or is not a directory"
423+
)
424+
425+
loaded_any = False
426+
for pattern in _ORACLE_PRELOAD_PATTERNS:
427+
for path in sorted(glob.glob(os.path.join(lib_dir, pattern))):
428+
try:
429+
ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL)
430+
loaded_any = True
431+
logger.debug("Preloaded Oracle client lib: %s", path)
432+
except OSError as e:
433+
# Non-fatal: a missing satellite lib (e.g. libipc1 in older
434+
# client releases) is fine as long as libclntsh and its actual
435+
# deps load. Keep going so we surface a useful error from
436+
# init_oracle_client() if anything critical is missing.
437+
logger.debug("Skipping %s while preloading: %s", path, e)
438+
439+
if not loaded_any:
440+
raise ConfigurationError(
441+
f"No Oracle Instant Client libraries found in {lib_dir!r}. "
442+
"Verify the path points to an unpacked Instant Client (it should "
443+
"contain libclntsh.so* and libnnz*.so*)."
444+
)
445+
446+
381447
def _setup_oracle_compatibility() -> None:
382448
"""
383449
Set up Oracle compatibility for SQLAlchemy.
@@ -466,8 +532,13 @@ class OracleConfig(BasicSQLAlchemyConfig, BaseUsageConfig):
466532
)
467533
thick_mode_lib_dir: Optional[str] = Field(
468534
default=None,
469-
description="If using thick mode on Windows or Mac, set thick_mode_lib_dir to the oracle client libraries path. "
470-
"On Linux, this value is ignored, as ldconfig or LD_LIBRARY_PATH will define the location.",
535+
description="Path to the directory containing the Oracle Instant Client libraries. "
536+
"Required on Windows and Mac when enable_thick_mode is true. "
537+
"Optional on Linux: when set, the connector preloads the client libraries "
538+
"from this directory before initializing python-oracledb, which makes "
539+
"thick mode work without needing ldconfig or LD_LIBRARY_PATH to be set "
540+
"(see https://github.com/oracle/python-oracledb/issues/578). When unset "
541+
"on Linux, the standard ldconfig / LD_LIBRARY_PATH search is used.",
471542
)
472543
# Stored procedures configuration
473544
include_stored_procedures: bool = Field(
@@ -1297,11 +1368,21 @@ def __init__(self, config, ctx):
12971368
# create_engine, which is called in get_inspectors()
12981369
# https://python-oracledb.readthedocs.io/en/latest/user_guide/initialization.html#enabling-python-oracledb-thick-mode
12991370
if self.config.enable_thick_mode:
1300-
if platform.system() == "Darwin" or platform.system() == "Windows":
1301-
# windows and mac os require lib_dir to be set explicitly
1371+
if platform.system() in ("Darwin", "Windows"):
1372+
# Mac/Windows: lib_dir is required and is enough; the platform's
1373+
# loader handles the dependent libs.
13021374
oracledb.init_oracle_client(lib_dir=self.config.thick_mode_lib_dir)
1375+
elif self.config.thick_mode_lib_dir:
1376+
# Linux: passing lib_dir to init_oracle_client() locates
1377+
# libclntsh.so itself but the loader still falls back to
1378+
# LD_LIBRARY_PATH / ld.so.cache for its DT_NEEDED deps, which
1379+
# fails on hosts that don't have ldconfig set up. Preload every
1380+
# .so in lib_dir by absolute path with RTLD_GLOBAL so the deps
1381+
# are resolved by SONAME from the process namespace.
1382+
_preload_oracle_client_libs(self.config.thick_mode_lib_dir)
1383+
oracledb.init_oracle_client()
13031384
else:
1304-
# linux requires configurating the library path with ldconfig or LD_LIBRARY_PATH
1385+
# Linux without thick_mode_lib_dir: rely on ldconfig / LD_LIBRARY_PATH.
13051386
oracledb.init_oracle_client()
13061387

13071388
# Pre-fetch schemas from DataHub when not ingesting all tables/views so that

metadata-ingestion/tests/unit/test_oracle_source.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import os
12
import unittest.mock
23
from datetime import datetime
4+
from typing import List, Optional
35
from unittest.mock import MagicMock, Mock, patch
46

57
import pytest
@@ -9,7 +11,7 @@
911
from sqlalchemy.engine import Inspector
1012
from sqlalchemy.sql import sqltypes
1113

12-
from datahub.configuration.common import AllowDenyPattern
14+
from datahub.configuration.common import AllowDenyPattern, ConfigurationError
1315
from datahub.ingestion.api.common import PipelineContext
1416
from datahub.ingestion.source.sql.oracle import (
1517
VSQL_USAGE_QUERY,
@@ -19,6 +21,7 @@
1921
OracleSource,
2022
ProcedureDependencies,
2123
VSqlPrerequisiteCheckResult,
24+
_preload_oracle_client_libs,
2225
extra_oracle_types,
2326
)
2427
from datahub.ingestion.source.sql.sql_report import SQLSourceReport
@@ -1423,3 +1426,128 @@ def mock_parent_workunits():
14231426
list(source.get_workunits())
14241427

14251428
assert all(registered_during_iteration)
1429+
1430+
1431+
def _make_thick_mode_config(thick_mode_lib_dir: Optional[str] = None) -> OracleConfig:
1432+
return OracleConfig(
1433+
username="user",
1434+
password="password",
1435+
host_port="host:1521",
1436+
service_name="svc01",
1437+
enable_thick_mode=True,
1438+
thick_mode_lib_dir=thick_mode_lib_dir,
1439+
)
1440+
1441+
1442+
def test_preload_oracle_client_libs_loads_in_dependency_order(tmp_path):
1443+
"""Deps must be opened before libclntsh; missing optional libs are skipped silently."""
1444+
# Create stub files for some but not all of the patterns the helper looks for.
1445+
# libipc1 / libmql1 / libociei are intentionally absent — they are optional in
1446+
# newer Instant Client releases and the helper must tolerate that.
1447+
files = [
1448+
"libnnz12.so",
1449+
"libclntshcore.so.21.1",
1450+
"libons.so",
1451+
"libclntsh.so.21.1",
1452+
]
1453+
for name in files:
1454+
(tmp_path / name).write_bytes(b"")
1455+
1456+
loaded: List[str] = []
1457+
1458+
class _FakeCDLL:
1459+
def __init__(self, path: str, mode: int) -> None:
1460+
loaded.append(os.path.basename(path))
1461+
1462+
with patch("datahub.ingestion.source.sql.oracle.ctypes.CDLL", _FakeCDLL):
1463+
_preload_oracle_client_libs(str(tmp_path))
1464+
1465+
# Dependencies before libclntsh — that's the whole point of the helper.
1466+
assert loaded[-1] == "libclntsh.so.21.1"
1467+
assert "libnnz12.so" in loaded
1468+
assert "libclntshcore.so.21.1" in loaded
1469+
assert "libons.so" in loaded
1470+
# nnz must precede libclntsh; libclntshcore likewise.
1471+
assert loaded.index("libnnz12.so") < loaded.index("libclntsh.so.21.1")
1472+
assert loaded.index("libclntshcore.so.21.1") < loaded.index("libclntsh.so.21.1")
1473+
1474+
1475+
def test_preload_oracle_client_libs_raises_when_dir_missing(tmp_path):
1476+
bogus = tmp_path / "does-not-exist"
1477+
1478+
with pytest.raises(ConfigurationError, match="does not exist"):
1479+
_preload_oracle_client_libs(str(bogus))
1480+
1481+
1482+
def test_preload_oracle_client_libs_raises_when_dir_empty(tmp_path):
1483+
# Directory exists but contains no Oracle libs — surfaces a clearer error
1484+
# than the cryptic DPI-1047 we'd otherwise get downstream.
1485+
with pytest.raises(ConfigurationError, match="No Oracle Instant Client libraries"):
1486+
_preload_oracle_client_libs(str(tmp_path))
1487+
1488+
1489+
def test_preload_oracle_client_libs_skips_individual_load_failures(tmp_path):
1490+
"""One bad .so should not abort the whole preload — log and continue."""
1491+
for name in ["libnnz12.so", "libclntsh.so.21.1"]:
1492+
(tmp_path / name).write_bytes(b"")
1493+
1494+
attempts: List[str] = []
1495+
1496+
class _FlakyCDLL:
1497+
def __init__(self, path: str, mode: int) -> None:
1498+
attempts.append(os.path.basename(path))
1499+
if path.endswith("libnnz12.so"):
1500+
raise OSError("synthetic load failure")
1501+
1502+
with patch("datahub.ingestion.source.sql.oracle.ctypes.CDLL", _FlakyCDLL):
1503+
_preload_oracle_client_libs(str(tmp_path))
1504+
1505+
assert "libnnz12.so" in attempts
1506+
assert "libclntsh.so.21.1" in attempts
1507+
1508+
1509+
@patch("datahub.ingestion.source.sql.oracle.platform.system", return_value="Linux")
1510+
@patch("datahub.ingestion.source.sql.oracle._preload_oracle_client_libs")
1511+
@patch("datahub.ingestion.source.sql.oracle.oracledb")
1512+
def test_oracle_source_linux_preloads_when_lib_dir_set(
1513+
mock_oracledb, mock_preload, _mock_platform, tmp_path
1514+
):
1515+
config = _make_thick_mode_config(thick_mode_lib_dir=str(tmp_path))
1516+
1517+
OracleSource(config, PipelineContext("test-thick-linux-preload"))
1518+
1519+
mock_preload.assert_called_once_with(str(tmp_path))
1520+
# On Linux we must NOT pass lib_dir to init_oracle_client(): the preload
1521+
# already put the libs in the process namespace, and passing lib_dir is
1522+
# what triggers the DT_NEEDED resolution failure described in oracle/python-oracledb#578.
1523+
mock_oracledb.init_oracle_client.assert_called_once_with()
1524+
1525+
1526+
@patch("datahub.ingestion.source.sql.oracle.platform.system", return_value="Linux")
1527+
@patch("datahub.ingestion.source.sql.oracle._preload_oracle_client_libs")
1528+
@patch("datahub.ingestion.source.sql.oracle.oracledb")
1529+
def test_oracle_source_linux_skips_preload_when_lib_dir_unset(
1530+
mock_oracledb, mock_preload, _mock_platform
1531+
):
1532+
config = _make_thick_mode_config(thick_mode_lib_dir=None)
1533+
1534+
OracleSource(config, PipelineContext("test-thick-linux-no-preload"))
1535+
1536+
mock_preload.assert_not_called()
1537+
mock_oracledb.init_oracle_client.assert_called_once_with()
1538+
1539+
1540+
@patch("datahub.ingestion.source.sql.oracle.platform.system", return_value="Darwin")
1541+
@patch("datahub.ingestion.source.sql.oracle._preload_oracle_client_libs")
1542+
@patch("datahub.ingestion.source.sql.oracle.oracledb")
1543+
def test_oracle_source_mac_passes_lib_dir_without_preload(
1544+
mock_oracledb, mock_preload, _mock_platform, tmp_path
1545+
):
1546+
config = _make_thick_mode_config(thick_mode_lib_dir=str(tmp_path))
1547+
1548+
OracleSource(config, PipelineContext("test-thick-mac"))
1549+
1550+
# macOS uses dyld, not glibc's loader, so no preload trick is needed —
1551+
# init_oracle_client(lib_dir=...) is sufficient.
1552+
mock_preload.assert_not_called()
1553+
mock_oracledb.init_oracle_client.assert_called_once_with(lib_dir=str(tmp_path))

0 commit comments

Comments
 (0)