-
Notifications
You must be signed in to change notification settings - Fork 157
fix: include class-based UDF instance state in hash #1925
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
a4b8722
47dff65
34b5a90
c4916e5
05a9633
20116bf
08fc4ba
8ae7d1e
28bdb55
b686bc6
7c0e5de
f10cfa0
0e7e758
461027f
ce52ef2
cbcf944
dd22402
97e4f43
626ac27
615bc8a
a551acb
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 |
|---|---|---|
|
|
@@ -151,6 +151,40 @@ class ImageEncoder(Mapper): | |
| del self.model | ||
| ``` | ||
|
|
||
| ### Caching class-based operations | ||
|
|
||
| DataChain hashes UDF code, schemas, and constructor arguments. Primitive values and | ||
| nested built-in containers are handled automatically. Callables and custom objects | ||
| receive a unique identity instead, preventing incorrect cache reuse. Override | ||
| `state_hash()` when you can identify such state safely: | ||
|
|
||
| ```python | ||
| import hashlib | ||
| import json | ||
| from datachain.lib.udf import Mapper | ||
|
|
||
| class Tokenize(Mapper): | ||
| def __init__(self, tokenizer, tokenizer_id: str, max_length: int): | ||
| self.tokenizer = tokenizer | ||
| self.tokenizer_id = tokenizer_id | ||
| self.max_length = max_length | ||
|
|
||
| def state_hash(self) -> str: | ||
|
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. Q: is it really a "state" hash? does it change during the operation?
Contributor
Author
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. Yea ... "state" reads like something that changes at runtime. Renamed the API to |
||
| state = json.dumps( | ||
| {"tokenizer_id": self.tokenizer_id, "max_length": self.max_length}, | ||
| sort_keys=True, | ||
| ) | ||
| return hashlib.sha256(state.encode()).hexdigest() | ||
|
|
||
| def process(self, text: str) -> list[str]: | ||
| return self.tokenizer(text)[: self.max_length] | ||
| ``` | ||
|
|
||
| `state_hash()` must return a SHA-256 hexadecimal string covering all instance state | ||
| that affects output. When all constructor arguments are supported primitives, call | ||
| `super().state_hash()` to include them in an override. UDF code and schemas are always | ||
| included; an incomplete hash can reuse an incorrect cached result. | ||
|
|
||
| Use class-based operations sparingly; `.setup()` covers most cases. | ||
|
|
||
| ## Execution and Scale | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -139,7 +139,8 @@ Reliability is layered: | |
| Materialized `llm.*` columns are cached and versioned, so re-running a chain reads | ||
| the stored result instead of re-calling the model; the cache invalidates when any | ||
| output-affecting input changes (model, prompt, schema, the input column, `type`, | ||
| params, ...). | ||
| params, ...). Custom objects in parameters receive a per-operation identity, safely | ||
|
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. this not clear tbh ... in the user facing docs ... why is that a problem, why we do this, etc |
||
| disabling reuse across separately created operations. | ||
|
|
||
| ## No fused predicate | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import math | ||
| import pickle | ||
| from collections.abc import Sequence | ||
| from itertools import islice | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
@@ -33,6 +34,27 @@ | |
| DATACHAIN_SIGNAL_SCHEMA_PARQUET_KEY = b"DataChain SignalSchema" | ||
|
|
||
|
|
||
| def _parse_options_hash_args(options: ParseOptions) -> dict[str, Any]: | ||
| return { | ||
| "delimiter": options.delimiter, | ||
| "double_quote": options.double_quote, | ||
| "escape_char": options.escape_char, | ||
| "ignore_empty_lines": options.ignore_empty_lines, | ||
| "invalid_row_handler": options.invalid_row_handler, | ||
| "newlines_in_values": options.newlines_in_values, | ||
| "quote_char": options.quote_char, | ||
| } | ||
|
|
||
|
|
||
| def _csv_format_hash_args(format: CsvFileFormat) -> dict[str, Any]: | ||
| scan_options = format.default_fragment_scan_options | ||
| return { | ||
| "parse_options": _parse_options_hash_args(format.parse_options), | ||
| "read_options": pickle.dumps(scan_options.read_options), | ||
| "convert_options": pickle.dumps(scan_options.convert_options), | ||
| } | ||
|
|
||
|
|
||
| def fix_pyarrow_format(format, parse_options=None): | ||
| # Re-init invalid row handler: https://issues.apache.org/jira/browse/ARROW-17641 | ||
| if ( | ||
|
|
@@ -54,6 +76,7 @@ def __init__( | |
| output_schema: type["BaseModel"] | None = None, | ||
| source: bool = True, | ||
| nrows: int | None = None, | ||
| _generated_output_schema: bool = False, | ||
| **kwargs, | ||
| ): | ||
| """ | ||
|
|
@@ -75,6 +98,25 @@ def __init__( | |
| self.parse_options = kwargs.pop("parse_options", None) | ||
| self.kwargs = kwargs | ||
|
|
||
| @classmethod | ||
| def _constructor_hash_args(cls, arguments): | ||
| arguments = arguments.copy() | ||
| generated_output_schema = arguments.pop("_generated_output_schema") | ||
| if generated_output_schema: | ||
|
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. is it really a problem to hash it again? |
||
| # Its stable field shape is already part of the output signal schema. | ||
| arguments.pop("output_schema", None) | ||
| input_schema = arguments.get("input_schema") | ||
| if input_schema is not None: | ||
| arguments["input_schema"] = input_schema.serialize().to_pybytes() | ||
| kwargs = arguments["kwargs"].copy() | ||
| for name, value in kwargs.items(): | ||
| if isinstance(value, ParseOptions): | ||
| kwargs[name] = _parse_options_hash_args(value) | ||
| elif isinstance(value, CsvFileFormat): | ||
| kwargs[name] = _csv_format_hash_args(value) | ||
| arguments["kwargs"] = kwargs | ||
| return arguments | ||
|
|
||
| def process(self, file: File): | ||
| if file._caching_enabled: | ||
| file.ensure_cached() | ||
|
|
||
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.
Q: do we document class based approach at all? where?
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.
I wonder what terminology we use there ...
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.
"operations" or something else