apache-hamilton 1.90.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apache_hamilton-1.90.0.dev0.dist-info/METADATA +407 -0
- apache_hamilton-1.90.0.dev0.dist-info/RECORD +151 -0
- apache_hamilton-1.90.0.dev0.dist-info/WHEEL +4 -0
- apache_hamilton-1.90.0.dev0.dist-info/entry_points.txt +9 -0
- apache_hamilton-1.90.0.dev0.dist-info/licenses/DISCLAIMER +10 -0
- apache_hamilton-1.90.0.dev0.dist-info/licenses/LICENSE +228 -0
- apache_hamilton-1.90.0.dev0.dist-info/licenses/NOTICE +5 -0
- hamilton/__init__.py +24 -0
- hamilton/ad_hoc_utils.py +132 -0
- hamilton/async_driver.py +465 -0
- hamilton/base.py +466 -0
- hamilton/caching/__init__.py +16 -0
- hamilton/caching/adapter.py +1475 -0
- hamilton/caching/cache_key.py +70 -0
- hamilton/caching/fingerprinting.py +287 -0
- hamilton/caching/stores/__init__.py +16 -0
- hamilton/caching/stores/base.py +242 -0
- hamilton/caching/stores/file.py +140 -0
- hamilton/caching/stores/memory.py +297 -0
- hamilton/caching/stores/sqlite.py +282 -0
- hamilton/caching/stores/utils.py +40 -0
- hamilton/cli/__init__.py +16 -0
- hamilton/cli/__main__.py +328 -0
- hamilton/cli/commands.py +126 -0
- hamilton/cli/logic.py +338 -0
- hamilton/common/__init__.py +76 -0
- hamilton/contrib/__init__.py +41 -0
- hamilton/data_quality/__init__.py +16 -0
- hamilton/data_quality/base.py +198 -0
- hamilton/data_quality/default_validators.py +560 -0
- hamilton/data_quality/pandera_validators.py +121 -0
- hamilton/dataflows/__init__.py +726 -0
- hamilton/dataflows/template/README.md +25 -0
- hamilton/dataflows/template/__init__.py +54 -0
- hamilton/dataflows/template/author.md +28 -0
- hamilton/dataflows/template/requirements.txt +0 -0
- hamilton/dataflows/template/tags.json +7 -0
- hamilton/dataflows/template/valid_configs.jsonl +1 -0
- hamilton/dev_utils/__init__.py +16 -0
- hamilton/dev_utils/deprecation.py +204 -0
- hamilton/driver.py +2112 -0
- hamilton/execution/__init__.py +16 -0
- hamilton/execution/debugging_utils.py +56 -0
- hamilton/execution/executors.py +502 -0
- hamilton/execution/graph_functions.py +421 -0
- hamilton/execution/grouping.py +430 -0
- hamilton/execution/state.py +539 -0
- hamilton/experimental/__init__.py +27 -0
- hamilton/experimental/databackend.py +61 -0
- hamilton/experimental/decorators/__init__.py +16 -0
- hamilton/experimental/decorators/parameterize_frame.py +233 -0
- hamilton/experimental/h_async.py +29 -0
- hamilton/experimental/h_cache.py +413 -0
- hamilton/experimental/h_dask.py +28 -0
- hamilton/experimental/h_databackends.py +174 -0
- hamilton/experimental/h_ray.py +28 -0
- hamilton/experimental/h_spark.py +32 -0
- hamilton/function_modifiers/README +40 -0
- hamilton/function_modifiers/__init__.py +121 -0
- hamilton/function_modifiers/adapters.py +900 -0
- hamilton/function_modifiers/base.py +859 -0
- hamilton/function_modifiers/configuration.py +310 -0
- hamilton/function_modifiers/delayed.py +202 -0
- hamilton/function_modifiers/dependencies.py +246 -0
- hamilton/function_modifiers/expanders.py +1230 -0
- hamilton/function_modifiers/macros.py +1634 -0
- hamilton/function_modifiers/metadata.py +434 -0
- hamilton/function_modifiers/recursive.py +908 -0
- hamilton/function_modifiers/validation.py +289 -0
- hamilton/function_modifiers_base.py +31 -0
- hamilton/graph.py +1153 -0
- hamilton/graph_types.py +264 -0
- hamilton/graph_utils.py +41 -0
- hamilton/htypes.py +450 -0
- hamilton/io/__init__.py +32 -0
- hamilton/io/data_adapters.py +216 -0
- hamilton/io/default_data_loaders.py +224 -0
- hamilton/io/materialization.py +500 -0
- hamilton/io/utils.py +158 -0
- hamilton/lifecycle/__init__.py +67 -0
- hamilton/lifecycle/api.py +833 -0
- hamilton/lifecycle/base.py +1130 -0
- hamilton/lifecycle/default.py +802 -0
- hamilton/log_setup.py +47 -0
- hamilton/models.py +92 -0
- hamilton/node.py +449 -0
- hamilton/plugins/README.md +48 -0
- hamilton/plugins/__init__.py +16 -0
- hamilton/plugins/dask_extensions.py +47 -0
- hamilton/plugins/dlt_extensions.py +161 -0
- hamilton/plugins/geopandas_extensions.py +49 -0
- hamilton/plugins/h_dask.py +331 -0
- hamilton/plugins/h_ddog.py +522 -0
- hamilton/plugins/h_diskcache.py +163 -0
- hamilton/plugins/h_experiments/__init__.py +22 -0
- hamilton/plugins/h_experiments/__main__.py +62 -0
- hamilton/plugins/h_experiments/cache.py +39 -0
- hamilton/plugins/h_experiments/data_model.py +68 -0
- hamilton/plugins/h_experiments/hook.py +219 -0
- hamilton/plugins/h_experiments/server.py +375 -0
- hamilton/plugins/h_kedro.py +152 -0
- hamilton/plugins/h_logging.py +454 -0
- hamilton/plugins/h_mcp/__init__.py +28 -0
- hamilton/plugins/h_mcp/__main__.py +33 -0
- hamilton/plugins/h_mcp/_helpers.py +129 -0
- hamilton/plugins/h_mcp/_templates.py +417 -0
- hamilton/plugins/h_mcp/server.py +328 -0
- hamilton/plugins/h_mlflow.py +335 -0
- hamilton/plugins/h_narwhals.py +134 -0
- hamilton/plugins/h_openlineage.py +400 -0
- hamilton/plugins/h_opentelemetry.py +167 -0
- hamilton/plugins/h_pandas.py +257 -0
- hamilton/plugins/h_pandera.py +117 -0
- hamilton/plugins/h_polars.py +304 -0
- hamilton/plugins/h_polars_lazyframe.py +282 -0
- hamilton/plugins/h_pyarrow.py +56 -0
- hamilton/plugins/h_pydantic.py +127 -0
- hamilton/plugins/h_ray.py +242 -0
- hamilton/plugins/h_rich.py +142 -0
- hamilton/plugins/h_schema.py +493 -0
- hamilton/plugins/h_slack.py +100 -0
- hamilton/plugins/h_spark.py +1380 -0
- hamilton/plugins/h_threadpool.py +125 -0
- hamilton/plugins/h_tqdm.py +122 -0
- hamilton/plugins/h_vaex.py +129 -0
- hamilton/plugins/huggingface_extensions.py +236 -0
- hamilton/plugins/ibis_extensions.py +93 -0
- hamilton/plugins/jupyter_magic.py +622 -0
- hamilton/plugins/kedro_extensions.py +117 -0
- hamilton/plugins/lightgbm_extensions.py +99 -0
- hamilton/plugins/matplotlib_extensions.py +108 -0
- hamilton/plugins/mlflow_extensions.py +216 -0
- hamilton/plugins/numpy_extensions.py +105 -0
- hamilton/plugins/pandas_extensions.py +1763 -0
- hamilton/plugins/plotly_extensions.py +150 -0
- hamilton/plugins/polars_extensions.py +71 -0
- hamilton/plugins/polars_implementations.py +25 -0
- hamilton/plugins/polars_lazyframe_extensions.py +302 -0
- hamilton/plugins/polars_post_1_0_0_extensions.py +877 -0
- hamilton/plugins/polars_pre_1_0_0_extension.py +836 -0
- hamilton/plugins/pydantic_extensions.py +98 -0
- hamilton/plugins/pyspark_pandas_extensions.py +47 -0
- hamilton/plugins/sklearn_plot_extensions.py +129 -0
- hamilton/plugins/spark_extensions.py +105 -0
- hamilton/plugins/vaex_extensions.py +51 -0
- hamilton/plugins/xgboost_extensions.py +91 -0
- hamilton/plugins/yaml_extensions.py +89 -0
- hamilton/registry.py +254 -0
- hamilton/settings.py +18 -0
- hamilton/telemetry.py +50 -0
- hamilton/version.py +18 -0
|
@@ -0,0 +1,1475 @@
|
|
|
1
|
+
# Licensed to the Apache Software Foundation (ASF) under one
|
|
2
|
+
# or more contributor license agreements. See the NOTICE file
|
|
3
|
+
# distributed with this work for additional information
|
|
4
|
+
# regarding copyright ownership. The ASF licenses this file
|
|
5
|
+
# to you under the Apache License, Version 2.0 (the
|
|
6
|
+
# "License"); you may not use this file except in compliance
|
|
7
|
+
# with the License. You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
|
12
|
+
# software distributed under the License is distributed on an
|
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
14
|
+
# KIND, either express or implied. See the License for the
|
|
15
|
+
# specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
import collections
|
|
19
|
+
import dataclasses
|
|
20
|
+
import enum
|
|
21
|
+
import functools
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import pathlib
|
|
25
|
+
import uuid
|
|
26
|
+
from collections.abc import Callable, Collection
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from typing import Any, Literal, TypeVar
|
|
29
|
+
|
|
30
|
+
import hamilton.node
|
|
31
|
+
from hamilton import graph_types
|
|
32
|
+
from hamilton.caching import fingerprinting
|
|
33
|
+
from hamilton.caching.cache_key import create_cache_key
|
|
34
|
+
from hamilton.caching.stores.base import (
|
|
35
|
+
MetadataStore,
|
|
36
|
+
ResultRetrievalError,
|
|
37
|
+
ResultStore,
|
|
38
|
+
search_data_adapter_registry,
|
|
39
|
+
)
|
|
40
|
+
from hamilton.caching.stores.file import FileResultStore
|
|
41
|
+
from hamilton.caching.stores.sqlite import SQLiteMetadataStore
|
|
42
|
+
from hamilton.function_modifiers.metadata import cache as cache_decorator
|
|
43
|
+
from hamilton.graph import FunctionGraph
|
|
44
|
+
from hamilton.lifecycle.base import (
|
|
45
|
+
BaseDoNodeExecute,
|
|
46
|
+
BasePostNodeExecute,
|
|
47
|
+
BasePreGraphExecute,
|
|
48
|
+
BasePreNodeExecute,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger("hamilton.caching")
|
|
52
|
+
|
|
53
|
+
SENTINEL = object()
|
|
54
|
+
S = TypeVar("S", object, object)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
CACHING_BEHAVIORS = Literal["default", "recompute", "disable", "ignore"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CachingBehavior(enum.Enum):
|
|
61
|
+
"""Behavior applied by the caching adapter
|
|
62
|
+
|
|
63
|
+
DEFAULT:
|
|
64
|
+
Try to retrieve result from cache instead of executing the node. If the node is executed, store the result.
|
|
65
|
+
Compute the result data version and store it too.
|
|
66
|
+
|
|
67
|
+
RECOMPUTE:
|
|
68
|
+
Don't try to retrieve result from cache and always execute the node. Otherwise, behaves as default.
|
|
69
|
+
Useful when nodes are stochastic (e.g., model training) or interact with external
|
|
70
|
+
components (e.g., read from database).
|
|
71
|
+
|
|
72
|
+
DISABLE:
|
|
73
|
+
Node is executed as if the caching feature wasn't enabled.
|
|
74
|
+
It never tries to retrieve results. Results are never stored nor versioned.
|
|
75
|
+
Behaves like IGNORE, but the node remains a dependency for downstream nodes.
|
|
76
|
+
This means downstream cache lookup will likely fail systematically (i.e., if the cache is empty).
|
|
77
|
+
|
|
78
|
+
IGNORE:
|
|
79
|
+
Node is executed as if the caching feature wasn't enable.
|
|
80
|
+
It never tries to retrieve results. Results are never stored nor versioned.
|
|
81
|
+
IGNORE means downstream nodes will ignore this node as a dependency for lookup.
|
|
82
|
+
Ignoring clients and connections can be useful since they shouldn't directly impact the downstream results.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
DEFAULT = 1
|
|
86
|
+
RECOMPUTE = 2
|
|
87
|
+
DISABLE = 3
|
|
88
|
+
IGNORE = 4
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_string(cls, string: str) -> "CachingBehavior":
|
|
92
|
+
"""Create a caching behavior from a string of the enum value. This is
|
|
93
|
+
leveraged by the ``hamilton.lifecycle.caching.SmartCacheAdapter`` and
|
|
94
|
+
the ``hamilton.function_modifiers.metadata.cache`` decorator.
|
|
95
|
+
|
|
96
|
+
.. code-block::
|
|
97
|
+
|
|
98
|
+
CachingBehavior.from_string("recompute")
|
|
99
|
+
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
return cls[string.upper()]
|
|
103
|
+
except KeyError as e:
|
|
104
|
+
raise KeyError(f"{string} is an invalid `CachingBehavior` value") from e
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class NodeRoleInTaskExecution(enum.Enum):
|
|
108
|
+
"""Identify the role of a node in task-based execution, in particular when
|
|
109
|
+
``Parallelizable/Collect`` are used.
|
|
110
|
+
|
|
111
|
+
NOTE This is an internal construct and it will likely change in the future.
|
|
112
|
+
|
|
113
|
+
STANDARD: when task-based execution is not used. All nodes and dependencies are STANDARD.
|
|
114
|
+
EXPAND: node with type ``Parallelizable``. It returns an iterator where individual items need to be handled.
|
|
115
|
+
Dependencies can only be OUTSIDE.
|
|
116
|
+
COLLECT: node with type ``Collect``. It returns an iterable where individual items need to be handled.
|
|
117
|
+
Dependencies can be INSIDE, OUTSIDE, or EXPAND
|
|
118
|
+
OUTSIDE: "outside" of ``Parallelizable/Collect`` paths; handled like STANDARD in most cases.
|
|
119
|
+
Dependencies can be OUTSIDE or COLLECT
|
|
120
|
+
INSIDE: "inside" or "between" a ``Parallelizable/Collect`` nodes.
|
|
121
|
+
Dependencies can be INSIDE, OUTSIDE, or EXPAND.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
STANDARD = 1
|
|
125
|
+
EXPAND = 2
|
|
126
|
+
COLLECT = 3
|
|
127
|
+
OUTSIDE = 4
|
|
128
|
+
INSIDE = 5
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CachingEventType(enum.Enum):
|
|
132
|
+
"""Event types logged by the caching adapter"""
|
|
133
|
+
|
|
134
|
+
GET_DATA_VERSION = "get_data_version"
|
|
135
|
+
SET_DATA_VERSION = "set_data_version"
|
|
136
|
+
GET_CACHE_KEY = "get_cache_key"
|
|
137
|
+
SET_CACHE_KEY = "set_cache_key"
|
|
138
|
+
GET_RESULT = "get_result"
|
|
139
|
+
SET_RESULT = "set_result"
|
|
140
|
+
MISSING_RESULT = "missing_result"
|
|
141
|
+
FAILED_RETRIEVAL = "failed_retrieval"
|
|
142
|
+
EXECUTE_NODE = "execute_node"
|
|
143
|
+
FAILED_EXECUTION = "failed_execution"
|
|
144
|
+
RESOLVE_BEHAVIOR = "resolve_behavior"
|
|
145
|
+
UNHASHABLE_DATA_VERSION = "unhashable_data_version"
|
|
146
|
+
IS_OVERRIDE = "is_override"
|
|
147
|
+
IS_INPUT = "is_input"
|
|
148
|
+
IS_FINAL_VAR = "is_final_var"
|
|
149
|
+
IS_DEFAULT_PARAMETER_VALUE = "is_default_parameter_value"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclasses.dataclass(frozen=True)
|
|
153
|
+
class CachingEvent:
|
|
154
|
+
"""Event logged by the caching adapter"""
|
|
155
|
+
|
|
156
|
+
run_id: str
|
|
157
|
+
actor: Literal["adapter", "metadata_store", "result_store"]
|
|
158
|
+
event_type: CachingEventType
|
|
159
|
+
node_name: str
|
|
160
|
+
task_id: str | None = None
|
|
161
|
+
msg: str | None = None
|
|
162
|
+
value: Any | None = None
|
|
163
|
+
timestamp: float = dataclasses.field(
|
|
164
|
+
default_factory=lambda: datetime.now(timezone.utc).timestamp()
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def __str__(self) -> str:
|
|
168
|
+
"""Create a human-readable string format for `print()`"""
|
|
169
|
+
|
|
170
|
+
string = self.node_name
|
|
171
|
+
if self.task_id is not None:
|
|
172
|
+
string += f"::{self.task_id}"
|
|
173
|
+
string += f"::{self.actor}"
|
|
174
|
+
string += f"::{self.event_type.value}"
|
|
175
|
+
if self.msg: # this catches None and empty strings
|
|
176
|
+
string += f"::{self.msg}"
|
|
177
|
+
|
|
178
|
+
return string
|
|
179
|
+
|
|
180
|
+
def as_dict(self):
|
|
181
|
+
return dict(
|
|
182
|
+
run_id=self.run_id,
|
|
183
|
+
timestamp=self.timestamp,
|
|
184
|
+
node_name=self.node_name,
|
|
185
|
+
task_id=self.task_id,
|
|
186
|
+
actor=self.actor,
|
|
187
|
+
event_type=self.event_type.value,
|
|
188
|
+
msg=self.msg,
|
|
189
|
+
value=str(self.value) if self.value else self.value,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# TODO we could add a "driver-level" kwarg to specify the cache format (e.g., parquet, JSON, etc.)
|
|
194
|
+
class HamiltonCacheAdapter(
|
|
195
|
+
BaseDoNodeExecute, BasePreGraphExecute, BasePostNodeExecute, BasePreNodeExecute
|
|
196
|
+
):
|
|
197
|
+
"""Adapter enabling Hamilton's caching feature through ``Builder.with_cache()``
|
|
198
|
+
|
|
199
|
+
.. code-block:: python
|
|
200
|
+
|
|
201
|
+
from hamilton import driver
|
|
202
|
+
import my_dataflow
|
|
203
|
+
|
|
204
|
+
dr = (
|
|
205
|
+
driver.Builder()
|
|
206
|
+
.with_modules(my_dataflow)
|
|
207
|
+
.with_cache()
|
|
208
|
+
.build()
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# then, you can access the adapter via
|
|
212
|
+
dr.cache
|
|
213
|
+
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
path: str | pathlib.Path = ".hamilton_cache",
|
|
219
|
+
metadata_store: MetadataStore | None = None,
|
|
220
|
+
result_store: ResultStore | None = None,
|
|
221
|
+
default: Literal[True] | Collection[str] | None = None,
|
|
222
|
+
recompute: Literal[True] | Collection[str] | None = None,
|
|
223
|
+
ignore: Literal[True] | Collection[str] | None = None,
|
|
224
|
+
disable: Literal[True] | Collection[str] | None = None,
|
|
225
|
+
default_behavior: CACHING_BEHAVIORS | None = None,
|
|
226
|
+
default_loader_behavior: CACHING_BEHAVIORS | None = None,
|
|
227
|
+
default_saver_behavior: CACHING_BEHAVIORS | None = None,
|
|
228
|
+
log_to_file: bool = False,
|
|
229
|
+
**kwargs,
|
|
230
|
+
):
|
|
231
|
+
"""Initialize the cache adapter.
|
|
232
|
+
|
|
233
|
+
:param path: path where the cache metadata and results will be stored
|
|
234
|
+
:param metadata_store: BaseStore handling metadata for the cache adapter
|
|
235
|
+
:param result_store: BaseStore caching dataflow execution results
|
|
236
|
+
:param default: Set caching behavior to DEFAULT for specified node names. If True, apply to all nodes.
|
|
237
|
+
:param recompute: Set caching behavior to RECOMPUTE for specified node names. If True, apply to all nodes.
|
|
238
|
+
:param ignore: Set caching behavior to IGNORE for specified node names. If True, apply to all nodes.
|
|
239
|
+
:param disable: Set caching behavior to DISABLE for specified node names. If True, apply to all nodes.
|
|
240
|
+
:param default_behavior: Set the default caching behavior.
|
|
241
|
+
:param default_loader_behavior: Set the default caching behavior `DataLoader` nodes.
|
|
242
|
+
:param default_saver_behavior: Set the default caching behavior `DataSaver` nodes.
|
|
243
|
+
:param log_to_file: If True, append cache event logs as they happen in JSONL format.
|
|
244
|
+
"""
|
|
245
|
+
self._path = path
|
|
246
|
+
self.metadata_store = (
|
|
247
|
+
metadata_store if metadata_store is not None else SQLiteMetadataStore(path=path)
|
|
248
|
+
)
|
|
249
|
+
self.result_store = (
|
|
250
|
+
result_store if result_store is not None else FileResultStore(path=str(path))
|
|
251
|
+
)
|
|
252
|
+
self.log_to_file = log_to_file
|
|
253
|
+
|
|
254
|
+
if sum([default is True, recompute is True, disable is True, ignore is True]) > 1:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
"Can only set one of (`default`, `recompute`, `disable`, `ignore`) to True. Please pass mutually exclusive sets of node names"
|
|
257
|
+
)
|
|
258
|
+
self._default = default
|
|
259
|
+
self._recompute = recompute
|
|
260
|
+
self._disable = disable
|
|
261
|
+
self._ignore = ignore
|
|
262
|
+
self.default_behavior = default_behavior
|
|
263
|
+
self.default_loader_behavior = default_loader_behavior
|
|
264
|
+
self.default_saver_behavior = default_saver_behavior
|
|
265
|
+
|
|
266
|
+
# attributes populated at execution time
|
|
267
|
+
self.run_ids: list[str] = []
|
|
268
|
+
self._fn_graphs: dict[str, FunctionGraph] = {} # {run_id: graph}
|
|
269
|
+
self._data_savers: dict[str, Collection[str]] = {} # {run_id: list[node_name]}
|
|
270
|
+
self._data_loaders: dict[str, Collection[str]] = {} # {run_id: list[node_name]}
|
|
271
|
+
self.behaviors: dict[
|
|
272
|
+
str, dict[str, CachingBehavior]
|
|
273
|
+
] = {} # {run_id: {node_name: behavior}}
|
|
274
|
+
self.data_versions: dict[
|
|
275
|
+
str, dict[str, str | dict[str, str]]
|
|
276
|
+
] = {} # {run_id: {node_name: version}} or {run_id: {node_name: {task_id: version}}}
|
|
277
|
+
self.code_versions: dict[str, dict[str, str]] = {} # {run_id: {node_name: version}}
|
|
278
|
+
self.cache_keys: dict[
|
|
279
|
+
str, dict[str, str | dict[str, str]]
|
|
280
|
+
] = {} # {run_id: {node_name: key}} or {run_id: {node_name: {task_id: key}}}
|
|
281
|
+
self._logs: dict[str, list[CachingEvent]] = {} # {run_id: [logs]}
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def last_run_id(self):
|
|
285
|
+
"""Run id of the last started run. Not necessarily the last to complete."""
|
|
286
|
+
return self.run_ids[-1]
|
|
287
|
+
|
|
288
|
+
def __getstate__(self) -> dict:
|
|
289
|
+
"""Serialization method required for multiprocessing and multithreading
|
|
290
|
+
when using task-based execution with `Parallelizable/Collect`
|
|
291
|
+
"""
|
|
292
|
+
state = self.__dict__.copy()
|
|
293
|
+
# store the classes to reinstantiate the same backend in __setstate__
|
|
294
|
+
state["metadata_store_cls"] = self.metadata_store.__class__
|
|
295
|
+
state["metadata_store_init"] = self.metadata_store.__getstate__()
|
|
296
|
+
state["result_store_cls"] = self.result_store.__class__
|
|
297
|
+
state["result_store_init"] = self.result_store.__getstate__()
|
|
298
|
+
del state["metadata_store"]
|
|
299
|
+
del state["result_store"]
|
|
300
|
+
return state
|
|
301
|
+
|
|
302
|
+
def __setstate__(self, state: dict) -> None:
|
|
303
|
+
"""Serialization method required for multiprocessing and multithreading
|
|
304
|
+
when using task-based execution with `Parallelizable/Collect`.
|
|
305
|
+
|
|
306
|
+
Create new instances of metadata and result stores to have one connection
|
|
307
|
+
per thread.
|
|
308
|
+
"""
|
|
309
|
+
# instantiate the backend from the class, then delete the attribute before
|
|
310
|
+
# setting it on the adapter instance.
|
|
311
|
+
self.metadata_store = state["metadata_store_cls"](**state["metadata_store_init"])
|
|
312
|
+
self.result_store = state["result_store_cls"](**state["result_store_init"])
|
|
313
|
+
del state["metadata_store_cls"]
|
|
314
|
+
del state["result_store_cls"]
|
|
315
|
+
self.__dict__.update(state)
|
|
316
|
+
|
|
317
|
+
def _log_event(
|
|
318
|
+
self,
|
|
319
|
+
run_id: str,
|
|
320
|
+
node_name: str,
|
|
321
|
+
actor: Literal["adapter", "metadata_store", "result_store"],
|
|
322
|
+
event_type: CachingEventType,
|
|
323
|
+
msg: str | None = None,
|
|
324
|
+
value: Any | None = None,
|
|
325
|
+
task_id: str | None = None,
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Add a single event to logs stored in state, keyed by run_id
|
|
328
|
+
|
|
329
|
+
If global log level is set to logging.INFO, only log if event type is GET_RESULT or EXECUTE_NODE;
|
|
330
|
+
If it is set to logging.DEBUG, log all events.
|
|
331
|
+
|
|
332
|
+
If `SmartCacheAdapter.log_to_file` is set to True, log all events to a file in JSONL format.
|
|
333
|
+
|
|
334
|
+
:param node_name: name of the node associated with the event
|
|
335
|
+
:param task_id: optional identifier when using task-based execution. (node_name, task_id) is a primary key
|
|
336
|
+
:param actor: component responsible for the event
|
|
337
|
+
:param event_type: enum specifying what type of event (execute, retrieve, etc.)
|
|
338
|
+
:param msg: additional message to display in the logs (e.g., via terminal)
|
|
339
|
+
:param value: arbitrary value to include (typically a string for data version, code version, cache_key). Must be small and JSON-serializable.
|
|
340
|
+
"""
|
|
341
|
+
event = CachingEvent(
|
|
342
|
+
run_id=run_id,
|
|
343
|
+
node_name=node_name,
|
|
344
|
+
task_id=task_id,
|
|
345
|
+
actor=actor,
|
|
346
|
+
event_type=event_type,
|
|
347
|
+
msg=msg,
|
|
348
|
+
value=value,
|
|
349
|
+
)
|
|
350
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
351
|
+
logger.debug(f"{event.__str__()}")
|
|
352
|
+
elif logger.isEnabledFor(logging.INFO):
|
|
353
|
+
if event.event_type in (CachingEventType.GET_RESULT, CachingEventType.EXECUTE_NODE):
|
|
354
|
+
logger.info(f"{event.__str__()}")
|
|
355
|
+
|
|
356
|
+
self._logs[run_id].append(event)
|
|
357
|
+
|
|
358
|
+
if self.log_to_file:
|
|
359
|
+
log_file_path = pathlib.Path(self.metadata_store._directory, "cache_logs.jsonl")
|
|
360
|
+
json_line = json.dumps(event.as_dict())
|
|
361
|
+
with log_file_path.open("a") as f:
|
|
362
|
+
f.write(json_line + "\n")
|
|
363
|
+
|
|
364
|
+
def _log_by_node_name(
|
|
365
|
+
self, run_id: str, level: Literal["debug", "info"] = "info"
|
|
366
|
+
) -> dict[str, list[str]]:
|
|
367
|
+
"""For a given run, group logs to key them by ``node_name`` or ``(node_name, run_id)`` if applicable."""
|
|
368
|
+
run_logs = collections.defaultdict(list)
|
|
369
|
+
for event in self._logs[run_id]:
|
|
370
|
+
if level == "info":
|
|
371
|
+
if event.event_type not in (
|
|
372
|
+
CachingEventType.GET_RESULT,
|
|
373
|
+
CachingEventType.EXECUTE_NODE,
|
|
374
|
+
):
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
key = (event.node_name, event.task_id) if event.task_id else event.node_name
|
|
378
|
+
run_logs[key].append(event)
|
|
379
|
+
return dict(run_logs)
|
|
380
|
+
|
|
381
|
+
def logs(self, run_id: str | None = None, level: Literal["debug", "info"] = "info") -> dict:
|
|
382
|
+
"""Execution logs of the cache adapter.
|
|
383
|
+
|
|
384
|
+
:param run_id: If ``None``, return all logged runs. If provided a ``run_id``, group logs by node.
|
|
385
|
+
:param level: If ``"debug"`` log all events. If ``"info"`` only log if result is retrieved or executed.
|
|
386
|
+
:return: a mapping between node/task and a list of logged events
|
|
387
|
+
|
|
388
|
+
.. code-block:: python
|
|
389
|
+
|
|
390
|
+
from hamilton import driver
|
|
391
|
+
import my_dataflow
|
|
392
|
+
|
|
393
|
+
dr = driver.Builder().with_modules(my_dataflow).with_cache().build()
|
|
394
|
+
dr.execute(...)
|
|
395
|
+
dr.execute(...)
|
|
396
|
+
|
|
397
|
+
all_logs = dr.cache.logs()
|
|
398
|
+
# all_logs is a dictionary with run_ids as keys and lists of CachingEvent as values.
|
|
399
|
+
# {
|
|
400
|
+
# run_id_1: [CachingEvent(...), CachingEvent(...)],
|
|
401
|
+
# run_id_2: [CachingEvent(...), CachingEvent(...)],
|
|
402
|
+
# }
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
run_logs = dr.cache.logs(run_id=dr.last_run_id)
|
|
406
|
+
# run_logs are keyed by ``node_name``
|
|
407
|
+
# {node_name: [CachingEvent(...), CachingEvent(...)], ...}
|
|
408
|
+
# or ``(node_name, task_id)`` if task-based execution is used.
|
|
409
|
+
# {(node_name_1, task_id_1): [CachingEvent(...), CachingEvent(...)], ...}
|
|
410
|
+
|
|
411
|
+
"""
|
|
412
|
+
if run_id:
|
|
413
|
+
return self._log_by_node_name(run_id=run_id, level=level)
|
|
414
|
+
|
|
415
|
+
logs = collections.defaultdict(list)
|
|
416
|
+
for run_id, run_logs in self._logs.items():
|
|
417
|
+
for event in run_logs:
|
|
418
|
+
if level == "info" and event.event_type not in (
|
|
419
|
+
CachingEventType.GET_RESULT,
|
|
420
|
+
CachingEventType.EXECUTE_NODE,
|
|
421
|
+
):
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
logs[run_id].append(event)
|
|
425
|
+
|
|
426
|
+
return dict(logs)
|
|
427
|
+
|
|
428
|
+
@staticmethod
|
|
429
|
+
def _view_run(
|
|
430
|
+
fn_graph: FunctionGraph,
|
|
431
|
+
logs,
|
|
432
|
+
final_vars: list[str],
|
|
433
|
+
inputs: dict,
|
|
434
|
+
overrides: dict,
|
|
435
|
+
output_file_path: str | None = None,
|
|
436
|
+
):
|
|
437
|
+
"""Create a Hamilton visualization of the execution and the cache hits/misses.
|
|
438
|
+
|
|
439
|
+
This leverages the ``custom_style_function`` feature internally.
|
|
440
|
+
"""
|
|
441
|
+
from hamilton.driver import Driver # avoid circular import
|
|
442
|
+
|
|
443
|
+
def _visualization_styling_function(*, node, node_class, logs):
|
|
444
|
+
"""Custom style function for the visualization."""
|
|
445
|
+
if any(
|
|
446
|
+
event.event_type == CachingEventType.GET_RESULT for event in logs.get(node.name, [])
|
|
447
|
+
):
|
|
448
|
+
style = (
|
|
449
|
+
{"penwidth": "3", "color": "#F06449", "fillcolor": "#ffffff"},
|
|
450
|
+
node_class,
|
|
451
|
+
"from cache",
|
|
452
|
+
)
|
|
453
|
+
else:
|
|
454
|
+
style = ({}, node_class, None)
|
|
455
|
+
|
|
456
|
+
return style
|
|
457
|
+
|
|
458
|
+
return Driver._visualize_execution_helper(
|
|
459
|
+
adapter=None,
|
|
460
|
+
bypass_validation=True,
|
|
461
|
+
render_kwargs={},
|
|
462
|
+
output_file_path=output_file_path,
|
|
463
|
+
fn_graph=fn_graph,
|
|
464
|
+
final_vars=final_vars,
|
|
465
|
+
inputs=inputs,
|
|
466
|
+
overrides=overrides,
|
|
467
|
+
custom_style_function=functools.partial(_visualization_styling_function, logs=logs),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# TODO make this work directly from the metadata_store too
|
|
471
|
+
# visualization from logs is convenient when debugging someone else's issue
|
|
472
|
+
def view_run(self, run_id: str | None = None, output_file_path: str | None = None):
|
|
473
|
+
"""View the dataflow execution, including cache hits/misses.
|
|
474
|
+
|
|
475
|
+
:param run_id: If ``None``, view the last run. If provided a ``run_id``, view that run.
|
|
476
|
+
:param output_file_path: If provided a path, save the visualization to a file.
|
|
477
|
+
|
|
478
|
+
.. code-block:: python
|
|
479
|
+
|
|
480
|
+
from hamilton import driver
|
|
481
|
+
import my_dataflow
|
|
482
|
+
|
|
483
|
+
dr = driver.Builder().with_modules(my_dataflow).with_cache().build()
|
|
484
|
+
|
|
485
|
+
# execute 3 times
|
|
486
|
+
dr.execute(...)
|
|
487
|
+
dr.execute(...)
|
|
488
|
+
dr.execute(...)
|
|
489
|
+
|
|
490
|
+
# view the last run
|
|
491
|
+
dr.cache.view_run()
|
|
492
|
+
# this is equivalent to
|
|
493
|
+
dr.cache.view_run(run_id=dr.last_run_id)
|
|
494
|
+
|
|
495
|
+
# get a specific run id
|
|
496
|
+
run_id = dr.cache.run_ids[1]
|
|
497
|
+
dr.cache.view_run(run_id=run_id)
|
|
498
|
+
|
|
499
|
+
"""
|
|
500
|
+
if run_id is None:
|
|
501
|
+
run_id = self.last_run_id
|
|
502
|
+
|
|
503
|
+
fn_graph = self._fn_graphs[run_id]
|
|
504
|
+
logs = self.logs(run_id, level="debug")
|
|
505
|
+
|
|
506
|
+
final_vars = []
|
|
507
|
+
inputs = {}
|
|
508
|
+
overrides = {}
|
|
509
|
+
for key, events in logs.items():
|
|
510
|
+
if isinstance(key, tuple):
|
|
511
|
+
raise ValueError(
|
|
512
|
+
"`.view()` is currently not supported for task-based execution. "
|
|
513
|
+
"Please inspect the logs directly via `.logs(run_id=...)` for debugging."
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
node_name = key
|
|
517
|
+
if any(e.event_type == CachingEventType.IS_FINAL_VAR for e in events):
|
|
518
|
+
final_vars.append(node_name)
|
|
519
|
+
|
|
520
|
+
if any(e.event_type == CachingEventType.IS_INPUT for e in events):
|
|
521
|
+
inputs[node_name] = None # the value doesn't matter, only the key of the dict
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
elif any(e.event_type == CachingEventType.IS_OVERRIDE for e in events):
|
|
525
|
+
overrides[node_name] = None # the value doesn't matter, only the key of the dict
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
return self._view_run(
|
|
529
|
+
fn_graph=fn_graph,
|
|
530
|
+
logs=logs,
|
|
531
|
+
final_vars=final_vars,
|
|
532
|
+
inputs=inputs,
|
|
533
|
+
overrides=overrides,
|
|
534
|
+
output_file_path=output_file_path,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def _get_node_role(
|
|
538
|
+
self, run_id: str, node_name: str, task_id: str | None
|
|
539
|
+
) -> NodeRoleInTaskExecution:
|
|
540
|
+
"""Determine based on the node name and task_id if a node is part of parallel execution."""
|
|
541
|
+
if task_id is None:
|
|
542
|
+
role = NodeRoleInTaskExecution.STANDARD
|
|
543
|
+
else:
|
|
544
|
+
node_type: hamilton.node.NodeType = self._fn_graphs[run_id].nodes[node_name].node_role
|
|
545
|
+
if node_type == hamilton.node.NodeType.EXPAND:
|
|
546
|
+
role = NodeRoleInTaskExecution.EXPAND
|
|
547
|
+
elif node_type == hamilton.node.NodeType.COLLECT:
|
|
548
|
+
role = NodeRoleInTaskExecution.COLLECT
|
|
549
|
+
elif node_name == task_id:
|
|
550
|
+
role = NodeRoleInTaskExecution.OUTSIDE
|
|
551
|
+
else:
|
|
552
|
+
role = NodeRoleInTaskExecution.INSIDE
|
|
553
|
+
|
|
554
|
+
return role
|
|
555
|
+
|
|
556
|
+
def get_cache_key(self, run_id: str, node_name: str, task_id: str | None = None) -> str | S:
|
|
557
|
+
"""Get the ``cache_key`` stored in-memory for a specific ``run_id``, ``node_name``, and ``task_id``.
|
|
558
|
+
|
|
559
|
+
This method is public-facing and can be used directly to inspect the cache.
|
|
560
|
+
|
|
561
|
+
:param run_id: Id of the Hamilton execution run.
|
|
562
|
+
:param node_name: Name of the node associated with the cache key. ``node_name`` is a unique identifier
|
|
563
|
+
if task-based execution is not used.
|
|
564
|
+
:param task_id: Id of the task when task-based execution is used. Then, the tuple ``(node_name, task_id)``
|
|
565
|
+
is a unique identifier.
|
|
566
|
+
:return: The cache key if it exists, otherwise return a sentinel value.
|
|
567
|
+
|
|
568
|
+
.. code-block:: python
|
|
569
|
+
|
|
570
|
+
from hamilton import driver
|
|
571
|
+
import my_dataflow
|
|
572
|
+
|
|
573
|
+
dr = driver.Builder().with_modules(my_dataflow).with_cache().build()
|
|
574
|
+
dr.execute(...)
|
|
575
|
+
|
|
576
|
+
dr.cache.get_cache_key(run_id=dr.last_run_id, node_name="my_node", task_id=None)
|
|
577
|
+
|
|
578
|
+
"""
|
|
579
|
+
node_role = self._get_node_role(run_id=run_id, node_name=node_name, task_id=task_id)
|
|
580
|
+
|
|
581
|
+
if node_role == NodeRoleInTaskExecution.INSIDE:
|
|
582
|
+
cache_key = self.cache_keys[run_id].get(node_name, {}).get(task_id, SENTINEL) # type: ignore ; `task_id` can't be None
|
|
583
|
+
else:
|
|
584
|
+
cache_key = self.cache_keys[run_id].get(node_name, SENTINEL)
|
|
585
|
+
|
|
586
|
+
cache_key = cache_key if cache_key is not SENTINEL else None
|
|
587
|
+
self._log_event(
|
|
588
|
+
run_id=run_id,
|
|
589
|
+
node_name=node_name,
|
|
590
|
+
task_id=task_id,
|
|
591
|
+
actor="adapter",
|
|
592
|
+
event_type=CachingEventType.GET_CACHE_KEY,
|
|
593
|
+
msg="hit" if cache_key is not SENTINEL else "miss",
|
|
594
|
+
value=cache_key,
|
|
595
|
+
)
|
|
596
|
+
return cache_key
|
|
597
|
+
|
|
598
|
+
def _set_cache_key(
|
|
599
|
+
self, run_id: str, node_name: str, cache_key: str, task_id: str | None = None
|
|
600
|
+
) -> None:
|
|
601
|
+
"""Set the ``cache_key`` stored in-memory for a specific ``run_id``, ``node_name``, and ``task_id``.
|
|
602
|
+
|
|
603
|
+
When calling this method, ``cache_key`` must not be ``None``.
|
|
604
|
+
"""
|
|
605
|
+
assert cache_key is not None
|
|
606
|
+
node_role = self._get_node_role(run_id=run_id, node_name=node_name, task_id=task_id)
|
|
607
|
+
if node_role in (
|
|
608
|
+
NodeRoleInTaskExecution.STANDARD,
|
|
609
|
+
NodeRoleInTaskExecution.OUTSIDE,
|
|
610
|
+
NodeRoleInTaskExecution.EXPAND,
|
|
611
|
+
NodeRoleInTaskExecution.COLLECT,
|
|
612
|
+
):
|
|
613
|
+
self.cache_keys[run_id][node_name] = cache_key
|
|
614
|
+
elif node_role == NodeRoleInTaskExecution.INSIDE:
|
|
615
|
+
if self.cache_keys[run_id].get(node_name, SENTINEL) is SENTINEL:
|
|
616
|
+
self.cache_keys[run_id][node_name] = {}
|
|
617
|
+
self.cache_keys[run_id][node_name][task_id] = cache_key # type: ignore ; we just initialized the nested dict
|
|
618
|
+
else:
|
|
619
|
+
raise ValueError(
|
|
620
|
+
f"Received `{node_role}`. Unhandled `NodeRoleInTaskExecution`, please report this bug."
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
self._log_event(
|
|
624
|
+
run_id=run_id,
|
|
625
|
+
node_name=node_name,
|
|
626
|
+
task_id=task_id,
|
|
627
|
+
actor="adapter",
|
|
628
|
+
event_type=CachingEventType.SET_CACHE_KEY,
|
|
629
|
+
value=cache_key,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
def _get_memory_data_version(
|
|
633
|
+
self, run_id: str, node_name: str, task_id: str | None = None
|
|
634
|
+
) -> str | S:
|
|
635
|
+
"""Get the ``data_version`` stored in-memory for a specific ``run_id``, ``node_name``, and ``task_id``.
|
|
636
|
+
|
|
637
|
+
The behavior depends on the ``CacheBehavior`` (e.g., RECOMPUTE, IGNORE, DISABLE, DEFAULT) and
|
|
638
|
+
the ``NodeRoleInTaskExecution`` of the node (e.g., STANDARD, OUTSIDE, INSIDE, EXPAND, COLLECT).
|
|
639
|
+
|
|
640
|
+
:param run_id: Id of the Hamilton execution run.
|
|
641
|
+
:param node_name: Name of the node associated with the cache key. ``node_name`` is a unique identifier
|
|
642
|
+
if task-based execution is not used.
|
|
643
|
+
:param task_id: Id of the task when task-based execution is used. Then, the tuple ``(node_name, task_id)``
|
|
644
|
+
is a unique identifier.
|
|
645
|
+
"""
|
|
646
|
+
node_role = self._get_node_role(run_id=run_id, node_name=node_name, task_id=task_id)
|
|
647
|
+
if node_role in (
|
|
648
|
+
NodeRoleInTaskExecution.STANDARD,
|
|
649
|
+
NodeRoleInTaskExecution.OUTSIDE,
|
|
650
|
+
NodeRoleInTaskExecution.COLLECT,
|
|
651
|
+
):
|
|
652
|
+
data_version = self.data_versions[run_id].get(node_name, SENTINEL)
|
|
653
|
+
elif node_role == NodeRoleInTaskExecution.EXPAND:
|
|
654
|
+
data_version = SENTINEL
|
|
655
|
+
elif node_role == NodeRoleInTaskExecution.INSIDE:
|
|
656
|
+
tasks_data_versions = self.data_versions[run_id].get(node_name, SENTINEL)
|
|
657
|
+
if isinstance(tasks_data_versions, dict):
|
|
658
|
+
data_version = tasks_data_versions.get(task_id, SENTINEL)
|
|
659
|
+
else:
|
|
660
|
+
data_version = SENTINEL
|
|
661
|
+
else:
|
|
662
|
+
raise ValueError(
|
|
663
|
+
f"Received `{node_role}`. Unhandled `NodeRoleInTaskExecution`, please report this bug."
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
self._log_event(
|
|
667
|
+
run_id=run_id,
|
|
668
|
+
node_name=node_name,
|
|
669
|
+
task_id=task_id,
|
|
670
|
+
actor="adapter",
|
|
671
|
+
event_type=CachingEventType.GET_DATA_VERSION,
|
|
672
|
+
msg="hit" if data_version is not SENTINEL else "miss",
|
|
673
|
+
)
|
|
674
|
+
return data_version
|
|
675
|
+
|
|
676
|
+
def _get_stored_data_version(
|
|
677
|
+
self, run_id: str, node_name: str, cache_key: str, task_id: str | None = None
|
|
678
|
+
) -> str | S:
|
|
679
|
+
"""Get the ``data_version`` stored in the metadata store associated with the ``cache_key``.
|
|
680
|
+
|
|
681
|
+
The ``run_id``, ``node_name``, and ``task_id`` are included only for logging purposes.
|
|
682
|
+
"""
|
|
683
|
+
data_version = self.metadata_store.get(cache_key=cache_key)
|
|
684
|
+
data_version = SENTINEL if data_version is None else data_version
|
|
685
|
+
self._log_event(
|
|
686
|
+
run_id=run_id,
|
|
687
|
+
node_name=node_name,
|
|
688
|
+
task_id=task_id,
|
|
689
|
+
actor="metadata_store",
|
|
690
|
+
event_type=CachingEventType.GET_DATA_VERSION,
|
|
691
|
+
msg="hit" if data_version is not SENTINEL else "miss",
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
return data_version
|
|
695
|
+
|
|
696
|
+
def get_data_version(
|
|
697
|
+
self,
|
|
698
|
+
run_id: str,
|
|
699
|
+
node_name: str,
|
|
700
|
+
cache_key: str | None = None,
|
|
701
|
+
task_id: str | None = None,
|
|
702
|
+
) -> str | S:
|
|
703
|
+
"""Get the ``data_version`` for a specific ``run_id``, ``node_name``, and ``task_id``.
|
|
704
|
+
|
|
705
|
+
This method is public-facing and can be used directly to inspect the cache. This will check data versions
|
|
706
|
+
stored both in-memory and in the metadata store.
|
|
707
|
+
|
|
708
|
+
:param run_id: Id of the Hamilton execution run.
|
|
709
|
+
:param node_name: Name of the node associated with the data version. ``node_name`` is a unique identifier
|
|
710
|
+
if task-based execution is not used.
|
|
711
|
+
:param task_id: Id of the task when task-based execution is used. Then, the tuple ``(node_name, task_id)``
|
|
712
|
+
is a unique identifier.
|
|
713
|
+
:return: The data version if it exists, otherwise return a sentinel value.
|
|
714
|
+
|
|
715
|
+
..code-block:: python
|
|
716
|
+
|
|
717
|
+
from hamilton import driver
|
|
718
|
+
import my_dataflow
|
|
719
|
+
|
|
720
|
+
dr = driver.Builder().with_modules(my_dataflow).with_cache().build()
|
|
721
|
+
dr.execute(...)
|
|
722
|
+
|
|
723
|
+
dr.cache.get_data_version(run_id=dr.last_run_id, node_name="my_node", task_id=None)
|
|
724
|
+
|
|
725
|
+
"""
|
|
726
|
+
|
|
727
|
+
data_version = self._get_memory_data_version(
|
|
728
|
+
run_id=run_id, node_name=node_name, task_id=task_id
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
if data_version is SENTINEL and cache_key is not None:
|
|
732
|
+
data_version = self._get_stored_data_version(
|
|
733
|
+
run_id=run_id, node_name=node_name, task_id=task_id, cache_key=cache_key
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
return data_version
|
|
737
|
+
|
|
738
|
+
def _set_memory_metadata(
|
|
739
|
+
self, run_id: str, node_name: str, data_version: str, task_id: str | None = None
|
|
740
|
+
) -> None:
|
|
741
|
+
"""Set in-memory data_version whether a task_id is specified or not"""
|
|
742
|
+
assert data_version is not None
|
|
743
|
+
node_role = self._get_node_role(run_id=run_id, node_name=node_name, task_id=task_id)
|
|
744
|
+
if node_role in (
|
|
745
|
+
NodeRoleInTaskExecution.STANDARD,
|
|
746
|
+
NodeRoleInTaskExecution.OUTSIDE,
|
|
747
|
+
NodeRoleInTaskExecution.COLLECT,
|
|
748
|
+
):
|
|
749
|
+
self.data_versions[run_id][node_name] = data_version
|
|
750
|
+
elif node_role == NodeRoleInTaskExecution.EXPAND:
|
|
751
|
+
self.data_versions[run_id][node_name] = {}
|
|
752
|
+
elif node_role == NodeRoleInTaskExecution.INSIDE:
|
|
753
|
+
if self.data_versions[run_id].get(node_name, SENTINEL) is SENTINEL:
|
|
754
|
+
self.data_versions[run_id][node_name] = {}
|
|
755
|
+
self.data_versions[run_id][node_name][task_id] = data_version # type: ignore ; we just initialized the nested dict
|
|
756
|
+
else:
|
|
757
|
+
raise ValueError(
|
|
758
|
+
f"Received `{node_role}`. Unhandled `NodeRoleInTaskExecution`, please report this bug."
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
self._log_event(
|
|
762
|
+
run_id=run_id,
|
|
763
|
+
node_name=node_name,
|
|
764
|
+
task_id=task_id,
|
|
765
|
+
actor="adapter",
|
|
766
|
+
event_type=CachingEventType.SET_DATA_VERSION,
|
|
767
|
+
value=data_version,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
def _set_stored_metadata(
|
|
771
|
+
self,
|
|
772
|
+
run_id: str,
|
|
773
|
+
node_name: str,
|
|
774
|
+
cache_key: str,
|
|
775
|
+
data_version: str,
|
|
776
|
+
task_id: str | None = None,
|
|
777
|
+
) -> None:
|
|
778
|
+
"""Set data_version in the metadata store associated with the cache_key"""
|
|
779
|
+
self.metadata_store.set(
|
|
780
|
+
run_id=run_id,
|
|
781
|
+
node_name=node_name,
|
|
782
|
+
code_version=self.code_versions[run_id][node_name],
|
|
783
|
+
data_version=data_version,
|
|
784
|
+
cache_key=cache_key,
|
|
785
|
+
)
|
|
786
|
+
self._log_event(
|
|
787
|
+
run_id=run_id,
|
|
788
|
+
node_name=node_name,
|
|
789
|
+
task_id=task_id,
|
|
790
|
+
actor="metadata_store",
|
|
791
|
+
event_type=CachingEventType.SET_DATA_VERSION,
|
|
792
|
+
value=data_version,
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
def _version_data(
|
|
796
|
+
self, node_name: str, run_id: str, result: Any, task_id: str | None = None
|
|
797
|
+
) -> str:
|
|
798
|
+
"""Create a unique data version for the result"""
|
|
799
|
+
data_version = fingerprinting.hash_value(result)
|
|
800
|
+
if data_version == fingerprinting.UNHASHABLE:
|
|
801
|
+
self._log_event(
|
|
802
|
+
run_id=run_id,
|
|
803
|
+
node_name=node_name,
|
|
804
|
+
task_id=task_id,
|
|
805
|
+
actor="adapter",
|
|
806
|
+
event_type=CachingEventType.UNHASHABLE_DATA_VERSION,
|
|
807
|
+
msg=f"unhashable type {type(result)}; set CachingBehavior.IGNORE to silence warning",
|
|
808
|
+
value=data_version,
|
|
809
|
+
)
|
|
810
|
+
logger.warning(
|
|
811
|
+
f"Node `{node_name}` has unhashable result of type `{type(result)}`. "
|
|
812
|
+
"Set `CachingBehavior.IGNORE` or register a versioning function to silence warning. "
|
|
813
|
+
"Learn more: https://hamilton.apache.org/concepts/caching/#caching-behavior\n"
|
|
814
|
+
)
|
|
815
|
+
# if the data version is unhashable, we need to set a random suffix to the cache_key
|
|
816
|
+
# to prevent the cache from thinking this value is constant, causing a cache hit.
|
|
817
|
+
data_version = "<unhashable>" + f"_{uuid.uuid4()}"
|
|
818
|
+
|
|
819
|
+
return data_version
|
|
820
|
+
|
|
821
|
+
def version_data(self, result: Any, run_id: str = None) -> str:
|
|
822
|
+
"""Create a unique data version for the result
|
|
823
|
+
|
|
824
|
+
This is a user-facing method.
|
|
825
|
+
"""
|
|
826
|
+
# stuff the internal function call to not log event
|
|
827
|
+
return self._version_data(result=result, run_id=run_id, node_name=None)
|
|
828
|
+
|
|
829
|
+
def version_code(self, node_name: str, run_id: str | None = None) -> str:
|
|
830
|
+
"""Create a unique code version for the source code defining the node"""
|
|
831
|
+
run_id = self.last_run_id if run_id is None else run_id
|
|
832
|
+
node = self._fn_graphs[run_id].nodes[node_name]
|
|
833
|
+
return graph_types.HamiltonNode.from_node(node).version # type: ignore
|
|
834
|
+
|
|
835
|
+
def _execute_node(
|
|
836
|
+
self,
|
|
837
|
+
run_id: str,
|
|
838
|
+
node_name: str,
|
|
839
|
+
node_callable: Callable,
|
|
840
|
+
node_kwargs: dict[str, Any],
|
|
841
|
+
task_id: str | None = None,
|
|
842
|
+
) -> Any:
|
|
843
|
+
"""Simple wrapper that logs the regular execution of a node."""
|
|
844
|
+
logger.debug(node_name)
|
|
845
|
+
result = node_callable(**node_kwargs)
|
|
846
|
+
self._log_event(
|
|
847
|
+
run_id=run_id,
|
|
848
|
+
node_name=node_name,
|
|
849
|
+
task_id=task_id,
|
|
850
|
+
actor="adapter",
|
|
851
|
+
event_type=CachingEventType.EXECUTE_NODE,
|
|
852
|
+
)
|
|
853
|
+
return result
|
|
854
|
+
|
|
855
|
+
@staticmethod
|
|
856
|
+
def _resolve_node_behavior(
|
|
857
|
+
node: hamilton.node.Node,
|
|
858
|
+
default: Collection[str] | None = None,
|
|
859
|
+
disable: Collection[str] | None = None,
|
|
860
|
+
recompute: Collection[str] | None = None,
|
|
861
|
+
ignore: Collection[str] | None = None,
|
|
862
|
+
default_behavior: CACHING_BEHAVIORS = "default",
|
|
863
|
+
default_loader_behavior: CACHING_BEHAVIORS = "default",
|
|
864
|
+
default_saver_behavior: CACHING_BEHAVIORS = "default",
|
|
865
|
+
) -> CachingBehavior:
|
|
866
|
+
"""Determine the cache behavior of a node.
|
|
867
|
+
Behavior specified via the ``Builder`` has precedence over the ``@cache`` decorator.
|
|
868
|
+
Otherwise, set the ``DEFAULT`` behavior.
|
|
869
|
+
If the node is `Parallelizable` enforce the ``RECOMPUTE`` behavior to ensure
|
|
870
|
+
yielded items are versioned individually.
|
|
871
|
+
"""
|
|
872
|
+
if node.node_role == hamilton.node.NodeType.EXPAND:
|
|
873
|
+
return CachingBehavior.RECOMPUTE
|
|
874
|
+
|
|
875
|
+
behavior_from_tag = node.tags.get(cache_decorator.BEHAVIOR_KEY, SENTINEL)
|
|
876
|
+
if behavior_from_tag is not SENTINEL:
|
|
877
|
+
behavior_from_tag = CachingBehavior.from_string(behavior_from_tag)
|
|
878
|
+
|
|
879
|
+
behavior_from_driver = SENTINEL
|
|
880
|
+
for behavior, node_set in (
|
|
881
|
+
(CachingBehavior.DEFAULT, default),
|
|
882
|
+
(CachingBehavior.DISABLE, disable),
|
|
883
|
+
(CachingBehavior.RECOMPUTE, recompute),
|
|
884
|
+
(CachingBehavior.IGNORE, ignore),
|
|
885
|
+
):
|
|
886
|
+
# guard against default None value
|
|
887
|
+
if node_set is None:
|
|
888
|
+
continue
|
|
889
|
+
|
|
890
|
+
if node.name in node_set:
|
|
891
|
+
if behavior_from_driver is not SENTINEL:
|
|
892
|
+
raise ValueError(
|
|
893
|
+
f"Multiple caching behaviors specified by Driver for node: {node.name}"
|
|
894
|
+
)
|
|
895
|
+
behavior_from_driver = behavior
|
|
896
|
+
|
|
897
|
+
if behavior_from_driver is not SENTINEL:
|
|
898
|
+
return behavior_from_driver
|
|
899
|
+
elif behavior_from_tag is not SENTINEL:
|
|
900
|
+
return behavior_from_tag
|
|
901
|
+
elif node.tags.get("hamilton.data_loader"):
|
|
902
|
+
return CachingBehavior.from_string(default_loader_behavior)
|
|
903
|
+
elif node.tags.get("hamilton.data_saver"):
|
|
904
|
+
return CachingBehavior.from_string(default_saver_behavior)
|
|
905
|
+
else:
|
|
906
|
+
return CachingBehavior.from_string(default_behavior)
|
|
907
|
+
|
|
908
|
+
def resolve_behaviors(self, run_id: str) -> dict[str, CachingBehavior]:
|
|
909
|
+
"""Resolve the caching behavior for each node based on the ``@cache`` decorator
|
|
910
|
+
and the ``Builder.with_cache()`` parameters for a specific ``run_id``.
|
|
911
|
+
|
|
912
|
+
This is a user-facing method.
|
|
913
|
+
|
|
914
|
+
Behavior specified via ``Builder.with_cache()`` have precedence. If no parameters are specified,
|
|
915
|
+
the ``CachingBehavior.DEFAULT`` is used. If a node is ``Parallelizable`` (i.e., ``@expand``),
|
|
916
|
+
the ``CachingBehavior`` is set to ``CachingBehavior.RECOMPUTE`` to ensure the yielded items
|
|
917
|
+
are versioned individually. Internally, this uses the ``FunctionGraph`` stored for each ``run_id`` and logs
|
|
918
|
+
the resolved caching behavior for each node.
|
|
919
|
+
|
|
920
|
+
:param run_id: Id of the Hamilton execution run.
|
|
921
|
+
:return: A dictionary of ``{node name: caching behavior}``.
|
|
922
|
+
"""
|
|
923
|
+
graph = self._fn_graphs[run_id]
|
|
924
|
+
|
|
925
|
+
_default = self._default
|
|
926
|
+
_disable = self._disable
|
|
927
|
+
_recompute = self._recompute
|
|
928
|
+
_ignore = self._ignore
|
|
929
|
+
|
|
930
|
+
if _default is True:
|
|
931
|
+
_default = [n.name for n in graph.get_nodes()]
|
|
932
|
+
elif _disable is True:
|
|
933
|
+
_disable = [n.name for n in graph.get_nodes()]
|
|
934
|
+
elif _recompute is True:
|
|
935
|
+
_recompute = [n.name for n in graph.get_nodes()]
|
|
936
|
+
elif _ignore is True:
|
|
937
|
+
_ignore = [n.name for n in graph.get_nodes()]
|
|
938
|
+
|
|
939
|
+
default_behavior = "default"
|
|
940
|
+
if self.default_behavior is not None:
|
|
941
|
+
default_behavior = self.default_behavior
|
|
942
|
+
|
|
943
|
+
default_loader_behavior = default_behavior
|
|
944
|
+
if self.default_loader_behavior is not None:
|
|
945
|
+
default_loader_behavior = self.default_loader_behavior
|
|
946
|
+
|
|
947
|
+
default_saver_behavior = default_behavior
|
|
948
|
+
if self.default_saver_behavior is not None:
|
|
949
|
+
default_saver_behavior = self.default_saver_behavior
|
|
950
|
+
|
|
951
|
+
behaviors = {}
|
|
952
|
+
for node in graph.get_nodes():
|
|
953
|
+
behavior = HamiltonCacheAdapter._resolve_node_behavior(
|
|
954
|
+
node=node,
|
|
955
|
+
default=_default,
|
|
956
|
+
disable=_disable,
|
|
957
|
+
recompute=_recompute,
|
|
958
|
+
ignore=_ignore,
|
|
959
|
+
default_behavior=default_behavior,
|
|
960
|
+
default_loader_behavior=default_loader_behavior,
|
|
961
|
+
default_saver_behavior=default_saver_behavior,
|
|
962
|
+
)
|
|
963
|
+
behaviors[node.name] = behavior
|
|
964
|
+
|
|
965
|
+
self._log_event(
|
|
966
|
+
run_id=run_id,
|
|
967
|
+
node_name=node.name,
|
|
968
|
+
task_id=None,
|
|
969
|
+
actor="adapter",
|
|
970
|
+
event_type=CachingEventType.RESOLVE_BEHAVIOR,
|
|
971
|
+
value=behavior,
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
# need to handle materializers via a second pass to copy the behavior
|
|
975
|
+
# of their "main node"
|
|
976
|
+
for node in graph.get_nodes():
|
|
977
|
+
if node.tags.get("hamilton.data_loader") is True:
|
|
978
|
+
main_node = node.tags["hamilton.data_loader.node"]
|
|
979
|
+
if main_node == node.name:
|
|
980
|
+
continue
|
|
981
|
+
|
|
982
|
+
# solution for `@dataloader` and `from_`
|
|
983
|
+
if behaviors.get(main_node, None) is not None:
|
|
984
|
+
behaviors[node.name] = behaviors[main_node]
|
|
985
|
+
# this hacky section is required to support @load_from and provide
|
|
986
|
+
# a unified pattern to specify behavior from the module or the driver
|
|
987
|
+
else:
|
|
988
|
+
behaviors[node.name] = HamiltonCacheAdapter._resolve_node_behavior(
|
|
989
|
+
# we create a fake node, only its name matters
|
|
990
|
+
node=hamilton.node.Node(
|
|
991
|
+
name=main_node,
|
|
992
|
+
typ=str,
|
|
993
|
+
callabl=lambda: None,
|
|
994
|
+
tags=node.tags.copy(),
|
|
995
|
+
),
|
|
996
|
+
default=_default,
|
|
997
|
+
disable=_disable,
|
|
998
|
+
recompute=_recompute,
|
|
999
|
+
ignore=_ignore,
|
|
1000
|
+
default_behavior=default_loader_behavior,
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
self._data_loaders[run_id].append(main_node)
|
|
1004
|
+
|
|
1005
|
+
if node.tags.get("hamilton.data_saver", None) is not None:
|
|
1006
|
+
self._data_savers[run_id].append(node.name)
|
|
1007
|
+
|
|
1008
|
+
return behaviors
|
|
1009
|
+
|
|
1010
|
+
def resolve_code_versions(
|
|
1011
|
+
self,
|
|
1012
|
+
run_id: str,
|
|
1013
|
+
final_vars: list[str] | None = None,
|
|
1014
|
+
inputs: dict[str, Any] | None = None,
|
|
1015
|
+
overrides: dict[str, Any] | None = None,
|
|
1016
|
+
) -> dict[str, str]:
|
|
1017
|
+
"""Resolve the code version for each node for a specific ``run_id``.
|
|
1018
|
+
|
|
1019
|
+
This is a user-facing method.
|
|
1020
|
+
|
|
1021
|
+
If ``final_vars`` is None, all nodes will be versioned. If ``final_vars`` is provided,
|
|
1022
|
+
the ``inputs`` and ``overrides`` are used to determine the execution path and only
|
|
1023
|
+
version the code for these nodes.
|
|
1024
|
+
|
|
1025
|
+
:param run_id: Id of the Hamilton execution run.
|
|
1026
|
+
:param final_vars: Nodes requested for execution.
|
|
1027
|
+
:param inputs: Input node values.
|
|
1028
|
+
:param overrides: Override node values.
|
|
1029
|
+
:return: A dictionary of ``{node name: code version}``.
|
|
1030
|
+
"""
|
|
1031
|
+
graph = self._fn_graphs[run_id]
|
|
1032
|
+
|
|
1033
|
+
final_vars = [] if final_vars is None else final_vars
|
|
1034
|
+
inputs = {} if inputs is None else inputs
|
|
1035
|
+
overrides = {} if overrides is None else overrides
|
|
1036
|
+
|
|
1037
|
+
node_selection = graph.get_nodes()
|
|
1038
|
+
if len(final_vars) > 0:
|
|
1039
|
+
all_nodes, user_defined_nodes = graph.get_upstream_nodes(final_vars, inputs, overrides)
|
|
1040
|
+
node_selection = set(all_nodes) - set(user_defined_nodes)
|
|
1041
|
+
|
|
1042
|
+
return {
|
|
1043
|
+
node.name: self.version_code(run_id=run_id, node_name=node.name)
|
|
1044
|
+
for node in node_selection
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
def _process_input(self, run_id: str, node_name: str, value: Any) -> None:
|
|
1048
|
+
"""Process input nodes to version data and code.
|
|
1049
|
+
|
|
1050
|
+
To enable caching, input values must be versioned. Since inputs have no associated code,
|
|
1051
|
+
set a constant "code version" ``f"input__{node_name}"`` that uniquely identifies this input.
|
|
1052
|
+
"""
|
|
1053
|
+
data_version = self._version_data(node_name=node_name, run_id=run_id, result=value)
|
|
1054
|
+
self.code_versions[run_id][node_name] = f"input__{node_name}"
|
|
1055
|
+
self.data_versions[run_id][node_name] = data_version
|
|
1056
|
+
self._log_event(
|
|
1057
|
+
run_id=run_id,
|
|
1058
|
+
node_name=node_name,
|
|
1059
|
+
task_id=None,
|
|
1060
|
+
actor="adapter",
|
|
1061
|
+
event_type=CachingEventType.IS_INPUT,
|
|
1062
|
+
value=data_version,
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
def _process_override(self, run_id: str, node_name: str, value: Any) -> None:
|
|
1066
|
+
"""Process override nodes to version data and code.
|
|
1067
|
+
|
|
1068
|
+
To enable caching, override values must be versioned. As opposed to executed nodes,
|
|
1069
|
+
code and data versions for overrides are not stored because their value is user provided
|
|
1070
|
+
and isn't necessarily tied to the code.
|
|
1071
|
+
"""
|
|
1072
|
+
data_version = self._version_data(node_name=node_name, run_id=run_id, result=value)
|
|
1073
|
+
self.data_versions[run_id][node_name] = data_version
|
|
1074
|
+
self._log_event(
|
|
1075
|
+
run_id=run_id,
|
|
1076
|
+
node_name=node_name,
|
|
1077
|
+
task_id=None,
|
|
1078
|
+
actor="adapter",
|
|
1079
|
+
event_type=CachingEventType.IS_OVERRIDE,
|
|
1080
|
+
value=data_version,
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
@staticmethod
|
|
1084
|
+
def _resolve_default_parameter_values(
|
|
1085
|
+
node_: hamilton.node.Node, node_kwargs: dict[str, Any]
|
|
1086
|
+
) -> dict[str, Any]:
|
|
1087
|
+
"""
|
|
1088
|
+
If a node uses the function's default parameter values, they won't be part of the
|
|
1089
|
+
node_kwargs. To ensure a consistent `cache_key` we want to retrieve default parameter
|
|
1090
|
+
values if they're used
|
|
1091
|
+
"""
|
|
1092
|
+
resolved_kwargs = node_kwargs.copy()
|
|
1093
|
+
for param_name, param_value in node_.default_parameter_values.items():
|
|
1094
|
+
# if the `param_name` not in `node_kwargs`, it means the node uses the default
|
|
1095
|
+
# parameter value
|
|
1096
|
+
if param_name not in node_kwargs.keys():
|
|
1097
|
+
resolved_kwargs.update(**{param_name: param_value})
|
|
1098
|
+
|
|
1099
|
+
return resolved_kwargs
|
|
1100
|
+
|
|
1101
|
+
def pre_graph_execute(
|
|
1102
|
+
self,
|
|
1103
|
+
*,
|
|
1104
|
+
run_id: str,
|
|
1105
|
+
graph: FunctionGraph,
|
|
1106
|
+
final_vars: list[str],
|
|
1107
|
+
inputs: dict[str, Any],
|
|
1108
|
+
overrides: dict[str, Any],
|
|
1109
|
+
):
|
|
1110
|
+
"""Set up the state of the adapter for a new execution.
|
|
1111
|
+
|
|
1112
|
+
Most attributes need to be keyed by run_id to prevent potential conflicts because
|
|
1113
|
+
the same adapter instance is shared between across all ``Driver.execute()`` calls.
|
|
1114
|
+
"""
|
|
1115
|
+
self.run_ids.append(run_id)
|
|
1116
|
+
self.metadata_store.initialize(run_id)
|
|
1117
|
+
self._logs[run_id] = []
|
|
1118
|
+
|
|
1119
|
+
self._fn_graphs[run_id] = graph
|
|
1120
|
+
self.data_versions[run_id] = {}
|
|
1121
|
+
self.cache_keys[run_id] = {}
|
|
1122
|
+
self.code_versions[run_id] = self.resolve_code_versions(
|
|
1123
|
+
run_id=run_id, final_vars=final_vars, inputs=inputs, overrides=overrides
|
|
1124
|
+
)
|
|
1125
|
+
# the empty `._data_loaders` and `._data_savers` need to be instantiated before calling
|
|
1126
|
+
# `self.resolve_behaviors` because it appends to them
|
|
1127
|
+
self._data_loaders[run_id] = []
|
|
1128
|
+
self._data_savers[run_id] = []
|
|
1129
|
+
self.behaviors[run_id] = self.resolve_behaviors(run_id=run_id)
|
|
1130
|
+
|
|
1131
|
+
# final vars are logged to be retrieved by the ``.view_run()`` method
|
|
1132
|
+
for final_var in final_vars:
|
|
1133
|
+
self._log_event(
|
|
1134
|
+
run_id=run_id,
|
|
1135
|
+
node_name=final_var,
|
|
1136
|
+
task_id=None,
|
|
1137
|
+
actor="adapter",
|
|
1138
|
+
event_type=CachingEventType.IS_FINAL_VAR,
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
if inputs:
|
|
1142
|
+
for node_name, value in inputs.items():
|
|
1143
|
+
self._process_input(run_id, node_name, value)
|
|
1144
|
+
|
|
1145
|
+
if overrides:
|
|
1146
|
+
for node_name, value in overrides.items():
|
|
1147
|
+
self._process_override(run_id, node_name, value)
|
|
1148
|
+
|
|
1149
|
+
def pre_node_execute(
|
|
1150
|
+
self,
|
|
1151
|
+
*,
|
|
1152
|
+
run_id: str,
|
|
1153
|
+
node_: hamilton.node.Node,
|
|
1154
|
+
kwargs: dict[str, Any],
|
|
1155
|
+
task_id: str | None = None,
|
|
1156
|
+
**future_kwargs,
|
|
1157
|
+
):
|
|
1158
|
+
"""Before node execution or retrieval, create the cache_key and set it in memory.
|
|
1159
|
+
The cache_key is created based on the node's code version and its dependencies' data versions.
|
|
1160
|
+
|
|
1161
|
+
Collecting ``data_version`` for upstream dependencies requires handling special cases when
|
|
1162
|
+
task-based execution is used:
|
|
1163
|
+
- If the current node is ``COLLECT`` , the dependency annotated with ``Collect[]`` needs to
|
|
1164
|
+
be versioned item by item instead of versioning the full container. This is because the
|
|
1165
|
+
collect order is inconsistent.
|
|
1166
|
+
- If the current node is ``INSIDE`` and the dependency is ``EXPAND``, this means the
|
|
1167
|
+
``kwargs`` dictionary contains a single item. We need to version this individual item because
|
|
1168
|
+
it will not be available from "inside" the branch for some executors (multiprocessing, multithreading)
|
|
1169
|
+
because they lose access to the data_versions of ``OUTSIDE`` nodes stored in ``self.data_versions``.
|
|
1170
|
+
|
|
1171
|
+
"""
|
|
1172
|
+
node_name = node_.name
|
|
1173
|
+
node_kwargs = HamiltonCacheAdapter._resolve_default_parameter_values(node_, kwargs)
|
|
1174
|
+
|
|
1175
|
+
if self.behaviors[run_id][node_name] == CachingBehavior.IGNORE:
|
|
1176
|
+
return
|
|
1177
|
+
|
|
1178
|
+
# won't need the cache_key for either result retrieval or storage
|
|
1179
|
+
if self.behaviors[run_id][node_name] == CachingBehavior.DISABLE:
|
|
1180
|
+
return
|
|
1181
|
+
|
|
1182
|
+
node_role = self._get_node_role(run_id=run_id, node_name=node_name, task_id=task_id)
|
|
1183
|
+
collected_name = (
|
|
1184
|
+
node_.collect_dependency if node_role == NodeRoleInTaskExecution.COLLECT else SENTINEL
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
dependencies_data_versions = {}
|
|
1188
|
+
for dep_name, dep_value in node_kwargs.items():
|
|
1189
|
+
# resolve caching behaviors
|
|
1190
|
+
if self.behaviors[run_id][dep_name] == CachingBehavior.IGNORE:
|
|
1191
|
+
# setting the data_version to "<ignore>" in the cache_key means that
|
|
1192
|
+
# the value of the dependency appears constant to this node
|
|
1193
|
+
dependencies_data_versions[dep_name] = "<ignore>"
|
|
1194
|
+
continue
|
|
1195
|
+
elif self.behaviors[run_id][dep_name] == CachingBehavior.DISABLE:
|
|
1196
|
+
# setting the data_version to "<disable>" with a random suffix in the
|
|
1197
|
+
# cache_key means the current node will be a cache miss and forced to recompute
|
|
1198
|
+
dependencies_data_versions[dep_name] = "<disable>" + f"_{uuid.uuid4()}"
|
|
1199
|
+
continue
|
|
1200
|
+
|
|
1201
|
+
# resolve NodeRoleInTaskExecution
|
|
1202
|
+
if task_id is None:
|
|
1203
|
+
dep_role = NodeRoleInTaskExecution.STANDARD
|
|
1204
|
+
else:
|
|
1205
|
+
# want to check if dependency is an EXPAND node. We must not pass the current `task_id`
|
|
1206
|
+
dep_role = self._get_node_role(
|
|
1207
|
+
run_id=run_id, node_name=dep_name, task_id="<placeholder>"
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
# if dep_role == NodeRoleInTaskExecution.STANDARD:
|
|
1211
|
+
|
|
1212
|
+
if dep_name == collected_name:
|
|
1213
|
+
# the collected value should be hashed based on the items, not the container
|
|
1214
|
+
items_data_versions = [self.version_data(item, run_id=run_id) for item in dep_value]
|
|
1215
|
+
dep_data_version = fingerprinting.hash_value(sorted(items_data_versions))
|
|
1216
|
+
|
|
1217
|
+
elif dep_role == NodeRoleInTaskExecution.EXPAND:
|
|
1218
|
+
# if the dependency is `EXPAND`, the kwarg received is a single item yielded by the iterator
|
|
1219
|
+
# rather than the full iterable. We must version it directly, similar to a top-level input
|
|
1220
|
+
dep_data_version = self.version_data(dep_value, run_id=run_id)
|
|
1221
|
+
|
|
1222
|
+
else:
|
|
1223
|
+
tasks_data_versions = self._get_memory_data_version(
|
|
1224
|
+
run_id=run_id, node_name=dep_name, task_id=None
|
|
1225
|
+
)
|
|
1226
|
+
if tasks_data_versions is SENTINEL:
|
|
1227
|
+
dep_data_version = self.version_data(dep_value, run_id=run_id)
|
|
1228
|
+
elif isinstance(tasks_data_versions, dict):
|
|
1229
|
+
dep_data_version = tasks_data_versions.get(task_id)
|
|
1230
|
+
else:
|
|
1231
|
+
dep_data_version = tasks_data_versions
|
|
1232
|
+
|
|
1233
|
+
if dep_data_version == fingerprinting.UNHASHABLE:
|
|
1234
|
+
# if the data version is unhashable, we need to set a random suffix to the cache_key
|
|
1235
|
+
# to prevent the cache from thinking this value is constant, causing a cache hit.
|
|
1236
|
+
dep_data_version = "<unhashable>" + f"_{uuid.uuid4()}"
|
|
1237
|
+
|
|
1238
|
+
dependencies_data_versions[dep_name] = dep_data_version
|
|
1239
|
+
|
|
1240
|
+
# create cache_key before execution; will be reused during and after execution
|
|
1241
|
+
cache_key = create_cache_key(
|
|
1242
|
+
node_name=node_name,
|
|
1243
|
+
code_version=self.code_versions[run_id][node_name],
|
|
1244
|
+
dependencies_data_versions=dependencies_data_versions,
|
|
1245
|
+
)
|
|
1246
|
+
self._set_cache_key(
|
|
1247
|
+
run_id=run_id, node_name=node_name, task_id=task_id, cache_key=cache_key
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
def do_node_execute(
|
|
1251
|
+
self,
|
|
1252
|
+
*,
|
|
1253
|
+
run_id: str,
|
|
1254
|
+
node_: hamilton.node.Node,
|
|
1255
|
+
kwargs: dict[str, Any],
|
|
1256
|
+
task_id: str | None = None,
|
|
1257
|
+
**future_kwargs,
|
|
1258
|
+
):
|
|
1259
|
+
"""Try to retrieve stored result from previous executions or execute the node.
|
|
1260
|
+
|
|
1261
|
+
Use the previously created cache_key to retrieve the data_version from memory or the metadata_store.
|
|
1262
|
+
If data_version is retrieved try to retrieve the result. If it fails, execute the node.
|
|
1263
|
+
Else, execute the node.
|
|
1264
|
+
"""
|
|
1265
|
+
node_name = node_.name
|
|
1266
|
+
node_callable = node_.callable
|
|
1267
|
+
node_kwargs = HamiltonCacheAdapter._resolve_default_parameter_values(node_, kwargs)
|
|
1268
|
+
|
|
1269
|
+
if self.behaviors[run_id][node_name] in (
|
|
1270
|
+
CachingBehavior.DISABLE,
|
|
1271
|
+
CachingBehavior.IGNORE,
|
|
1272
|
+
CachingBehavior.RECOMPUTE,
|
|
1273
|
+
):
|
|
1274
|
+
result = self._execute_node(
|
|
1275
|
+
run_id=run_id,
|
|
1276
|
+
node_name=node_name,
|
|
1277
|
+
node_callable=node_callable,
|
|
1278
|
+
node_kwargs=node_kwargs,
|
|
1279
|
+
task_id=task_id,
|
|
1280
|
+
)
|
|
1281
|
+
if self.behaviors[run_id][node_name] in (
|
|
1282
|
+
CachingBehavior.RECOMPUTE,
|
|
1283
|
+
CachingBehavior.IGNORE,
|
|
1284
|
+
):
|
|
1285
|
+
cache_key = self.get_cache_key(run_id=run_id, node_name=node_name, task_id=task_id)
|
|
1286
|
+
|
|
1287
|
+
# nodes collected in `._data_loaders` return tuples of (result, metadata)
|
|
1288
|
+
# where metadata often includes a timestamp. To ensure we provide a consistent
|
|
1289
|
+
# `data_version` / hash, we only hash the result part of the materializer return
|
|
1290
|
+
# value and discard the metadata.
|
|
1291
|
+
if node_name in self._data_loaders[run_id] and isinstance(result, tuple):
|
|
1292
|
+
result = result[0]
|
|
1293
|
+
|
|
1294
|
+
data_version = self._version_data(node_name=node_name, run_id=run_id, result=result)
|
|
1295
|
+
|
|
1296
|
+
# nodes collected in `._data_savers` return a dictionary of metadata
|
|
1297
|
+
# this metadata often includes a timestamp, leading to an unstable hash.
|
|
1298
|
+
# we do not version nor store the metadata. This node is executed for its
|
|
1299
|
+
# external effect of saving a file
|
|
1300
|
+
if node_name in self._data_savers[run_id]:
|
|
1301
|
+
data_version = f"{node_name}__metadata"
|
|
1302
|
+
|
|
1303
|
+
self._set_memory_metadata(
|
|
1304
|
+
run_id=run_id, node_name=node_name, task_id=task_id, data_version=data_version
|
|
1305
|
+
)
|
|
1306
|
+
self._set_stored_metadata(
|
|
1307
|
+
run_id=run_id,
|
|
1308
|
+
node_name=node_name,
|
|
1309
|
+
task_id=task_id,
|
|
1310
|
+
cache_key=cache_key,
|
|
1311
|
+
data_version=data_version,
|
|
1312
|
+
)
|
|
1313
|
+
|
|
1314
|
+
return result
|
|
1315
|
+
|
|
1316
|
+
# cache_key is set in `pre_node_execute`
|
|
1317
|
+
cache_key = self.get_cache_key(run_id=run_id, node_name=node_name, task_id=task_id)
|
|
1318
|
+
# retrieve data version from memory or metadata_store
|
|
1319
|
+
data_version = self.get_data_version(
|
|
1320
|
+
run_id=run_id, node_name=node_name, task_id=task_id, cache_key=cache_key
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
need_to_compute_node = False
|
|
1324
|
+
if data_version is SENTINEL:
|
|
1325
|
+
# must execute: data_version not found in memory or in metadata_store
|
|
1326
|
+
need_to_compute_node = True
|
|
1327
|
+
elif data_version == fingerprinting.UNHASHABLE:
|
|
1328
|
+
# must execute: the retrieved data_version is UNHASHABLE, therefore it isn't stored.
|
|
1329
|
+
need_to_compute_node = True
|
|
1330
|
+
elif self.result_store.exists(data_version) is False:
|
|
1331
|
+
# must execute: data_version retrieved, but result store can't find result
|
|
1332
|
+
need_to_compute_node = True
|
|
1333
|
+
self._log_event(
|
|
1334
|
+
run_id=run_id,
|
|
1335
|
+
node_name=node_name,
|
|
1336
|
+
task_id=task_id,
|
|
1337
|
+
actor="result_store",
|
|
1338
|
+
event_type=CachingEventType.MISSING_RESULT,
|
|
1339
|
+
value=data_version,
|
|
1340
|
+
)
|
|
1341
|
+
else:
|
|
1342
|
+
# try to retrieve: data_version retrieve, result store found result
|
|
1343
|
+
try:
|
|
1344
|
+
# successful retrieval: retrieve the result; potentially load using the DataLoader if e.g.,``@cache(format="json")``
|
|
1345
|
+
result = self.result_store.get(data_version=data_version)
|
|
1346
|
+
self._log_event(
|
|
1347
|
+
run_id=run_id,
|
|
1348
|
+
node_name=node_name,
|
|
1349
|
+
task_id=task_id,
|
|
1350
|
+
actor="result_store",
|
|
1351
|
+
event_type=CachingEventType.GET_RESULT,
|
|
1352
|
+
msg="hit",
|
|
1353
|
+
value=data_version,
|
|
1354
|
+
)
|
|
1355
|
+
# set the data_version previously retrieved (could be from memory or store)
|
|
1356
|
+
self._set_memory_metadata(
|
|
1357
|
+
run_id=run_id, node_name=node_name, task_id=task_id, data_version=data_version
|
|
1358
|
+
)
|
|
1359
|
+
except ResultRetrievalError:
|
|
1360
|
+
# failed retrieval: despite finding the result, probably failed loading data using DataLoader if e.g.,``@cache(format="json")``
|
|
1361
|
+
self.metadata_store.delete(cache_key=cache_key)
|
|
1362
|
+
self.result_store.delete(data_version)
|
|
1363
|
+
need_to_compute_node = True
|
|
1364
|
+
|
|
1365
|
+
if need_to_compute_node is True:
|
|
1366
|
+
result = self._execute_node(
|
|
1367
|
+
run_id=run_id,
|
|
1368
|
+
node_name=node_name,
|
|
1369
|
+
node_callable=node_callable,
|
|
1370
|
+
node_kwargs=node_kwargs,
|
|
1371
|
+
task_id=task_id,
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
# nodes collected in `._data_loaders` return tuples of (result, metadata)
|
|
1375
|
+
# where metadata often includes a timestamp. To ensure we provide a consistent
|
|
1376
|
+
# `data_version` / hash, we only hash the result part of the materializer return
|
|
1377
|
+
# value and discard the metadata.
|
|
1378
|
+
if node_name in self._data_loaders[run_id] and isinstance(result, tuple):
|
|
1379
|
+
result = result[0]
|
|
1380
|
+
|
|
1381
|
+
data_version = self._version_data(node_name=node_name, run_id=run_id, result=result)
|
|
1382
|
+
|
|
1383
|
+
# nodes collected in `._data_savers` return a dictionary of metadata
|
|
1384
|
+
# this metadata often includes a timestamp, leading to an unstable hash.
|
|
1385
|
+
# we do not version nor store the metadata. This node is executed for its
|
|
1386
|
+
# external effect of saving a file
|
|
1387
|
+
if node_name in self._data_savers[run_id]:
|
|
1388
|
+
data_version = f"{node_name}__metadata"
|
|
1389
|
+
|
|
1390
|
+
self._set_memory_metadata(
|
|
1391
|
+
run_id=run_id, node_name=node_name, task_id=task_id, data_version=data_version
|
|
1392
|
+
)
|
|
1393
|
+
self._set_stored_metadata(
|
|
1394
|
+
run_id=run_id,
|
|
1395
|
+
node_name=node_name,
|
|
1396
|
+
task_id=task_id,
|
|
1397
|
+
cache_key=cache_key,
|
|
1398
|
+
data_version=data_version,
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
return result
|
|
1402
|
+
|
|
1403
|
+
def post_node_execute(
|
|
1404
|
+
self,
|
|
1405
|
+
*,
|
|
1406
|
+
run_id: str,
|
|
1407
|
+
node_: hamilton.node.Node,
|
|
1408
|
+
result: str | None,
|
|
1409
|
+
success: bool = True,
|
|
1410
|
+
error: Exception | None = None,
|
|
1411
|
+
task_id: str | None = None,
|
|
1412
|
+
**future_kwargs,
|
|
1413
|
+
):
|
|
1414
|
+
"""Get the cache_key and data_version stored in memory (respectively from
|
|
1415
|
+
pre_node_execute and do_node_execute) and store the result in result_store
|
|
1416
|
+
if it doesn't exist.
|
|
1417
|
+
"""
|
|
1418
|
+
node_name = node_.name
|
|
1419
|
+
|
|
1420
|
+
if success is False:
|
|
1421
|
+
self._log_event(
|
|
1422
|
+
run_id=run_id,
|
|
1423
|
+
node_name=node_name,
|
|
1424
|
+
task_id=task_id,
|
|
1425
|
+
actor="adapter",
|
|
1426
|
+
event_type=CachingEventType.FAILED_EXECUTION,
|
|
1427
|
+
msg=f"{error}",
|
|
1428
|
+
)
|
|
1429
|
+
return
|
|
1430
|
+
|
|
1431
|
+
if self.behaviors[run_id][node_name] in (
|
|
1432
|
+
CachingBehavior.DEFAULT,
|
|
1433
|
+
CachingBehavior.RECOMPUTE,
|
|
1434
|
+
CachingBehavior.IGNORE,
|
|
1435
|
+
):
|
|
1436
|
+
cache_key = self.get_cache_key(run_id=run_id, node_name=node_name, task_id=task_id)
|
|
1437
|
+
data_version = self.get_data_version(
|
|
1438
|
+
run_id=run_id, node_name=node_name, task_id=task_id, cache_key=cache_key
|
|
1439
|
+
)
|
|
1440
|
+
assert data_version is not SENTINEL
|
|
1441
|
+
|
|
1442
|
+
# TODO clean up this logic
|
|
1443
|
+
# check if a materialized file exist before writing results
|
|
1444
|
+
# when using `@cache(format="json")`
|
|
1445
|
+
cache_format = (
|
|
1446
|
+
self._fn_graphs[run_id]
|
|
1447
|
+
.nodes[node_name]
|
|
1448
|
+
.tags.get(cache_decorator.FORMAT_KEY, SENTINEL)
|
|
1449
|
+
)
|
|
1450
|
+
if cache_format is not SENTINEL:
|
|
1451
|
+
saver_cls, loader_cls = search_data_adapter_registry(
|
|
1452
|
+
name=cache_format, type_=type(result)
|
|
1453
|
+
) # type: ignore
|
|
1454
|
+
materialized_path = self.result_store._materialized_path(data_version, saver_cls)
|
|
1455
|
+
materialized_path_missing = not materialized_path.exists()
|
|
1456
|
+
else:
|
|
1457
|
+
saver_cls, loader_cls = None, None
|
|
1458
|
+
materialized_path_missing = False
|
|
1459
|
+
|
|
1460
|
+
result_missing = not self.result_store.exists(data_version)
|
|
1461
|
+
if result_missing or materialized_path_missing:
|
|
1462
|
+
self.result_store.set(
|
|
1463
|
+
data_version=data_version,
|
|
1464
|
+
result=result,
|
|
1465
|
+
saver_cls=saver_cls,
|
|
1466
|
+
loader_cls=loader_cls,
|
|
1467
|
+
)
|
|
1468
|
+
self._log_event(
|
|
1469
|
+
run_id=run_id,
|
|
1470
|
+
node_name=node_name,
|
|
1471
|
+
task_id=task_id,
|
|
1472
|
+
actor="result_store",
|
|
1473
|
+
event_type=CachingEventType.SET_RESULT,
|
|
1474
|
+
value=data_version,
|
|
1475
|
+
)
|