Skip to content

Commit 6766eca

Browse files
even-weiclaude
andcommitted
fix(cli): address round 2 review feedback on per_node.db emitter
Critical: - Phantom env='base' rows when only one env has artifacts. The emit loop keyed off `manifest is None`, but in the single-env path-swap case both curr_manifest and base_manifest are loaded pointing at the SAME env — so the missing-env guard never fired and we shipped duplicate rows under a fabricated env label. The emit loop now uses `has_target` / `has_base`, matching the CLL cache loop's truth source. Added a CLI test that reproduces the path-swap scenario and asserts no env='base' rows ship. Addressed Copilot suggestion: - Skip per_node.db emission entirely when Cloud doesn't advertise `per_node_db_url`. Fetch upload URLs first; only emit the SQLite file when there's somewhere to upload it. Saves 1-2s of pure-waste emit work against older Cloud servers during the migration window. Docs: - `extract_rows_from_artifacts` docstring no longer says "tests-as-columns" — columns come from each node's manifest `columns` metadata; tests are derived separately into `node_tests` via `_derive_tests`. - Updated PR description: schema is rollback-journal (not WAL) mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: even-wei <evenwei@infuseai.io>
1 parent a768670 commit 6766eca

3 files changed

Lines changed: 187 additions & 54 deletions

File tree

recce/cli.py

