|
1 | 1 | import os |
2 | 2 | from pathlib import Path |
3 | | -from typing import List |
| 3 | +from typing import List, Optional |
4 | 4 |
|
5 | 5 | import click |
6 | 6 |
|
@@ -318,16 +318,23 @@ def init(cache_db, **kwargs): |
318 | 318 |
|
319 | 319 | import json |
320 | 320 | import logging |
| 321 | + import shutil |
321 | 322 | import tempfile |
322 | 323 | import time |
323 | 324 |
|
324 | 325 | import requests |
325 | 326 | from rich.console import Console |
326 | 327 | from rich.progress import Progress |
327 | 328 |
|
| 329 | + from recce import __version__ |
328 | 330 | from recce.adapter.dbt_adapter import DbtAdapter |
329 | 331 | from recce.core import load_context |
330 | 332 | from recce.util.cll import _DEFAULT_DB_PATH, CllCache, get_cll_cache, set_cll_cache |
| 333 | + from recce.util.per_node_db import SCHEMA_VERSION as PER_NODE_DB_SCHEMA_VERSION |
| 334 | + from recce.util.per_node_db import ( |
| 335 | + PerNodeDbWriter, |
| 336 | + extract_rows_from_artifacts, |
| 337 | + ) |
331 | 338 |
|
332 | 339 | logger = logging.getLogger("recce") |
333 | 340 | console = Console() |
@@ -671,73 +678,187 @@ def _stream_download_to_file(url: str, dest: Path) -> int: |
671 | 678 | stats = cache.stats |
672 | 679 | console.print(f"\nCache saved to [bold]{cache_db}[/bold] ({stats['entries']} entries)") |
673 | 680 |
|
674 | | - # Upload results to Cloud if in cloud mode |
675 | | - if is_cloud and cloud_client: |
676 | | - console.print("\n[bold]Uploading results to Cloud...[/bold]") |
677 | | - upload_failures = [] |
| 681 | + # In cloud mode, emit per_node.db — a pure-artifact SQLite that Cloud |
| 682 | + # streams to serve lineage without proxying to an ephemeral Recce instance. |
| 683 | + # The scratch dir is always cleaned up, even on upload failure, so |
| 684 | + # long-lived Cloud deploys don't accumulate recce-per-node-* directories |
| 685 | + # in /tmp on retries. |
| 686 | + if is_cloud: |
| 687 | + per_node_scratch = Path(tempfile.mkdtemp(prefix="recce-per-node-")) |
678 | 688 | try: |
679 | | - upload_urls = cloud_client.get_upload_urls_by_session_id(cloud_org_id, cloud_project_id, session_id) |
680 | | - |
681 | | - # Upload CLL map |
682 | | - cll_map_upload_url = upload_urls.get("cll_map_url") |
683 | | - if cll_map_upload_url and cll_map_path.is_file(): |
| 689 | + if cloud_client: |
| 690 | + console.print("\n[bold]Uploading results to Cloud...[/bold]") |
| 691 | + upload_failures: list[str] = [] |
| 692 | + upload_urls: Optional[dict] = None |
684 | 693 | try: |
685 | | - with open(cll_map_path, "rb") as f: |
686 | | - resp = requests.put( |
687 | | - cll_map_upload_url, |
688 | | - data=f, |
689 | | - headers={"Content-Type": "application/json"}, |
690 | | - timeout=_UPLOAD_TIMEOUT, |
691 | | - ) |
692 | | - if resp.status_code in (200, 204): |
693 | | - console.print(f" Uploaded cll_map.json ({cll_map_path.stat().st_size / 1024 / 1024:.1f} MB)") |
| 694 | + 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 |
694 | 759 | else: |
695 | | - upload_failures.append("cll_map.json") |
696 | 760 | console.print( |
697 | | - f" [[yellow]Warning[/yellow]] Failed to upload cll_map.json: HTTP {resp.status_code}" |
| 761 | + " [[yellow]Warning[/yellow]] No per_node_db_url in upload URLs " |
| 762 | + "(Cloud server may need update) — skipping per_node.db emit" |
698 | 763 | ) |
699 | | - except requests.RequestException as e: |
700 | | - upload_failures.append("cll_map.json") |
701 | | - console.print(f" [[yellow]Warning[/yellow]] Failed to upload cll_map.json: {e}") |
702 | | - elif not cll_map_upload_url: |
703 | | - console.print( |
704 | | - " [[yellow]Warning[/yellow]] No cll_map_url in upload URLs (Cloud server may need update)" |
705 | | - ) |
706 | | - |
707 | | - # Upload CLL cache |
708 | | - cll_cache_upload_url = upload_urls.get("cll_cache_url") |
709 | | - if cll_cache_upload_url and Path(cache_db).is_file(): |
710 | | - try: |
711 | | - with open(cache_db, "rb") as f: |
712 | | - resp = requests.put( |
713 | | - cll_cache_upload_url, |
714 | | - data=f, |
715 | | - headers={"Content-Type": "application/octet-stream"}, |
716 | | - timeout=_UPLOAD_TIMEOUT, |
| 764 | + |
| 765 | + # Upload CLL map |
| 766 | + cll_map_upload_url = upload_urls.get("cll_map_url") |
| 767 | + if cll_map_upload_url and cll_map_path.is_file(): |
| 768 | + try: |
| 769 | + with open(cll_map_path, "rb") as f: |
| 770 | + resp = requests.put( |
| 771 | + cll_map_upload_url, |
| 772 | + data=f, |
| 773 | + headers={"Content-Type": "application/json"}, |
| 774 | + timeout=_UPLOAD_TIMEOUT, |
| 775 | + ) |
| 776 | + if resp.status_code in (200, 204): |
| 777 | + console.print( |
| 778 | + f" Uploaded cll_map.json ({cll_map_path.stat().st_size / 1024 / 1024:.1f} MB)" |
| 779 | + ) |
| 780 | + else: |
| 781 | + upload_failures.append("cll_map.json") |
| 782 | + console.print( |
| 783 | + f" [[yellow]Warning[/yellow]] Failed to upload cll_map.json: " |
| 784 | + f"HTTP {resp.status_code}" |
| 785 | + ) |
| 786 | + except requests.RequestException as e: |
| 787 | + upload_failures.append("cll_map.json") |
| 788 | + console.print(f" [[yellow]Warning[/yellow]] Failed to upload cll_map.json: {e}") |
| 789 | + elif not cll_map_upload_url: |
| 790 | + console.print( |
| 791 | + " [[yellow]Warning[/yellow]] No cll_map_url in upload URLs " |
| 792 | + "(Cloud server may need update)" |
717 | 793 | ) |
718 | | - if resp.status_code in (200, 204): |
719 | | - console.print(f" Uploaded cll_cache.db ({Path(cache_db).stat().st_size / 1024 / 1024:.1f} MB)") |
720 | | - else: |
721 | | - upload_failures.append("cll_cache.db") |
| 794 | + |
| 795 | + # Upload per_node.db (only when Cloud supports it AND we emitted). |
| 796 | + if per_node_db_upload_url and per_node_db_path and per_node_db_path.is_file(): |
| 797 | + try: |
| 798 | + with open(per_node_db_path, "rb") as f: |
| 799 | + resp = requests.put( |
| 800 | + per_node_db_upload_url, |
| 801 | + data=f, |
| 802 | + headers={"Content-Type": "application/octet-stream"}, |
| 803 | + timeout=_UPLOAD_TIMEOUT, |
| 804 | + ) |
| 805 | + if resp.status_code in (200, 204): |
| 806 | + console.print( |
| 807 | + f" Uploaded per_node.db " |
| 808 | + f"({per_node_db_path.stat().st_size / 1024 / 1024:.1f} MB)" |
| 809 | + ) |
| 810 | + else: |
| 811 | + upload_failures.append("per_node.db") |
| 812 | + console.print( |
| 813 | + f" [[yellow]Warning[/yellow]] Failed to upload per_node.db: " |
| 814 | + f"HTTP {resp.status_code}" |
| 815 | + ) |
| 816 | + except requests.RequestException as e: |
| 817 | + upload_failures.append("per_node.db") |
| 818 | + console.print(f" [[yellow]Warning[/yellow]] Failed to upload per_node.db: {e}") |
| 819 | + |
| 820 | + # Upload CLL cache. cll_cache.db is load-bearing across sessions — |
| 821 | + # build_full_cll_map reuses its warm entries on subsequent runs — |
| 822 | + # so Cloud uploads it alongside per_node.db. |
| 823 | + cll_cache_upload_url = upload_urls.get("cll_cache_url") |
| 824 | + if cll_cache_upload_url and Path(cache_db).is_file(): |
| 825 | + try: |
| 826 | + with open(cache_db, "rb") as f: |
| 827 | + resp = requests.put( |
| 828 | + cll_cache_upload_url, |
| 829 | + data=f, |
| 830 | + headers={"Content-Type": "application/octet-stream"}, |
| 831 | + timeout=_UPLOAD_TIMEOUT, |
| 832 | + ) |
| 833 | + if resp.status_code in (200, 204): |
| 834 | + console.print( |
| 835 | + f" Uploaded cll_cache.db " |
| 836 | + f"({Path(cache_db).stat().st_size / 1024 / 1024:.1f} MB)" |
| 837 | + ) |
| 838 | + else: |
| 839 | + upload_failures.append("cll_cache.db") |
| 840 | + console.print( |
| 841 | + f" [[yellow]Warning[/yellow]] Failed to upload cll_cache.db: " |
| 842 | + f"HTTP {resp.status_code}" |
| 843 | + ) |
| 844 | + except requests.RequestException as e: |
| 845 | + upload_failures.append("cll_cache.db") |
| 846 | + console.print(f" [[yellow]Warning[/yellow]] Failed to upload cll_cache.db: {e}") |
| 847 | + elif not cll_cache_upload_url: |
| 848 | + logger.debug("No cll_cache_url in upload URLs — cache upload not supported yet") |
| 849 | + |
| 850 | + if upload_failures: |
722 | 851 | console.print( |
723 | | - f" [[yellow]Warning[/yellow]] Failed to upload cll_cache.db: HTTP {resp.status_code}" |
| 852 | + f"[bold yellow]Cloud upload completed with warnings[/bold yellow] " |
| 853 | + f"(failed: {', '.join(upload_failures)})" |
724 | 854 | ) |
725 | | - except requests.RequestException as e: |
726 | | - upload_failures.append("cll_cache.db") |
727 | | - console.print(f" [[yellow]Warning[/yellow]] Failed to upload cll_cache.db: {e}") |
728 | | - elif not cll_cache_upload_url: |
729 | | - logger.debug("No cll_cache_url in upload URLs — cache upload not supported yet") |
730 | | - |
731 | | - if upload_failures: |
732 | | - console.print( |
733 | | - f"[bold yellow]Cloud upload completed with warnings[/bold yellow] " |
734 | | - f"(failed: {', '.join(upload_failures)})" |
735 | | - ) |
736 | | - else: |
737 | | - console.print("[bold green]Cloud upload complete.[/bold green]") |
738 | | - except Exception as e: |
739 | | - logger.warning("[recce init] Cloud upload failed: %s", e) |
740 | | - console.print(f" [[yellow]Warning[/yellow]] Cloud upload failed: {e}") |
| 855 | + else: |
| 856 | + console.print("[bold green]Cloud upload complete.[/bold green]") |
| 857 | + finally: |
| 858 | + # Always remove the per_node.db scratch dir — it is throwaway per |
| 859 | + # invocation. cll_cache.db lives at ~/.recce/cll_cache.db (or the |
| 860 | + # user-provided --cache-db) and is NOT touched here. |
| 861 | + shutil.rmtree(per_node_scratch, ignore_errors=True) |
741 | 862 | else: |
742 | 863 | console.print("Run [bold]recce server --enable-cll-cache[/bold] to use the cached lineage.") |
743 | 864 |
|
|
0 commit comments