Skip to content

Commit eaa29a1

Browse files
Dashboard As Code for Reconcile (databrickslabs#768)
closes databrickslabs#708 The existing dashboard has been broken into 2. For features unsupported by LSQL, we are using overrides to implement the required widgets. Conditional formatting depends on databrickslabs/lsql#299 Screenshots: ![REMORPH Reconciliation Metrics](https://github.com/user-attachments/assets/af7f6341-cecf-42d9-96b4-931a0347ed85) ![REMORPH Aggregate Reconciliation Metrics](https://github.com/user-attachments/assets/2c70b071-e373-4ab4-8923-7706ae60b6ce) -- co-authored by @bishwajit-db and @sundarshankar89 --------- Co-authored-by: Bishwajit <bishwajit.dey@databricks.com>
1 parent 152214d commit eaa29a1

File tree

62 files changed

+1689
-183
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+1689
-183
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ dependencies = [
1616
"databricks-sdk~=0.29.0",
1717
"sqlglot==25.8.1",
1818
"databricks-labs-blueprint[yaml]>=0.2.3",
19-
"databricks-labs-lsql>=0.4.3",
19+
"databricks-labs-lsql>=0.7.5",
2020
"cryptography>=41.0.3",
2121
]
2222

Lines changed: 117 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,140 @@
1-
import json
21
import logging
32
from datetime import timedelta
4-
from importlib.abc import Traversable
5-
from typing import Any
3+
from pathlib import Path
64

75
from databricks.labs.blueprint.installation import Installation
86
from databricks.labs.blueprint.installer import InstallState
7+
from databricks.labs.lsql.dashboards import DashboardMetadata, Dashboards
98
from databricks.sdk import WorkspaceClient
10-
from databricks.sdk.errors import DatabricksError
11-
from databricks.sdk.errors import InvalidParameterValue
9+
from databricks.sdk.errors import (
10+
InvalidParameterValue,
11+
NotFound,
12+
DeadlineExceeded,
13+
InternalError,
14+
ResourceAlreadyExists,
15+
)
1216
from databricks.sdk.retries import retried
13-
from databricks.sdk.service.dashboards import Dashboard
17+
from databricks.sdk.service.dashboards import LifecycleState, Dashboard
18+
19+
from databricks.labs.remorph.config import ReconcileConfig, ReconcileMetadataConfig
1420

1521
logger = logging.getLogger(__name__)
1622

1723

1824
class DashboardDeployment:
19-
_UPLOAD_TIMEOUT = timedelta(seconds=30)
2025

21-
def __init__(self, ws: WorkspaceClient, installation: Installation, install_state: InstallState):
26+
def __init__(
27+
self,
28+
ws: WorkspaceClient,
29+
installation: Installation,
30+
install_state: InstallState,
31+
):
2232
self._ws = ws
2333
self._installation = installation
2434
self._install_state = install_state
2535

26-
def deploy(self, name: str, dashboard_file: Traversable, parameters: dict[str, Any] | None = None):
27-
logger.debug(f"Deploying dashboard {name} from {dashboard_file.name}")
28-
dashboard_data = self._substitute_params(dashboard_file, parameters or {})
29-
dashboard = self._update_or_create_dashboard(name, dashboard_data, dashboard_file)
30-
logger.info(f"Dashboard deployed with dashboard_id {dashboard.dashboard_id}")
31-
logger.info(f"Dashboard URL: {self._ws.config.host}/sql/dashboardsv3/{dashboard.dashboard_id}")
32-
self._install_state.save()
33-
34-
@retried(on=[DatabricksError], timeout=_UPLOAD_TIMEOUT)
35-
def _update_or_create_dashboard(self, name: str, dashboard_data, dashboard_file) -> Dashboard:
36-
if name in self._install_state.dashboards:
36+
def deploy(
37+
self,
38+
folder: Path,
39+
config: ReconcileConfig,
40+
):
41+
"""
42+
Create dashboards from Dashboard metadata files.
43+
The given folder is expected to contain subfolders each containing metadata for individual dashboards.
44+
45+
:param folder: Path to the base folder.
46+
:param config: Configuration for reconciliation.
47+
"""
48+
logger.info(f"Deploying dashboards from base folder {folder}")
49+
parent_path = f"{self._installation.install_folder()}/dashboards"
50+
try:
51+
self._ws.workspace.mkdirs(parent_path)
52+
except ResourceAlreadyExists:
53+
logger.info(f"Dashboard parent path already exists: {parent_path}")
54+
55+
valid_dashboard_refs = set()
56+
for dashboard_folder in folder.iterdir():
57+
if not dashboard_folder.is_dir():
58+
continue
59+
valid_dashboard_refs.add(self._dashboard_reference(dashboard_folder))
60+
dashboard = self._update_or_create_dashboard(dashboard_folder, parent_path, config.metadata_config)
61+
logger.info(
62+
f"Dashboard deployed with URL: {self._ws.config.host}/sql/dashboardsv3/{dashboard.dashboard_id}"
63+
)
64+
self._install_state.save()
65+
66+
self._remove_deprecated_dashboards(valid_dashboard_refs)
67+
68+
def _dashboard_reference(self, folder: Path) -> str:
69+
return f"{folder.stem}".lower()
70+
71+
# InternalError and DeadlineExceeded are retried because of Lakeview internal issues
72+
# These issues have been reported to and are resolved by the Lakeview team
73+
# Keeping the retry for resilience
74+
@retried(on=[InternalError, DeadlineExceeded], timeout=timedelta(minutes=3))
75+
def _update_or_create_dashboard(
76+
self,
77+
folder: Path,
78+
ws_parent_path: str,
79+
config: ReconcileMetadataConfig,
80+
) -> Dashboard:
81+
logging.info(f"Reading dashboard folder {folder}")
82+
metadata = DashboardMetadata.from_path(folder).replace_database(
83+
catalog=config.catalog,
84+
catalog_to_replace="remorph",
85+
database=config.schema,
86+
database_to_replace="reconcile",
87+
)
88+
89+
metadata.display_name = self._name_with_prefix(metadata.display_name)
90+
reference = self._dashboard_reference(folder)
91+
dashboard_id = self._install_state.dashboards.get(reference)
92+
if dashboard_id is not None:
3793
try:
38-
dashboard_id = self._install_state.dashboards[name]
39-
logger.info(f"Updating dashboard with id={dashboard_id}")
40-
updated_dashboard = self._ws.lakeview.update(
41-
dashboard_id,
42-
display_name=self._name_with_prefix(name),
43-
serialized_dashboard=dashboard_data,
44-
)
45-
return updated_dashboard
46-
except InvalidParameterValue:
47-
del self._install_state.dashboards[name]
48-
logger.warning(f"Dashboard {name} does not exist anymore for some reason.")
49-
return self._update_or_create_dashboard(name, dashboard_data, dashboard_file)
50-
logger.info(f"Creating new dashboard {name}")
51-
new_dashboard = self._ws.lakeview.create(
52-
display_name=self._name_with_prefix(name),
53-
parent_path=self._install_state.install_folder(),
54-
serialized_dashboard=dashboard_data,
94+
dashboard_id = self._handle_existing_dashboard(dashboard_id, metadata.display_name)
95+
except (NotFound, InvalidParameterValue):
96+
logger.info(f"Recovering invalid dashboard: {metadata.display_name} ({dashboard_id})")
97+
try:
98+
dashboard_path = f"{ws_parent_path}/{metadata.display_name}.lvdash.json"
99+
self._ws.workspace.delete(dashboard_path) # Cannot recreate dashboard if file still exists
100+
logger.debug(
101+
f"Deleted dangling dashboard {metadata.display_name} ({dashboard_id}): {dashboard_path}"
102+
)
103+
except NotFound:
104+
pass
105+
dashboard_id = None # Recreate the dashboard if it's reference is corrupted (manually)
106+
107+
dashboard = Dashboards(self._ws).create_dashboard(
108+
metadata,
109+
dashboard_id=dashboard_id,
110+
parent_path=ws_parent_path,
111+
warehouse_id=self._ws.config.warehouse_id,
112+
publish=True,
55113
)
56-
assert new_dashboard.dashboard_id is not None
57-
self._install_state.dashboards[name] = new_dashboard.dashboard_id
58-
return new_dashboard
59-
60-
def _substitute_params(self, dashboard_file: Traversable, parameters: dict[str, Any]) -> str:
61-
if not parameters:
62-
return dashboard_file.read_text()
63-
64-
with dashboard_file.open() as f:
65-
dashboard_data = json.load(f)
66-
67-
for dataset in dashboard_data.get("datasets", []):
68-
for param in dataset.get("parameters", []):
69-
if param["keyword"] in parameters:
70-
param["defaultSelection"] = {
71-
"values": {
72-
"dataType": "STRING",
73-
"values": [
74-
{"value": parameters[param["keyword"]]},
75-
],
76-
},
77-
}
78-
79-
return json.dumps(dashboard_data)
114+
assert dashboard.dashboard_id is not None
115+
self._install_state.dashboards[reference] = dashboard.dashboard_id
116+
return dashboard
80117

81118
def _name_with_prefix(self, name: str) -> str:
82119
prefix = self._installation.product()
83120
return f"[{prefix.upper()}] {name}"
121+
122+
def _handle_existing_dashboard(self, dashboard_id: str, display_name: str) -> str | None:
123+
dashboard = self._ws.lakeview.get(dashboard_id)
124+
if dashboard.lifecycle_state is None:
125+
raise NotFound(f"Dashboard life cycle state: {display_name} ({dashboard_id})")
126+
if dashboard.lifecycle_state == LifecycleState.TRASHED:
127+
logger.info(f"Recreating trashed dashboard: {display_name} ({dashboard_id})")
128+
return None # Recreate the dashboard if it is trashed (manually)
129+
return dashboard_id # Update the existing dashboard
130+
131+
def _remove_deprecated_dashboards(self, valid_dashboard_refs: set[str]):
132+
for ref, dashboard_id in self._install_state.dashboards.items():
133+
if ref not in valid_dashboard_refs:
134+
try:
135+
logger.info(f"Removing dashboard_id={dashboard_id}, as it is no longer needed.")
136+
del self._install_state.dashboards[ref]
137+
self._ws.lakeview.trash(dashboard_id)
138+
except (InvalidParameterValue, NotFound):
139+
logger.warning(f"Dashboard `{dashboard_id}` doesn't exist anymore for some reason.")
140+
continue

src/databricks/labs/remorph/deployment/installation.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,14 @@
22

33
from databricks.labs.blueprint.installation import Installation
44
from databricks.labs.blueprint.tui import Prompts
5+
from databricks.labs.blueprint.upgrades import Upgrades
6+
from databricks.labs.blueprint.wheels import WheelsV2
57
from databricks.sdk import WorkspaceClient
68
from databricks.sdk.errors import NotFound
9+
from databricks.sdk.errors.platform import InvalidParameterValue
710

811
from databricks.labs.remorph.config import RemorphConfigs
912
from databricks.labs.remorph.deployment.recon import ReconDeployment
10-
from databricks.labs.blueprint.wheels import WheelsV2
11-
12-
from databricks.sdk.errors.platform import InvalidParameterValue
13-
from databricks.labs.blueprint.upgrades import Upgrades
14-
1513

1614
logger = logging.getLogger("databricks.labs.remorph.install")
1715

@@ -48,6 +46,7 @@ def _upload_wheel(self):
4846
def install(self, config: RemorphConfigs):
4947
wheel_paths: list[str] = self._upload_wheel()
5048
if config.reconcile:
49+
logger.info("Installing Remorph reconcile Metadata components.")
5150
self._recon_deployment.install(config.reconcile, wheel_paths)
5251
self._apply_upgrades()
5352

src/databricks/labs/remorph/deployment/recon.py

Lines changed: 9 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from databricks.labs.blueprint.installation import Installation
55
from databricks.labs.blueprint.installer import InstallState
66
from databricks.labs.blueprint.wheels import ProductInfo
7+
from databricks.labs.blueprint.wheels import find_project_root
78
from databricks.sdk import WorkspaceClient
89
from databricks.sdk.errors import InvalidParameterValue, NotFound
910

@@ -17,7 +18,6 @@
1718

1819
_RECON_PREFIX = "Reconciliation"
1920
RECON_JOB_NAME = f"{_RECON_PREFIX} Runner"
20-
RECON_METRICS_DASHBOARD_NAME = f"{_RECON_PREFIX} Metrics"
2121

2222

2323
class ReconDeployment:
@@ -41,6 +41,7 @@ def __init__(
4141

4242
def install(self, recon_config: ReconcileConfig | None, wheel_paths: list[str]):
4343
if not recon_config:
44+
logger.warning("Recon Config is empty.")
4445
return
4546
logger.info("Installing reconcile components.")
4647
self._deploy_tables(recon_config)
@@ -87,50 +88,21 @@ def _deploy_tables(self, recon_config: ReconcileConfig):
8788

8889
def _deploy_dashboards(self, recon_config: ReconcileConfig):
8990
logger.info("Deploying reconciliation dashboards.")
90-
self._deploy_recon_metrics_dashboard(RECON_METRICS_DASHBOARD_NAME, recon_config)
91-
for dashboard_name, dashboard_id in self._get_deprecated_dashboards():
92-
try:
93-
logger.info(f"Removing dashboard_id={dashboard_id}, as it is no longer needed.")
94-
del self._install_state.dashboards[dashboard_name]
95-
self._ws.lakeview.trash(dashboard_id)
96-
except (InvalidParameterValue, NotFound):
97-
logger.warning(f"Dashboard `{dashboard_name}` doesn't exist anymore for some reason.")
98-
continue
99-
100-
def _deploy_recon_metrics_dashboard(self, name: str, recon_config: ReconcileConfig):
101-
dashboard_params = {
102-
"catalog": recon_config.metadata_config.catalog,
103-
"schema": recon_config.metadata_config.schema,
104-
}
105-
106-
reconcile_dashboard_path = "reconcile/dashboards/Remorph-Reconciliation.lvdash.json"
107-
dashboard_file = files(databricks.labs.remorph.resources).joinpath(reconcile_dashboard_path)
108-
logger.info(f"Creating Reconciliation Dashboard `{name}`")
109-
self._dashboard_deployer.deploy(name, dashboard_file, parameters=dashboard_params)
91+
dashboard_base_dir = find_project_root(__file__) / "src/databricks/labs/remorph/resources/reconcile/dashboards"
92+
self._dashboard_deployer.deploy(dashboard_base_dir, recon_config)
11093

11194
def _get_dashboards(self) -> list[tuple[str, str]]:
112-
return [
113-
(dashboard_name, dashboard_id)
114-
for dashboard_name, dashboard_id in self._install_state.dashboards.items()
115-
if dashboard_name.startswith(_RECON_PREFIX)
116-
]
117-
118-
def _get_deprecated_dashboards(self) -> list[tuple[str, str]]:
119-
return [
120-
(dashboard_name, dashboard_id)
121-
for dashboard_name, dashboard_id in self._install_state.dashboards.items()
122-
if dashboard_name.startswith(_RECON_PREFIX) and dashboard_name != RECON_METRICS_DASHBOARD_NAME
123-
]
95+
return list(self._install_state.dashboards.items())
12496

12597
def _remove_dashboards(self):
12698
logger.info("Removing reconciliation dashboards.")
127-
for dashboard_name, dashboard_id in self._get_dashboards():
99+
for dashboard_ref, dashboard_id in self._get_dashboards():
128100
try:
129-
logger.info(f"Removing dashboard {dashboard_name} with dashboard_id={dashboard_id}.")
130-
del self._install_state.dashboards[dashboard_name]
101+
logger.info(f"Removing dashboard with id={dashboard_id}.")
102+
del self._install_state.dashboards[dashboard_ref]
131103
self._ws.lakeview.trash(dashboard_id)
132104
except (InvalidParameterValue, NotFound):
133-
logger.warning(f"Dashboard `{dashboard_name}` doesn't exist anymore for some reason.")
105+
logger.warning(f"Dashboard with id={dashboard_id} doesn't exist anymore for some reason.")
134106
continue
135107

136108
def _deploy_jobs(self, recon_config: ReconcileConfig, remorph_wheel_path: str):

src/databricks/labs/remorph/resources/reconcile/dashboards/Remorph-Reconciliation.lvdash.json

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Aggregates Reconcile Table Metrics
2+
### It provides the following information:
3+
4+
* Mismatch
5+
* Missing in Source
6+
* Missing in Target
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
columns:
2+
- recon_id
3+
- dd_recon_id
4+
type: MULTI_SELECT
5+
title: Recon Id
6+
width: 2
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
columns:
2+
- executed_by
3+
type: MULTI_SELECT
4+
title: Executed by
5+
width: 2
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
columns:
2+
- start_ts
3+
title: Started At
4+
type: DATE_RANGE_PICKER
5+
width: 2
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
columns:
2+
- source_type
3+
type: MULTI_SELECT
4+
title: Source Type
5+
width: 2

0 commit comments

Comments
 (0)