Lines changed: 71 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -686,51 +686,81 @@ def _stream_download_to_file(url: str, dest: Path) -> int:
686686
if is_cloud:
687687
per_node_scratch = Path(tempfile.mkdtemp(prefix="recce-per-node-"))
688688
try:
689-
per_node_db_path: Optional[Path] = per_node_scratch / "per_node.db"
690-
console.print("\n[bold]Emitting per-node SQLite...[/bold]")
691-
t_pn_start = time.perf_counter()
692-
try:
693-
with PerNodeDbWriter(per_node_db_path) as writer:
694-
writer.write_meta(
695-
schema_version=str(PER_NODE_DB_SCHEMA_VERSION),
696-
session_id=session_id or "",
697-
recce_version=__version__,
698-
generated_at=str(int(time.time())),
699-
)
700-
envs_to_emit = [
701-
("current", dbt_adapter.curr_manifest, dbt_adapter.curr_catalog),
702-
("base", dbt_adapter.base_manifest, dbt_adapter.base_catalog),
703-
]
704-
for env_name, manifest, catalog in envs_to_emit:
705-
if manifest is None:
706-
continue
707-
manifest_dict = manifest.to_dict() if hasattr(manifest, "to_dict") else manifest
708-
catalog_dict = (
709-
catalog.to_dict() if (catalog is not None and hasattr(catalog, "to_dict")) else catalog
710-
)
711-
node_rows, column_rows, edge_rows, test_rows = extract_rows_from_artifacts(
712-
manifest_dict, catalog_dict, env_name
713-
)
714-
writer.write_nodes(node_rows)
715-
writer.write_columns(column_rows)
716-
writer.write_edges(edge_rows)
717-
writer.write_tests(test_rows)
718-
pn_elapsed = time.perf_counter() - t_pn_start
719-
pn_size_mb = per_node_db_path.stat().st_size / 1024 / 1024
720-
console.print(
721-
f" per_node.db saved to [bold]{per_node_db_path}[/bold] ({pn_size_mb:.1f} MB, {pn_elapsed:.1f}s)"
722-
)
723-
except Exception as e:
724-
logger.warning("[recce init] Failed to emit per_node.db: %s", e)
725-
console.print(f" [[yellow]Warning[/yellow]] Failed to emit per_node.db: {e}")
726-
per_node_db_path = None
727-
728-
# Upload results to Cloud.
729689
if cloud_client:
730690
console.print("\n[bold]Uploading results to Cloud...[/bold]")
731691
upload_failures: list[str] = []
692+
upload_urls: Optional[dict] = None
732693
try:
733694
upload_urls = cloud_client.get_upload_urls_by_session_id(cloud_org_id, cloud_project_id, session_id)
695+
except Exception as e:
696+
logger.warning("[recce init] Cloud upload failed: %s", e)
697+
console.print(f" [[yellow]Warning[/yellow]] Cloud upload failed: {e}")
698+
699+
if upload_urls is not None:
700+
# Emit per_node.db only when Cloud declares support for it.
701+
# Against an older Cloud without per_node_db_url, emitting
702+
# the SQLite file is pure waste — it is a cloud-only
703+
# artifact with no local consumer.
704+
per_node_db_upload_url = upload_urls.get("per_node_db_url")
705+
per_node_db_path: Optional[Path] = None
706+
if per_node_db_upload_url:
707+
per_node_db_path = per_node_scratch / "per_node.db"
708+
console.print("\n[bold]Emitting per-node SQLite...[/bold]")
709+
t_pn_start = time.perf_counter()
710+
try:
711+
with PerNodeDbWriter(per_node_db_path) as writer:
712+
writer.write_meta(
713+
schema_version=str(PER_NODE_DB_SCHEMA_VERSION),
714+
session_id=session_id or "",
715+
recce_version=__version__,
716+
generated_at=str(int(time.time())),
717+
)
718+
# Use has_target / has_base to match the CLL
719+
# cache loop above. When only one env has
720+
# artifacts, context_kwargs path-swaps the
721+
# missing path to the present one so
722+
# load_context doesn't fail — so both manifests
723+
# are non-None but represent the SAME env. The
724+
# flags are the only truth about which env
725+
# actually has artifacts.
726+
envs_to_emit = []
727+
if has_target:
728+
envs_to_emit.append(
729+
("current", dbt_adapter.curr_manifest, dbt_adapter.curr_catalog)
730+
)
731+
if has_base:
732+
envs_to_emit.append(("base", dbt_adapter.base_manifest, dbt_adapter.base_catalog))
733+
for env_name, manifest, catalog in envs_to_emit:
734+
if manifest is None:
735+
continue
736+
manifest_dict = manifest.to_dict() if hasattr(manifest, "to_dict") else manifest
737+
catalog_dict = (
738+
catalog.to_dict()
739+
if (catalog is not None and hasattr(catalog, "to_dict"))
740+
else catalog
741+
)
742+
node_rows, column_rows, edge_rows, test_rows = extract_rows_from_artifacts(
743+
manifest_dict, catalog_dict, env_name
744+
)
745+
writer.write_nodes(node_rows)
746+
writer.write_columns(column_rows)
747+
writer.write_edges(edge_rows)
748+
writer.write_tests(test_rows)
749+
pn_elapsed = time.perf_counter() - t_pn_start
750+
pn_size_mb = per_node_db_path.stat().st_size / 1024 / 1024
751+
console.print(
752+
f" per_node.db saved to [bold]{per_node_db_path}[/bold] "
753+
f"({pn_size_mb:.1f} MB, {pn_elapsed:.1f}s)"
754+
)
755+
except Exception as e:
756+
logger.warning("[recce init] Failed to emit per_node.db: %s", e)
757+
console.print(f" [[yellow]Warning[/yellow]] Failed to emit per_node.db: {e}")
758+
per_node_db_path = None
759+
else:
760+
console.print(
761+
" [[yellow]Warning[/yellow]] No per_node_db_url in upload URLs "
762+
"(Cloud server may need update) — skipping per_node.db emit"
763+
)
734764

735765
# Upload CLL map
736766
cll_map_upload_url = upload_urls.get("cll_map_url")
@@ -762,10 +792,7 @@ def _stream_download_to_file(url: str, dest: Path) -> int:
762792
"(Cloud server may need update)"
763793
)
764794

