From 2e3274dc8206d6609dc0ff72f1586657a4d9659b Mon Sep 17 00:00:00 2001 From: "Wei-Chun, Chang" Date: Wed, 23 Sep 2026 13:28:07 +0800 Subject: [PATCH 1/7] fix(adapter): name dbt v2 as the cause when the dbt import fails Recce OSS cannot run on dbt v2 (Fusion), because dbt v2 drops dbt.adapters. The import failure told users that dbt was not installed. Recce now detects dbt v2 and says that it is not supported yet. The server startup log now shows any RecceException as one line, with the traceback in the debug log. The schema guard message no longer blames dbt v2 for a manifest above v12, because dbt v2 writes manifest v12 too. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Wei-Chun, Chang --- recce/adapter/dbt_adapter/__init__.py | 25 ++++++++------ recce/exceptions.py | 10 +++--- recce/server.py | 6 +++- tests/test_dbt.py | 49 +++++++++++++-------------- 4 files changed, 47 insertions(+), 43 deletions(-) diff --git a/recce/adapter/dbt_adapter/__init__.py b/recce/adapter/dbt_adapter/__init__.py index 3314c8a85..80b4da845 100644 --- a/recce/adapter/dbt_adapter/__init__.py +++ b/recce/adapter/dbt_adapter/__init__.py @@ -50,9 +50,18 @@ import dbt.adapters.factory from dbt.contracts.state import PreviousState except ImportError as e: - print("Error: dbt module not found. Please install it by running:") - print("pip install dbt-core dbt-") - raise e + import importlib.util + + try: + # dbt v2 (Fusion) has a dbt._core module and no dbt.adapters module. + is_dbt_v2 = importlib.util.find_spec("dbt._core") is not None + except ImportError: + is_dbt_v2 = False + if is_dbt_v2: + raise RecceException("Recce supports dbt-core 1.x. dbt v2 (Fusion) is not supported yet.") from e + raise RecceException( + "dbt module not found. Please install it by running:\n pip install dbt-core dbt-" + ) from e try: from dbt.artifacts.exceptions import IncompatibleSchemaError @@ -237,17 +246,13 @@ def as_manifest(m: WritableManifest) -> Manifest: return result -# Highest schema versions dbt 1.x ever emits; dbt v2 / Fusion jumps straight to -# v20, so anything above these is a Fusion artifact. Do not tie the ceiling to -# what the installed dbt is compatible with: under dbt 1.6 a v12 manifest is -# already incompatible, but it comes from dbt 1.x and should surface dbt's own -# version-mismatch error, not be reported as Fusion. +# dbt-core 1.x and dbt v2 both write manifest v12 and catalog v1. Do not use the +# versions that the installed dbt accepts: dbt 1.6 rejects a v12 manifest, and +# that case must raise dbt's own version-mismatch error. _DBT1X_MAX_SCHEMA = {"manifest": 12, "catalog": 1} def _guard_unsupported_schema(artifact: str, found_version_url): - """Raise UnsupportedDbtSchemaError if `found_version_url` is a dbt v2 / Fusion - schema (above the dbt 1.x ceiling); no-op otherwise.""" # dbt_schema_version looks like "https://schemas.getdbt.com/dbt/manifest/v12.json" match = re.search(r"/v(\d+)\.json", str(found_version_url)) found = int(match.group(1)) if match else None diff --git a/recce/exceptions.py b/recce/exceptions.py index c953d6400..ad3525c30 100644 --- a/recce/exceptions.py +++ b/recce/exceptions.py @@ -22,15 +22,13 @@ def __str__(self): class UnsupportedDbtSchemaError(RecceException): - """Raised when a dbt artifact's schema is newer than the bundled dbt 1.x - supports, i.e. a dbt v2 / Fusion artifact. See _DBT1X_MAX_SCHEMA in the dbt - adapter for the version ceiling.""" + """The version limits are in _DBT1X_MAX_SCHEMA in the dbt adapter.""" def __init__(self, artifact: str, found_version: int): message = ( - f"dbt v2 / Fusion {artifact}s (schema v{found_version}) are not yet " - f"supported by Recce. Recce supports dbt 1.x artifacts (manifest schema up " - f"to v12, catalog v1). Re-generate the {artifact} with dbt 1.x." + f"The {artifact} uses schema v{found_version}, which is newer than Recce " + f"supports (manifest up to v12, catalog v1). Re-generate the {artifact} " + f"with dbt-core 1.x." ) super().__init__(message, is_raise=True) diff --git a/recce/server.py b/recce/server.py index b97d98540..3213ca976 100644 --- a/recce/server.py +++ b/recce/server.py @@ -295,7 +295,11 @@ async def background_load(): tracker.node_count = len(recce_ctx.adapter.curr_manifest.nodes) log_performance("server_startup", tracker.to_dict()) except Exception as e: - logger.exception("Failed to load server context during startup") + if isinstance(e, RecceException): + logger.debug("Failed to load server context during startup", exc_info=True) + logger.error(f"Failed to load server context during startup: {e}") + else: + logger.exception("Failed to load server context during startup") app_state.startup_error = e finally: clear_startup_tracker() diff --git a/tests/test_dbt.py b/tests/test_dbt.py index d5cb01088..fcdaa13f0 100644 --- a/tests/test_dbt.py +++ b/tests/test_dbt.py @@ -12,9 +12,12 @@ current_dir = os.path.dirname(os.path.abspath(__file__)) -def _fusion_artifact(kind: str) -> dict: +_ABOVE_CEILING = {"manifest": 13, "catalog": 2} + + +def _newer_artifact(kind: str) -> dict: return { - "metadata": {"dbt_schema_version": f"https://schemas.getdbt.com/dbt/{kind}/v20.json"}, + "metadata": {"dbt_schema_version": f"https://schemas.getdbt.com/dbt/{kind}/v{_ABOVE_CEILING[kind]}.json"}, } @@ -47,58 +50,52 @@ def test_load_lineage_with_catalog(self): assert len(lineage["nodes"]["model.jaffle_shop.orders"]["columns"]) == 9 -class TestFusionManifestFailLoud(TestCase): - """A v20 (dbt v2 / Fusion) artifact must fail loud with a Recce-branded message.""" - - def _assert_friendly(self, exc_info): +class TestNewerSchemaFailLoud(TestCase): + def _assert_friendly(self, exc_info, kind): msg = str(exc_info.value) - assert "Fusion" in msg - assert "v20" in msg - assert "not yet supported" in msg + assert f"v{_ABOVE_CEILING[kind]}" in msg + assert "newer than Recce supports" in msg - def test_load_manifest_v20_data_fails_loud(self): + def test_load_manifest_newer_data_fails_loud(self): with pytest.raises(UnsupportedDbtSchemaError) as exc_info: - load_manifest(data=_fusion_artifact("manifest")) - self._assert_friendly(exc_info) + load_manifest(data=_newer_artifact("manifest")) + self._assert_friendly(exc_info, "manifest") - def test_load_manifest_v20_path_fails_loud(self): + def test_load_manifest_newer_path_fails_loud(self): with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: - json.dump(_fusion_artifact("manifest"), f) + json.dump(_newer_artifact("manifest"), f) path = f.name try: with pytest.raises(UnsupportedDbtSchemaError) as exc_info: load_manifest(path=path) - self._assert_friendly(exc_info) + self._assert_friendly(exc_info, "manifest") finally: os.unlink(path) - def test_load_catalog_v20_data_fails_loud(self): + def test_load_catalog_newer_data_fails_loud(self): with pytest.raises(UnsupportedDbtSchemaError) as exc_info: - load_catalog(data=_fusion_artifact("catalog")) - self._assert_friendly(exc_info) + load_catalog(data=_newer_artifact("catalog")) + self._assert_friendly(exc_info, "catalog") - def test_load_catalog_v20_path_fails_loud(self): + def test_load_catalog_newer_path_fails_loud(self): with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: - json.dump(_fusion_artifact("catalog"), f) + json.dump(_newer_artifact("catalog"), f) path = f.name try: with pytest.raises(UnsupportedDbtSchemaError) as exc_info: load_catalog(path=path) - self._assert_friendly(exc_info) + self._assert_friendly(exc_info, "catalog") finally: os.unlink(path) - def test_v12_not_flagged_as_fusion(self): - # A v12 manifest / v1 catalog is dbt 1.x, not Fusion — the guard must not fire, - # regardless of which dbt version Recce is running against. + def test_supported_schema_not_flagged(self): + # The result must not depend on the installed dbt version. from recce.adapter.dbt_adapter import _guard_unsupported_schema _guard_unsupported_schema("manifest", "https://schemas.getdbt.com/dbt/manifest/v12.json") _guard_unsupported_schema("catalog", "https://schemas.getdbt.com/dbt/catalog/v1.json") def test_old_incompatible_artifact_keeps_dbt_error(self): - # v1 / v0 sit below the 1.x ceiling but dbt still rejects them as too old — - # the guard must stay silent, not mislabel them as Fusion. from recce.adapter.dbt_adapter import IncompatibleSchemaError for loader, kind, version in [(load_manifest, "manifest", 1), (load_catalog, "catalog", 0)]: From fb439362d4561bc04daac52a6cf8e2591b8875be Mon Sep 17 00:00:00 2001 From: "Wei-Chun, Chang" Date: Wed, 23 Sep 2026 13:45:09 +0800 Subject: [PATCH 2/7] docs: clarify the schema limit comment and drop a test comment The comment above _DBT1X_MAX_SCHEMA did not say what must not depend on the installed dbt. It now names the limits and the error that an old dbt must raise. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Wei-Chun, Chang --- recce/adapter/dbt_adapter/__init__.py | 7 ++++--- tests/test_dbt.py | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/recce/adapter/dbt_adapter/__init__.py b/recce/adapter/dbt_adapter/__init__.py index 80b4da845..bdaea3fa9 100644 --- a/recce/adapter/dbt_adapter/__init__.py +++ b/recce/adapter/dbt_adapter/__init__.py @@ -246,9 +246,10 @@ def as_manifest(m: WritableManifest) -> Manifest: return result -# dbt-core 1.x and dbt v2 both write manifest v12 and catalog v1. Do not use the -# versions that the installed dbt accepts: dbt 1.6 rejects a v12 manifest, and -# that case must raise dbt's own version-mismatch error. +# dbt-core 1.x and dbt v2 both write manifest v12 and catalog v1. +# Keep these limits fixed. Do not set them from the installed dbt: an old dbt, +# such as dbt 1.7, cannot read a v12 manifest, and that case must raise dbt's +# own IncompatibleSchemaError, not UnsupportedDbtSchemaError. _DBT1X_MAX_SCHEMA = {"manifest": 12, "catalog": 1} diff --git a/tests/test_dbt.py b/tests/test_dbt.py index fcdaa13f0..a7f9e8957 100644 --- a/tests/test_dbt.py +++ b/tests/test_dbt.py @@ -89,7 +89,6 @@ def test_load_catalog_newer_path_fails_loud(self): os.unlink(path) def test_supported_schema_not_flagged(self): - # The result must not depend on the installed dbt version. from recce.adapter.dbt_adapter import _guard_unsupported_schema _guard_unsupported_schema("manifest", "https://schemas.getdbt.com/dbt/manifest/v12.json") From e76d8f2715109cfc6a8cf116ef2d0a80e3febd93 Mon Sep 17 00:00:00 2001 From: "Wei-Chun, Chang" Date: Wed, 23 Sep 2026 16:19:44 +0800 Subject: [PATCH 3/7] fix(server): keep the traceback for startup errors other than dbt import The server startup log dropped the traceback for every RecceException, including Recce Cloud state download failures, where the traceback helps find the cause. DbtUnavailableError now marks the dbt import failure, and only that error logs as one line. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Wei-Chun, Chang --- recce/adapter/dbt_adapter/__init__.py | 5 +++-- recce/exceptions.py | 4 ++++ recce/server.py | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/recce/adapter/dbt_adapter/__init__.py b/recce/adapter/dbt_adapter/__init__.py index bdaea3fa9..f6385bfe7 100644 --- a/recce/adapter/dbt_adapter/__init__.py +++ b/recce/adapter/dbt_adapter/__init__.py @@ -27,6 +27,7 @@ from recce.event import log_performance from recce.exceptions import ( + DbtUnavailableError, DuckDBExternalAccessBlocked, RecceException, UnsupportedDbtSchemaError, @@ -58,8 +59,8 @@ except ImportError: is_dbt_v2 = False if is_dbt_v2: - raise RecceException("Recce supports dbt-core 1.x. dbt v2 (Fusion) is not supported yet.") from e - raise RecceException( + raise DbtUnavailableError("Recce supports dbt-core 1.x. dbt v2 (Fusion) is not supported yet.") from e + raise DbtUnavailableError( "dbt module not found. Please install it by running:\n pip install dbt-core dbt-" ) from e diff --git a/recce/exceptions.py b/recce/exceptions.py index ad3525c30..539881763 100644 --- a/recce/exceptions.py +++ b/recce/exceptions.py @@ -21,6 +21,10 @@ def __str__(self): return super().__str__() +class DbtUnavailableError(RecceException): + pass + + class UnsupportedDbtSchemaError(RecceException): """The version limits are in _DBT1X_MAX_SCHEMA in the dbt adapter.""" diff --git a/recce/server.py b/recce/server.py index 3213ca976..a35a7975a 100644 --- a/recce/server.py +++ b/recce/server.py @@ -46,7 +46,7 @@ ) from .core import RecceContext, default_context, load_context from .event import get_recce_api_token, log_api_event, log_single_env_event -from .exceptions import RecceException +from .exceptions import DbtUnavailableError, RecceException from .github import is_github_codespace from .models.types import CllData from .models.websocket import CloudUserContextMessage @@ -295,7 +295,7 @@ async def background_load(): tracker.node_count = len(recce_ctx.adapter.curr_manifest.nodes) log_performance("server_startup", tracker.to_dict()) except Exception as e: - if isinstance(e, RecceException): + if isinstance(e, DbtUnavailableError): logger.debug("Failed to load server context during startup", exc_info=True) logger.error(f"Failed to load server context during startup: {e}") else: From eb1ce953728ce4a8f0c54942f3ca132dcbc4a7dc Mon Sep 17 00:00:00 2001 From: "Wei-Chun, Chang" Date: Wed, 23 Sep 2026 17:13:16 +0800 Subject: [PATCH 4/7] fix(server): keep dbt import failures out of Sentry Sentry tracks bugs, and a dbt import failure comes from an unsupported setup. The server now logs it as a warning, which the Sentry logging integration does not send as an event. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Wei-Chun, Chang --- recce/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recce/server.py b/recce/server.py index a35a7975a..716ef6c6d 100644 --- a/recce/server.py +++ b/recce/server.py @@ -297,7 +297,7 @@ async def background_load(): except Exception as e: if isinstance(e, DbtUnavailableError): logger.debug("Failed to load server context during startup", exc_info=True) - logger.error(f"Failed to load server context during startup: {e}") + logger.warning(f"Failed to load server context during startup: {e}") else: logger.exception("Failed to load server context during startup") app_state.startup_error = e From 363ec165fcec000719a26b863b9fa39378d451f8 Mon Sep 17 00:00:00 2001 From: "Wei-Chun, Chang" Date: Thu, 24 Sep 2026 10:27:03 +0800 Subject: [PATCH 5/7] fix(adapter): keep the original error for other dbt import failures The dbt import handler turned every ImportError into "dbt module not found". A broken dependency, such as a dbt-core and agate version mismatch, lost its real cause and its Sentry report. Only a missing dbt and dbt v2 now become DbtUnavailableError. Any other import failure is raised unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Wei-Chun, Chang --- recce/adapter/dbt_adapter/__init__.py | 16 ++++++------- tests/test_dbt.py | 33 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/recce/adapter/dbt_adapter/__init__.py b/recce/adapter/dbt_adapter/__init__.py index f6385bfe7..1b4e7c765 100644 --- a/recce/adapter/dbt_adapter/__init__.py +++ b/recce/adapter/dbt_adapter/__init__.py @@ -53,16 +53,14 @@ except ImportError as e: import importlib.util - try: - # dbt v2 (Fusion) has a dbt._core module and no dbt.adapters module. - is_dbt_v2 = importlib.util.find_spec("dbt._core") is not None - except ImportError: - is_dbt_v2 = False - if is_dbt_v2: + if importlib.util.find_spec("dbt") is None: + raise DbtUnavailableError( + "dbt module not found. Please install it by running:\n pip install dbt-core dbt-" + ) from e + # dbt v2 (Fusion) has a dbt._core module and no dbt.adapters module. + if importlib.util.find_spec("dbt._core") is not None: raise DbtUnavailableError("Recce supports dbt-core 1.x. dbt v2 (Fusion) is not supported yet.") from e - raise DbtUnavailableError( - "dbt module not found. Please install it by running:\n pip install dbt-core dbt-" - ) from e + raise try: from dbt.artifacts.exceptions import IncompatibleSchemaError diff --git a/tests/test_dbt.py b/tests/test_dbt.py index a7f9e8957..bab6b80d0 100644 --- a/tests/test_dbt.py +++ b/tests/test_dbt.py @@ -1,5 +1,7 @@ import json import os +import subprocess +import sys import tempfile from unittest import TestCase from unittest.mock import MagicMock @@ -109,3 +111,34 @@ def test_old_incompatible_artifact_keeps_dbt_error(self): loader(path=path) finally: os.unlink(path) + + +def _import_dbt_adapter(fake_packages_dir) -> subprocess.CompletedProcess: + pythonpath = os.pathsep.join(p for p in [str(fake_packages_dir), os.environ.get("PYTHONPATH")] if p) + return subprocess.run( + [sys.executable, "-c", "import recce.adapter.dbt_adapter"], + env={**os.environ, "PYTHONPATH": pythonpath}, + capture_output=True, + text=True, + ) + + +def test_dbt_v2_import_names_dbt_v2(tmp_path): + (tmp_path / "dbt").mkdir() + (tmp_path / "dbt" / "__init__.py").write_text("") + (tmp_path / "dbt" / "_core.py").write_text("") + + result = _import_dbt_adapter(tmp_path) + + assert result.returncode != 0 + assert "DbtUnavailableError: Recce supports dbt-core 1.x. dbt v2 (Fusion) is not supported yet." in result.stderr + + +def test_unexpected_import_error_keeps_original_error(tmp_path): + (tmp_path / "agate.py").write_text("raise ImportError(\"cannot import name 'MappedSequence' from 'agate'\")") + + result = _import_dbt_adapter(tmp_path) + + assert result.returncode != 0 + assert "ImportError: cannot import name 'MappedSequence' from 'agate'" in result.stderr + assert "DbtUnavailableError" not in result.stderr From af83136c2afb8c01f27fae00a177f835d11a700e Mon Sep 17 00:00:00 2001 From: "Wei-Chun, Chang" Date: Thu, 24 Sep 2026 10:27:06 +0800 Subject: [PATCH 6/7] docs: describe when the dbt exceptions are raised Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Wei-Chun, Chang --- recce/exceptions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/recce/exceptions.py b/recce/exceptions.py index 539881763..9939716e4 100644 --- a/recce/exceptions.py +++ b/recce/exceptions.py @@ -22,11 +22,12 @@ def __str__(self): class DbtUnavailableError(RecceException): - pass + """Raised when the dbt adapter cannot import dbt: dbt is not installed, or dbt v2 (Fusion) is installed.""" class UnsupportedDbtSchemaError(RecceException): - """The version limits are in _DBT1X_MAX_SCHEMA in the dbt adapter.""" + """Raised when a dbt artifact's schema is newer than Recce supports. + The version limits are in _DBT1X_MAX_SCHEMA in the dbt adapter.""" def __init__(self, artifact: str, found_version: int): message = ( From 4e5804575fbd0d6148949fcac70298aca712e25d Mon Sep 17 00:00:00 2001 From: "Wei-Chun, Chang" Date: Thu, 24 Sep 2026 10:50:16 +0800 Subject: [PATCH 7/7] fix(server): print the dbt import failure as an error A warning hid that the server cannot load. The server now prints the message as an error on the console. Sentry does not read console output, so it still does not report this failure. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Wei-Chun, Chang --- recce/server.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/recce/server.py b/recce/server.py index 716ef6c6d..2f14e2eec 100644 --- a/recce/server.py +++ b/recce/server.py @@ -256,6 +256,9 @@ def _do_lifespan_setup(app_state: AppState): @asynccontextmanager async def lifespan(fastapi: FastAPI): + from rich.console import Console + from rich.markup import escape + from recce.core import default_context from recce.event import log_performance from recce.util.startup_perf import clear_startup_tracker, get_startup_tracker @@ -297,7 +300,9 @@ async def background_load(): except Exception as e: if isinstance(e, DbtUnavailableError): logger.debug("Failed to load server context during startup", exc_info=True) - logger.warning(f"Failed to load server context during startup: {e}") + console = Console(stderr=True) + message = escape(str(e)) + console.print(f"[[red]Error[/red]] Failed to load server context during startup: {message}") else: logger.exception("Failed to load server context during startup") app_state.startup_error = e