-
Notifications
You must be signed in to change notification settings - Fork 216
Show the result builder as a node in the Hamilton UI #1678
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7fd7183
8012da5
c8bfcfa
a6ad703
ab19de6
f09353f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| # Licensed to the Apache Software Foundation (ASF) under one | ||
| # or more contributor license agreements. See the NOTICE file | ||
| # distributed with this work for additional information | ||
| # regarding copyright ownership. The ASF licenses this file | ||
| # to you under the Apache License, Version 2.0 (the | ||
| # "License"); you may not use this file except in compliance | ||
| # with the License. You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, | ||
| # software distributed under the License is distributed on an | ||
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| # KIND, either express or implied. See the License for the | ||
| # specific language governing permissions and limitations | ||
| # under the License. | ||
|
|
||
| # Generated by Django 5.2.15 on 2026-08-02 05:16 | ||
|
|
||
| import django.contrib.postgres.fields | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| dependencies = [ | ||
| ("trackingserver_template", "0002_alter_dagtemplate_unique_together"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AlterField( | ||
| model_name="nodetemplate", | ||
| name="classifications", | ||
| field=django.contrib.postgres.fields.ArrayField( | ||
| base_field=models.CharField( | ||
| choices=[ | ||
| ("transform", "Transform"), | ||
| ("data_saver", "DataSaver"), | ||
| ("data_loader", "DataLoader"), | ||
| ("input", "Input"), | ||
| ("placeholder", "Placeholder"), | ||
| ("result_builder", "ResultBuilder"), | ||
| ] | ||
| ), | ||
| size=None, | ||
| ), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,7 +29,7 @@ | |
| except ImportError: | ||
| UTC = timezone.utc | ||
|
|
||
| from collections.abc import Callable | ||
| from collections.abc import Callable, Mapping | ||
| from types import ModuleType | ||
| from typing import Any | ||
|
|
||
|
|
@@ -58,6 +58,105 @@ def get_node_name(node_: node.Node, task_id: str | None) -> str: | |
| LONG_SCALE = float(0xFFFFFFFFFFFFFFF) | ||
|
|
||
|
|
||
| def _result_attribute(node_name: str, name: str, observation: dict) -> dict: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: As noted in the PR description, these helpers overlap with the inline attribute-shaping in both trackers' |
||
| """Shapes one observation into the attribute dict the tracking API expects.""" | ||
| return dict( | ||
| node_name=node_name, | ||
| name=name, | ||
| type=observation["observability_type"], | ||
| # 0.0.3 -> 3 | ||
| schema_version=int(observation["observability_schema_version"].split(".")[-1]), | ||
| value=observation["observability_value"], | ||
| attribute_role="result_summary", | ||
| ) | ||
|
|
||
|
|
||
| def _result_attributes( | ||
| node_name: str, result_summary: dict, schema: dict | None, additional: list[dict] | ||
| ) -> list[dict]: | ||
| """Builds the attribute list for a successful task run. | ||
|
|
||
| `result_summary` is first because the order influences UI display order. | ||
| """ | ||
| others = ([schema] if schema is not None else []) + additional | ||
| return [_result_attribute(node_name, "result_summary", result_summary)] + [ | ||
| # retrieve name if specified | ||
| _result_attribute(node_name, other.get("name", f"Attribute {i + 1}"), other) | ||
| for i, other in enumerate(others) | ||
| ] | ||
|
|
||
|
|
||
| def _observability_failure_summary() -> dict: | ||
| """The result summary used when profiling the result did not produce one.""" | ||
| return { | ||
| "observability_type": "observability_failure", | ||
| "observability_schema_version": "0.0.3", | ||
| "observability_value": { | ||
| "type": str(str), | ||
| "value": "Failed to process result.", | ||
| }, | ||
| } | ||
|
|
||
|
|
||
| def _result_builder_dependencies(results: Any, final_vars: list[str]) -> list[str]: | ||
| """The requested outputs that actually reached the result being reported. | ||
|
|
||
| Narrowing is only sound when the result is keyed by node name, as ``DictResult`` and the raw | ||
| dict ``materialize`` hands over both are. A result that cannot say what went into it -- a | ||
| dataframe, a custom builder's own dict -- keeps the full list rather than be credited with | ||
| nothing. | ||
|
|
||
| :param results: What the driver passed to ``post_graph_execute``. | ||
| :param final_vars: The outputs requested at ``pre_graph_execute``. | ||
| :return: The node names to record as this run's dependencies, always a subset of | ||
| ``final_vars``. | ||
| """ | ||
| if isinstance(results, Mapping) and set(results).issubset(final_vars): | ||
| return [var for var in final_vars if var in results] | ||
| return final_vars | ||
|
|
||
|
|
||
| def _result_builder_payload( | ||
| results: Any, timestamp: datetime.datetime, final_vars: list[str] | ||
| ) -> tuple[TaskRun, list[dict], dict]: | ||
| """Builds everything the synthetic ``_result_builder`` task run needs to be sent. | ||
|
|
||
| Free of I/O, so the sync and async trackers can share it and each send it their own way. | ||
|
|
||
| Not guarded on ``results`` being non-None: a result builder with a side effect and no return | ||
| value still ran. See "The result builder node" in docs/hamilton-ui/ui.rst for which execution | ||
| paths hand this a built result and which hand it the raw output dict. | ||
|
|
||
| :param results: The combined result the driver produced. | ||
| :param timestamp: Time to stamp the task run with -- the builder runs between the last | ||
| node and ``post_graph_execute``, and its duration is not observable. | ||
| :param final_vars: The outputs this run asked for, used where the result cannot say. | ||
| :return: The task run, its attributes, and the task update to send. | ||
| """ | ||
| node_name = driver.RESULT_BUILDER_NODE_NAME | ||
| # process_result only reads `.name` and `.tags`; there is no real node to pass. | ||
| stand_in = node.Node(node_name, Any, callabl=lambda: None) | ||
| task_run = TaskRun(node_name=node_name, is_in_sample=True) | ||
| task_run.status = Status.SUCCESS | ||
| task_run.start_time = timestamp | ||
| task_run.end_time = timestamp | ||
| task_run.result_type = type(results) | ||
| result_summary, schema, additional_attributes = runs.process_result(results, stand_in) | ||
| if result_summary is None: | ||
| result_summary = _observability_failure_summary() | ||
| task_run.result_summary = result_summary | ||
| attributes = _result_attributes(node_name, result_summary, schema, additional_attributes) | ||
| task_update = dict( | ||
| node_template_name=node_name, | ||
| node_name=node_name, | ||
| realized_dependencies=_result_builder_dependencies(results, final_vars), | ||
| status=task_run.status, | ||
| start_time=task_run.start_time, | ||
| end_time=task_run.end_time, | ||
| ) | ||
| return task_run, attributes, task_update | ||
|
|
||
|
|
||
| class HamiltonTracker( | ||
| base.BasePostGraphConstruct, | ||
| base.BasePreGraphExecute, | ||
|
|
@@ -122,6 +221,8 @@ def __init__( | |
| self.tracking_states = {} | ||
| self.dw_run_ids = {} | ||
| self.task_runs = {} | ||
| # requested outputs per run -- the result-builder node's per-run dependencies | ||
| self.final_vars = {} | ||
| super().__init__() | ||
| # set this to a float to sample blocks. 0.1 means 10% of blocks will be sampled. | ||
| # set this to an int to sample blocks by modulo. | ||
|
|
@@ -147,14 +248,16 @@ def post_graph_construct( | |
| return | ||
| module_hash = driver._get_modules_hash(modules) | ||
| vcs_info = driver._derive_version_control_info(module_hash) | ||
| dag_hash = driver.hash_dag(graph) | ||
| dag_hash = driver.hash_dag(graph, include_result_builder=True) | ||
| code_hash = driver.hash_dag_modules(graph, modules) | ||
| dag_template_id = self.client.register_dag_template_if_not_exists( | ||
| project_id=self.project_id, | ||
| dag_hash=dag_hash, | ||
| code_hash=code_hash, | ||
| name=self.dag_name, | ||
| nodes=driver._extract_node_templates_from_function_graph(graph), | ||
| nodes=driver._extract_node_templates_from_function_graph( | ||
| graph, include_result_builder=True | ||
| ), | ||
| code_artifacts=driver.extract_code_artifacts_from_function_graph( | ||
| graph, vcs_info, vcs_info.local_repo_base_path | ||
| ), | ||
|
|
@@ -191,6 +294,7 @@ def pre_graph_execute( | |
| ) | ||
| self.dw_run_ids[run_id] = dw_run_id | ||
| self.task_runs[run_id] = {} | ||
| self.final_vars[run_id] = final_vars | ||
| logger.warning( | ||
| f"\nCapturing execution run. Results can be found at " | ||
| f"{self.hamilton_ui_url}/dashboard/project/{self.project_id}/runs/{dw_run_id}\n" | ||
|
|
@@ -365,6 +469,29 @@ def post_node_execute( | |
| in_samples=[task_run.is_in_sample for _ in attributes], | ||
| ) | ||
|
|
||
| def _emit_result_builder_task_run( | ||
| self, run_id: str, results: Any, timestamp: datetime.datetime | ||
| ): | ||
| """Emits the task run for the synthetic ``_result_builder`` node. | ||
|
|
||
| Failures are logged and swallowed: this runs before ``log_dag_run_end``, and an | ||
| otherwise-successful run should not be left rendering as still-running because | ||
| profiling or sending the combined result went wrong. | ||
| """ | ||
| try: | ||
| task_run, attributes, task_update = _result_builder_payload( | ||
| results, timestamp, self.final_vars.get(run_id, []) | ||
| ) | ||
| self.tracking_states[run_id].update_task(task_run.node_name, task_run) | ||
| self.client.update_tasks( | ||
| self.dw_run_ids[run_id], | ||
| attributes=attributes, | ||
| task_updates=[task_update for _ in attributes], | ||
| in_samples=[True for _ in attributes], | ||
| ) | ||
| except Exception: | ||
| logger.exception("Failed to emit the %s task run.", driver.RESULT_BUILDER_NODE_NAME) | ||
|
|
||
| def post_graph_execute( | ||
| self, | ||
| run_id: str, | ||
|
|
@@ -395,6 +522,8 @@ def post_graph_execute( | |
| task_run.error = ["Run was likely aborted."] | ||
| if task_run.end_time is None and task_run.status == Status.SUCCESS: | ||
| task_run.end_time = finally_block_time | ||
| elif driver._should_register_result_builder(graph): | ||
| self._emit_result_builder_task_run(run_id, results, finally_block_time) | ||
|
|
||
| self.client.log_dag_run_end( | ||
| dag_run_id=dw_run_id, | ||
|
|
@@ -441,6 +570,8 @@ def __init__( | |
| self.tracking_states = {} | ||
| self.dw_run_ids = {} | ||
| self.task_runs = {} | ||
| # requested outputs per run -- the result-builder node's per-run dependencies | ||
| self.final_vars = {} | ||
| self.initialized = False | ||
| super().__init__() | ||
|
|
||
|
|
@@ -481,14 +612,16 @@ async def post_graph_construct( | |
| return | ||
| module_hash = driver._get_modules_hash(modules) | ||
| vcs_info = driver._derive_version_control_info(module_hash) | ||
| dag_hash = driver.hash_dag(graph) | ||
| dag_hash = driver.hash_dag(graph, include_result_builder=True) | ||
| code_hash = driver.hash_dag_modules(graph, modules) | ||
| dag_template_id = await self.client.register_dag_template_if_not_exists( | ||
| project_id=self.project_id, | ||
| dag_hash=dag_hash, | ||
| code_hash=code_hash, | ||
| name=self.dag_name, | ||
| nodes=driver._extract_node_templates_from_function_graph(graph), | ||
| nodes=driver._extract_node_templates_from_function_graph( | ||
| graph, include_result_builder=True | ||
| ), | ||
| code_artifacts=driver.extract_code_artifacts_from_function_graph( | ||
| graph, vcs_info, vcs_info.local_repo_base_path | ||
| ), | ||
|
|
@@ -525,6 +658,7 @@ async def pre_graph_execute( | |
| ) | ||
| self.dw_run_ids[run_id] = dw_run_id | ||
| self.task_runs[run_id] = {} | ||
| self.final_vars[run_id] = final_vars | ||
|
|
||
| async def pre_node_execute( | ||
| self, run_id: str, node_: node.Node, kwargs: dict[str, Any], task_id: str | None = None | ||
|
|
@@ -642,6 +776,31 @@ async def post_node_execute( | |
| in_samples=[task_run.is_in_sample for _ in attributes], | ||
| ) | ||
|
|
||
| async def _emit_result_builder_task_run( | ||
| self, run_id: str, results: Any, timestamp: datetime.datetime | ||
| ): | ||
| """Emits the task run for the synthetic ``_result_builder`` node. | ||
|
|
||
| ``results`` here is always the raw output dict, never a built result: | ||
| ``async_driver.execute()`` awaits ``raw_execute()`` -- whose ``finally`` fires this hook | ||
| -- and only then calls ``do_build_result``, so the tracker cannot observe the builder. | ||
|
|
||
| Failures are logged and swallowed, as in the sync tracker. | ||
| """ | ||
| try: | ||
| task_run, attributes, task_update = _result_builder_payload( | ||
| results, timestamp, self.final_vars.get(run_id, []) | ||
| ) | ||
| self.tracking_states[run_id].update_task(task_run.node_name, task_run) | ||
| await self.client.update_tasks( | ||
| self.dw_run_ids[run_id], | ||
| attributes=attributes, | ||
| task_updates=[task_update for _ in attributes], | ||
| in_samples=[True for _ in attributes], | ||
| ) | ||
| except Exception: | ||
| logger.exception("Failed to emit the %s task run.", driver.RESULT_BUILDER_NODE_NAME) | ||
|
|
||
| async def post_graph_execute( | ||
| self, | ||
| run_id: str, | ||
|
|
@@ -670,6 +829,8 @@ async def post_graph_execute( | |
| task_run.error = ["Run was likely aborted."] | ||
| if task_run.end_time is None and task_run.status == Status.SUCCESS: | ||
| task_run.end_time = finally_block_time | ||
| elif driver._should_register_result_builder(graph): | ||
| await self._emit_result_builder_task_run(run_id, results, finally_block_time) | ||
|
|
||
| # TODO: only update things that have changed? | ||
| # self.client.update_tasks( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit / release note: This is documented clearly here. Since upgrading causes the first tracked run to register a new DAG-template version for every dataflow, could we also mention it in the release notes/CHANGELOG? That will keep operators from being surprised when new versions appear after the upgrade. Non-blocking.