765-
# Upload per_node.db. Graceful degradation: if Cloud hasn't added
766-
# the per_node_db_url key yet, log a warning and continue — this
767-
# keeps old CLI versions compatible with new Cloud and vice versa.
768-
per_node_db_upload_url = upload_urls.get("per_node_db_url")
795+
# Upload per_node.db (only when Cloud supports it AND we emitted).
769796
if per_node_db_upload_url and per_node_db_path and per_node_db_path.is_file():
770797
try:
771798
with open(per_node_db_path, "rb") as f:
@@ -789,11 +816,6 @@ def _stream_download_to_file(url: str, dest: Path) -> int:
789816
except requests.RequestException as e:
790817
upload_failures.append("per_node.db")
791818
console.print(f" [[yellow]Warning[/yellow]] Failed to upload per_node.db: {e}")
792-
elif not per_node_db_upload_url:
793-
console.print(
794-
" [[yellow]Warning[/yellow]] No per_node_db_url in upload URLs "
795-
"(Cloud server may need update)"
796-
)
797819

798820
# Upload CLL cache. cll_cache.db is load-bearing across sessions —
799821
# build_full_cll_map reuses its warm entries on subsequent runs —
@@ -832,9 +854,6 @@ def _stream_download_to_file(url: str, dest: Path) -> int:
832854
)
833855
else:
834856
console.print("[bold green]Cloud upload complete.[/bold green]")
835-
except Exception as e:
836-
logger.warning("[recce init] Cloud upload failed: %s", e)
837-
console.print(f" [[yellow]Warning[/yellow]] Cloud upload failed: {e}")
838857
finally:
839858
# Always remove the per_node.db scratch dir — it is throwaway per
840859
# invocation. cll_cache.db lives at ~/.recce/cll_cache.db (or the

recce/util/per_node_db.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,10 @@ def extract_rows_from_artifacts(
332332
333333
- ``manifest`` must be a raw dict (e.g. ``json.load(manifest.json)``).
334334
- ``catalog`` may be ``None``; when absent, all ``data_type`` are ``None``.
335-
- Column names come from the catalog when available; otherwise from the
336-
manifest's ``columns`` section (tests-as-columns).
335+
- Column names come from the catalog when available; otherwise from each
336+
node's ``columns`` metadata in the manifest (model/source column
337+
definitions). Tests are NOT part of columns — they are derived
338+
separately into ``node_tests`` rows via ``_derive_tests``.
337339
"""
338340
catalog_nodes = ((catalog or {}).get("nodes") or {}) if catalog else {}
339341
node_rows: list[NodeRow] = []

tests/test_cli_per_node_db.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,118 @@ def __init__(self, *_a, **_kw):
559559
assert any("cll_map.json" in url for url in put_calls), put_calls
560560
assert any("cll_cache.db" in url for url in put_calls), put_calls
561561

562+
@patch("recce.core.load_context")
563+
def test_no_base_rows_when_only_target_has_artifacts(self, mock_load_context, runner, tmp_path, tmp_db):
564+
"""Phantom env='base' guard: when only target/ is populated, the
565+
earlier path-swap in ``init`` points target-base at target so
566+
``load_context`` doesn't fail — both manifests end up loaded but
567+
represent the SAME (current) env. The emit loop must key off
568+
has_target / has_base, not ``manifest is None``, otherwise the
569+
current env's rows would ship in per_node.db under the fabricated
570+
env='base' label.
571+
"""
572+
import sqlite3 as _sqlite3
573+
574+
# Only current-env manifest is downloaded. base_download_urls is empty
575+
# → target-base/manifest.json is never written → has_base == False.
576+
manifest_bytes = b'{"nodes": {}, "child_map": {}}'
577+
mock_client = _make_mock_cloud_client(
578+
download_urls={"manifest_url": "https://s3.example.com/manifest.json"},
579+
base_download_urls={},
580+
upload_urls={
581+
"cll_map_url": "https://s3.example.com/upload/cll_map.json",
582+
"per_node_db_url": "https://s3.example.com/upload/per_node.db",
583+
"cll_cache_url": "https://s3.example.com/upload/cll_cache.db",
584+
},
585+
)
586+
587+
nodes = {"model.test.a": _make_mock_node(raw_code="SELECT a FROM src")}
588+
adapter = _make_mock_adapter(nodes)
589+
adapter.get_cll_cached.return_value = MagicMock()
590+
mock_cll_map = MagicMock()
591+
mock_cll_map.nodes = {}
592+
mock_cll_map.columns = {}
593+
mock_cll_map.model_dump.return_value = {"nodes": {}, "columns": {}}
594+
adapter.build_full_cll_map.return_value = mock_cll_map
595+
596+
# Simulate the path-swap outcome: both manifests are loaded, both
597+
# point at the SAME object (the current-env manifest). `.nodes` on the
598+
# manifest object must carry `.resource_type` attributes because the
599+
# CLL cache loop iterates it; `.to_dict()` is what our emitter sees.
600+
shared_manifest_dict = {
601+
"nodes": {
602+
"model.test.a": {
603+
"name": "a",
604+
"resource_type": "model",
605+
"package_name": "test",
606+
"columns": {"id": {"name": "id"}},
607+
}
608+
},
609+
"child_map": {"model.test.a": []},
610+
}
611+
shared_manifest = MagicMock()
612+
shared_manifest.to_dict.return_value = shared_manifest_dict
613+
shared_manifest.nodes = {"model.test.a": _make_mock_node(raw_code="SELECT a FROM src")}
614+
shared_manifest.metadata.adapter_type = "duckdb"
615+
adapter.curr_manifest = shared_manifest
616+
adapter.base_manifest = shared_manifest # Same object — what the path-swap produces.
617+
adapter.curr_catalog = None
618+
adapter.base_catalog = None
619+
620+
mock_ctx = MagicMock()
621+
mock_ctx.adapter = adapter
622+
mock_load_context.return_value = mock_ctx
623+
624+
captured: dict[str, bytes] = {}
625+
626+
def capture_put(url, data=None, **kwargs):
627+
if "per_node.db" in url:
628+
captured["per_node_db"] = data.read() if hasattr(data, "read") else (data or b"")
629+
return _make_mock_response(200)
630+
631+
with (
632+
patch("recce.util.recce_cloud.RecceCloud", return_value=mock_client),
633+
patch("requests.get", side_effect=lambda *a, **kw: _make_mock_response(200, manifest_bytes)),
634+
patch("requests.put", side_effect=capture_put),
635+
patch(
636+
"recce.adapter.dbt_adapter.DbtAdapter._serialize_cll_data",
637+
return_value='{"nodes":{}, "columns":{}, "parent_map":{}}',
638+
),
639+
):
640+
result = runner.invoke(
641+
cli,
642+
[
643+
"init",
644+
"--cloud",
645+
"--cloud-token",
646+
"ghp_testtoken",
647+
"--session-id",
648+
"sess-1",
649+
"--cache-db",
650+
tmp_db,
651+
"--project-dir",
652+
str(tmp_path),
653+
],
654+
catch_exceptions=False,
655+
)
656+
657+
assert result.exit_code == 0, result.output
658+
assert "per_node_db" in captured, "per_node.db was never PUT"
659+
660+
# Inspect the uploaded bytes directly — the scratch dir is already
661+
# cleaned up by the CLI's finally block.
662+
capture_path = tmp_path / "uploaded_per_node.db"
663+
capture_path.write_bytes(captured["per_node_db"])
664+
conn = _sqlite3.connect(str(capture_path))
665+
try:
666+
base_count = conn.execute("SELECT COUNT(*) FROM nodes WHERE env = 'base'").fetchone()[0]
667+
current_count = conn.execute("SELECT COUNT(*) FROM nodes WHERE env = 'current'").fetchone()[0]
668+
finally:
669+
conn.close()
670+
671+
assert base_count == 0, f"phantom base rows: got {base_count}, expected 0"
672+
assert current_count >= 1, f"expected ≥1 current-env node, got {current_count}"
673+
562674

563675
# ---------------------------------------------------------------------------
564676
# 2. Local-mode non-regression

0 commit comments

Comments
 (0)