hotdata-materialized 0.1.0__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.
- hotdata_materialized/__init__.py +44 -0
- hotdata_materialized/_arrow.py +44 -0
- hotdata_materialized/_sql.py +42 -0
- hotdata_materialized/client.py +50 -0
- hotdata_materialized/conf.py +70 -0
- hotdata_materialized/decorator.py +237 -0
- hotdata_materialized/exceptions.py +22 -0
- hotdata_materialized/fingerprint.py +77 -0
- hotdata_materialized/indexes.py +33 -0
- hotdata_materialized/py.typed +0 -0
- hotdata_materialized/registry.py +246 -0
- hotdata_materialized/store.py +405 -0
- hotdata_materialized-0.1.0.dist-info/METADATA +227 -0
- hotdata_materialized-0.1.0.dist-info/RECORD +16 -0
- hotdata_materialized-0.1.0.dist-info/WHEEL +5 -0
- hotdata_materialized-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""hotdata-materialized: offload expensive Django query results to Hotdata.
|
|
2
|
+
|
|
3
|
+
Public surface: the @materialize decorator and MaterializedFrame handle,
|
|
4
|
+
built on fingerprinting, the remote registry, and the entry store.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .conf import Config
|
|
8
|
+
from .client import HotdataClients, get_clients, reset_clients
|
|
9
|
+
from .decorator import MaterializedFrame, materialize
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
FingerprintError,
|
|
13
|
+
MaterializedError,
|
|
14
|
+
RegistryError,
|
|
15
|
+
StoreError,
|
|
16
|
+
)
|
|
17
|
+
from .fingerprint import fingerprint_call, fingerprint_queryset
|
|
18
|
+
from .indexes import BM25, Vector
|
|
19
|
+
from .registry import Registry, RegistryEntry
|
|
20
|
+
from .store import EntryStore
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"materialize",
|
|
26
|
+
"MaterializedFrame",
|
|
27
|
+
"BM25",
|
|
28
|
+
"Vector",
|
|
29
|
+
"Config",
|
|
30
|
+
"HotdataClients",
|
|
31
|
+
"get_clients",
|
|
32
|
+
"reset_clients",
|
|
33
|
+
"MaterializedError",
|
|
34
|
+
"ConfigurationError",
|
|
35
|
+
"FingerprintError",
|
|
36
|
+
"RegistryError",
|
|
37
|
+
"StoreError",
|
|
38
|
+
"fingerprint_call",
|
|
39
|
+
"fingerprint_queryset",
|
|
40
|
+
"Registry",
|
|
41
|
+
"RegistryEntry",
|
|
42
|
+
"EntryStore",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Arrow/parquet encoding helpers shared by the registry and the entry store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import pyarrow as pa
|
|
11
|
+
import pyarrow.ipc as pa_ipc
|
|
12
|
+
import pyarrow.parquet as pq
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def table_to_parquet_bytes(table: pa.Table) -> bytes:
|
|
16
|
+
buffer = io.BytesIO()
|
|
17
|
+
pq.write_table(table, buffer)
|
|
18
|
+
return buffer.getvalue()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def table_to_ipc_bytes(table: pa.Table) -> bytes:
|
|
22
|
+
sink = io.BytesIO()
|
|
23
|
+
with pa_ipc.new_stream(sink, table.schema) as writer:
|
|
24
|
+
writer.write_table(table)
|
|
25
|
+
return sink.getvalue()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def encode_inline_payload(table: pa.Table, threshold_bytes: int) -> Optional[str]:
|
|
29
|
+
# threshold bounds the payload as stored (post-base64), not the raw IPC
|
|
30
|
+
payload = base64.b64encode(table_to_ipc_bytes(table)).decode("ascii")
|
|
31
|
+
if len(payload) > threshold_bytes:
|
|
32
|
+
return None
|
|
33
|
+
return payload
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def decode_inline_payload(payload: str) -> pa.Table:
|
|
37
|
+
with pa_ipc.open_stream(io.BytesIO(base64.b64decode(payload))) as reader:
|
|
38
|
+
return reader.read_all()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def schema_to_json(table: pa.Table) -> str:
|
|
42
|
+
return json.dumps(
|
|
43
|
+
[{"name": field.name, "type": str(field.type)} for field in table.schema]
|
|
44
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""SQL literal and identifier encoding.
|
|
2
|
+
|
|
3
|
+
POST /v1/queries takes SQL text only — there are no bind parameters — so
|
|
4
|
+
every value written into SQL must go through quote_literal(), and every
|
|
5
|
+
caller-supplied identifier (column names in search) through quote_ident().
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from .exceptions import RegistryError
|
|
15
|
+
|
|
16
|
+
_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def quote_ident(name: str) -> str:
|
|
20
|
+
if not _IDENT.match(name):
|
|
21
|
+
raise ValueError(f"invalid SQL identifier: {name!r}")
|
|
22
|
+
return name
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def quote_literal(value) -> str:
|
|
26
|
+
if value is None:
|
|
27
|
+
return "NULL"
|
|
28
|
+
if isinstance(value, bool):
|
|
29
|
+
return "TRUE" if value else "FALSE"
|
|
30
|
+
if isinstance(value, int):
|
|
31
|
+
return str(value)
|
|
32
|
+
if isinstance(value, float):
|
|
33
|
+
if math.isnan(value) or math.isinf(value):
|
|
34
|
+
raise RegistryError(f"cannot encode non-finite float: {value!r}")
|
|
35
|
+
return repr(value)
|
|
36
|
+
if isinstance(value, (_dt.datetime, _dt.date)):
|
|
37
|
+
return quote_literal(value.isoformat())
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
if "\x00" in value:
|
|
40
|
+
raise RegistryError("cannot encode string containing NUL byte")
|
|
41
|
+
return "'" + value.replace("'", "''") + "'"
|
|
42
|
+
raise RegistryError(f"cannot encode SQL literal of type {type(value).__name__}")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Hotdata SDK client construction.
|
|
2
|
+
|
|
3
|
+
One ApiClient (and its connection pool) per process, shared by the query,
|
|
4
|
+
databases, and uploads resource APIs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import hotdata
|
|
13
|
+
from hotdata.arrow import ResultsApi
|
|
14
|
+
from hotdata.query import QueryApi
|
|
15
|
+
from hotdata.uploads import UploadsApi
|
|
16
|
+
|
|
17
|
+
from .conf import Config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HotdataClients:
|
|
21
|
+
def __init__(self, config: Config):
|
|
22
|
+
sdk_config = hotdata.Configuration(
|
|
23
|
+
host=config.api_url,
|
|
24
|
+
api_key=config.api_key,
|
|
25
|
+
workspace_id=config.workspace_id,
|
|
26
|
+
)
|
|
27
|
+
self.api_client = hotdata.ApiClient(sdk_config)
|
|
28
|
+
self.query = QueryApi(self.api_client)
|
|
29
|
+
self.databases = hotdata.DatabasesApi(self.api_client)
|
|
30
|
+
self.uploads = UploadsApi(self.api_client)
|
|
31
|
+
self.results = ResultsApi(self.api_client)
|
|
32
|
+
self.indexes = hotdata.IndexesApi(self.api_client)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_lock = threading.Lock()
|
|
36
|
+
_clients: Optional[HotdataClients] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_clients(config: Optional[Config] = None) -> HotdataClients:
|
|
40
|
+
global _clients
|
|
41
|
+
with _lock:
|
|
42
|
+
if _clients is None:
|
|
43
|
+
_clients = HotdataClients(config or Config.from_django())
|
|
44
|
+
return _clients
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def reset_clients() -> None:
|
|
48
|
+
global _clients
|
|
49
|
+
with _lock:
|
|
50
|
+
_clients = None
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Configuration, loaded from the HOTDATA_MATERIALIZED dict in Django settings.
|
|
2
|
+
|
|
3
|
+
The Config dataclass is plain and injectable so the library (and its tests)
|
|
4
|
+
can run without a configured Django project.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Dict
|
|
11
|
+
|
|
12
|
+
from .exceptions import ConfigurationError
|
|
13
|
+
|
|
14
|
+
DEFAULT_API_URL = "https://api.hotdata.dev"
|
|
15
|
+
|
|
16
|
+
_REQUIRED_KEYS = ("API_KEY", "WORKSPACE_ID")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Config:
|
|
21
|
+
api_key: str
|
|
22
|
+
workspace_id: str
|
|
23
|
+
api_url: str = DEFAULT_API_URL
|
|
24
|
+
registry_database: str = "materialized_registry"
|
|
25
|
+
inline_threshold_bytes: int = 2 * 1024 * 1024
|
|
26
|
+
entry_prefix: str = "mz_"
|
|
27
|
+
# Server-side entry-database expiry is set to ttl + grace: a safety net
|
|
28
|
+
# behind the sweep, never the primary eviction path.
|
|
29
|
+
entry_expiry_grace_seconds: int = 24 * 3600
|
|
30
|
+
# Write-behind is the default: a miss returns as soon as the caller's data
|
|
31
|
+
# is in hand and the persist runs on a small background pool.
|
|
32
|
+
background: bool = True
|
|
33
|
+
background_max_workers: int = 2
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, raw: Dict[str, Any]) -> "Config":
|
|
37
|
+
missing = [key for key in _REQUIRED_KEYS if not raw.get(key)]
|
|
38
|
+
if missing:
|
|
39
|
+
raise ConfigurationError(
|
|
40
|
+
"HOTDATA_MATERIALIZED is missing required keys: " + ", ".join(missing)
|
|
41
|
+
)
|
|
42
|
+
return cls(
|
|
43
|
+
api_key=raw["API_KEY"],
|
|
44
|
+
workspace_id=raw["WORKSPACE_ID"],
|
|
45
|
+
api_url=raw.get("API_URL", DEFAULT_API_URL),
|
|
46
|
+
registry_database=raw.get("REGISTRY_DATABASE", cls.registry_database),
|
|
47
|
+
inline_threshold_bytes=raw.get(
|
|
48
|
+
"INLINE_THRESHOLD_BYTES", cls.inline_threshold_bytes
|
|
49
|
+
),
|
|
50
|
+
entry_prefix=raw.get("ENTRY_PREFIX", cls.entry_prefix),
|
|
51
|
+
entry_expiry_grace_seconds=raw.get(
|
|
52
|
+
"ENTRY_EXPIRY_GRACE_SECONDS", cls.entry_expiry_grace_seconds
|
|
53
|
+
),
|
|
54
|
+
background=raw.get("BACKGROUND", cls.background),
|
|
55
|
+
background_max_workers=raw.get(
|
|
56
|
+
"BACKGROUND_MAX_WORKERS", cls.background_max_workers
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_django(cls) -> "Config":
|
|
62
|
+
from django.conf import settings
|
|
63
|
+
|
|
64
|
+
raw = getattr(settings, "HOTDATA_MATERIALIZED", None)
|
|
65
|
+
if not isinstance(raw, dict):
|
|
66
|
+
raise ConfigurationError(
|
|
67
|
+
"Define a HOTDATA_MATERIALIZED dict in Django settings "
|
|
68
|
+
"(required keys: API_KEY, WORKSPACE_ID)."
|
|
69
|
+
)
|
|
70
|
+
return cls.from_dict(raw)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""The @materialize decorator and the MaterializedFrame handle.
|
|
2
|
+
|
|
3
|
+
Wrap an expensive function with @materialize: a hit returns a frame backed by
|
|
4
|
+
Hotdata without calling the function; a miss calls it, returns a frame over
|
|
5
|
+
the computed result immediately, and persists write-behind. The wrapped
|
|
6
|
+
function must return something table-shaped: a pyarrow.Table, a pandas
|
|
7
|
+
DataFrame, or an iterable of dicts (e.g. a Django .values() queryset).
|
|
8
|
+
|
|
9
|
+
Fail-open: if the hit check or the persist raises a MaterializedError, the
|
|
10
|
+
function runs and its result is served uncached — the cache can degrade to
|
|
11
|
+
"no cache," never to "no page."
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
|
|
21
|
+
|
|
22
|
+
import pyarrow as pa
|
|
23
|
+
|
|
24
|
+
from .client import get_clients
|
|
25
|
+
from .conf import Config
|
|
26
|
+
from .exceptions import MaterializedError
|
|
27
|
+
from ._sql import quote_ident, quote_literal
|
|
28
|
+
from .fingerprint import fingerprint_call
|
|
29
|
+
from .indexes import BM25, Vector
|
|
30
|
+
from .registry import STATUS_READY, Registry, RegistryEntry
|
|
31
|
+
from .store import DATA_SCHEMA, DATA_TABLE, EntryStore
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_THIS = re.compile(r"\bthis\b")
|
|
36
|
+
# quoted chunks ('...' with '' escapes, "..." with "" escapes) pass through
|
|
37
|
+
# untouched so a literal like WHERE label = 'this' is never rewritten
|
|
38
|
+
_SQL_CHUNKS = re.compile(r"('(?:[^']|'')*'|\"(?:[^\"]|\"\")*\")")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _rewrite_this(sql: str) -> str:
|
|
42
|
+
return "".join(
|
|
43
|
+
part if part.startswith(("'", '"')) else _THIS.sub(DATA_TABLE, part)
|
|
44
|
+
for part in _SQL_CHUNKS.split(sql)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MaterializedFrame:
|
|
49
|
+
"""Handle over a materialized entry. One interface, two backings: a fresh
|
|
50
|
+
miss wraps the locally computed table; a hit reads from Hotdata lazily."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
store: EntryStore,
|
|
55
|
+
entry: RegistryEntry,
|
|
56
|
+
table: Optional[pa.Table] = None,
|
|
57
|
+
*,
|
|
58
|
+
cached: bool,
|
|
59
|
+
):
|
|
60
|
+
self._store = store
|
|
61
|
+
self.entry = entry
|
|
62
|
+
self._table = table
|
|
63
|
+
self.cached = cached
|
|
64
|
+
|
|
65
|
+
def arrow(self) -> pa.Table:
|
|
66
|
+
if self._table is None:
|
|
67
|
+
self._table = self._store.read_table(self.entry)
|
|
68
|
+
return self._table
|
|
69
|
+
|
|
70
|
+
def to_pylist(self) -> List[Dict[str, Any]]:
|
|
71
|
+
return self.arrow().to_pylist()
|
|
72
|
+
|
|
73
|
+
def df(self) -> Any:
|
|
74
|
+
"""The data as a pandas.DataFrame (requires pandas)."""
|
|
75
|
+
return self.arrow().to_pandas()
|
|
76
|
+
|
|
77
|
+
def sql(self, sql: str) -> pa.Table:
|
|
78
|
+
"""Run SQL server-side against this entry's database; the identifier
|
|
79
|
+
`this` names the cached data (e.g. "SELECT x FROM this LIMIT 5").
|
|
80
|
+
|
|
81
|
+
Requires a persisted entry: available on hits, or after the
|
|
82
|
+
write-behind persist lands (a fresh miss raises StoreError until
|
|
83
|
+
then; use background=False on the decorator to persist inline)."""
|
|
84
|
+
return self._store.query_table(self.entry, _rewrite_this(sql))
|
|
85
|
+
|
|
86
|
+
def search(self, query: str, *, column: str, limit: int = 10) -> pa.Table:
|
|
87
|
+
"""BM25 keyword search over the entry, best matches first. Requires
|
|
88
|
+
a BM25 index on the column (declare with @materialize(index=BM25(...));
|
|
89
|
+
a freshly missed entry may error until the index build finishes)."""
|
|
90
|
+
table_ref = f"default.{DATA_SCHEMA}.{DATA_TABLE}"
|
|
91
|
+
return self._store.query_table(
|
|
92
|
+
self.entry,
|
|
93
|
+
f"SELECT * FROM bm25_search({quote_literal(table_ref)}, "
|
|
94
|
+
f"{quote_literal(quote_ident(column))}, {quote_literal(query)}) "
|
|
95
|
+
f"ORDER BY score DESC LIMIT {int(limit)}",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def vector_search(self, query: str, *, column: str, limit: int = 10) -> pa.Table:
|
|
99
|
+
"""Semantic search over the entry, nearest first. Requires a vector
|
|
100
|
+
index (declare with @materialize(index=Vector(...))). Pass the indexed
|
|
101
|
+
source column; the query text is embedded server-side."""
|
|
102
|
+
return self._store.query_table(
|
|
103
|
+
self.entry,
|
|
104
|
+
f"SELECT *, vector_distance({quote_ident(column)}, "
|
|
105
|
+
f"{quote_literal(query)}) AS distance "
|
|
106
|
+
f"FROM {DATA_TABLE} ORDER BY distance LIMIT {int(limit)}",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def __len__(self) -> int:
|
|
110
|
+
return self.arrow().num_rows
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_runtime_lock = threading.Lock()
|
|
114
|
+
_runtime: Optional[Tuple[Registry, EntryStore]] = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_runtime() -> Tuple[Registry, EntryStore]:
|
|
118
|
+
"""The process-wide (Registry, EntryStore) pair, built from Django
|
|
119
|
+
settings on first use."""
|
|
120
|
+
global _runtime
|
|
121
|
+
with _runtime_lock:
|
|
122
|
+
if _runtime is None:
|
|
123
|
+
config = Config.from_django()
|
|
124
|
+
clients = get_clients(config)
|
|
125
|
+
registry = Registry(clients, config)
|
|
126
|
+
_runtime = (registry, EntryStore(clients, config, registry))
|
|
127
|
+
return _runtime
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def reset_runtime() -> None:
|
|
131
|
+
global _runtime
|
|
132
|
+
with _runtime_lock:
|
|
133
|
+
_runtime = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def to_arrow(result: Any) -> pa.Table:
|
|
137
|
+
"""Normalize a captured result into a pyarrow.Table."""
|
|
138
|
+
if isinstance(result, pa.Table):
|
|
139
|
+
return result
|
|
140
|
+
if type(result).__module__.split(".")[0] == "pandas":
|
|
141
|
+
return pa.Table.from_pandas(result)
|
|
142
|
+
rows = list(result)
|
|
143
|
+
if rows and not isinstance(rows[0], dict):
|
|
144
|
+
raise TypeError(
|
|
145
|
+
"materialize expects a pyarrow.Table, a pandas.DataFrame, or an "
|
|
146
|
+
"iterable of dicts (e.g. a .values() queryset); got an iterable "
|
|
147
|
+
f"of {type(rows[0]).__name__}"
|
|
148
|
+
)
|
|
149
|
+
return pa.Table.from_pylist(rows)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def materialize(
|
|
153
|
+
key: Optional[str] = None,
|
|
154
|
+
*,
|
|
155
|
+
ttl: Optional[int] = 3600,
|
|
156
|
+
version: int = 0,
|
|
157
|
+
background: Optional[bool] = None,
|
|
158
|
+
key_fn: Optional[Callable[..., Any]] = None,
|
|
159
|
+
index: Union[BM25, Vector, Sequence[Union[BM25, Vector]], None] = None,
|
|
160
|
+
) -> Callable[[Callable[..., Any]], Callable[..., MaterializedFrame]]:
|
|
161
|
+
"""Materialize a function's result into Hotdata.
|
|
162
|
+
|
|
163
|
+
@materialize(ttl=3600)
|
|
164
|
+
def revenue_by_region():
|
|
165
|
+
return Order.objects.values("region").annotate(revenue=Sum("total"))
|
|
166
|
+
|
|
167
|
+
The call returns a MaterializedFrame either way; `.cached` says which path
|
|
168
|
+
served it. `version=` busts the cache on code changes; `key_fn=` maps
|
|
169
|
+
non-JSON-serializable arguments to a stable identity. `index=` declares
|
|
170
|
+
BM25/Vector search indexes built when the entry persists.
|
|
171
|
+
"""
|
|
172
|
+
declarations: Tuple[Union[BM25, Vector], ...]
|
|
173
|
+
if index is None:
|
|
174
|
+
declarations = ()
|
|
175
|
+
elif isinstance(index, (BM25, Vector)):
|
|
176
|
+
declarations = (index,)
|
|
177
|
+
else:
|
|
178
|
+
declarations = tuple(index)
|
|
179
|
+
embedded = any(
|
|
180
|
+
isinstance(d, Vector) and d.provider is not None for d in declarations
|
|
181
|
+
)
|
|
182
|
+
if embedded and len(declarations) > 1:
|
|
183
|
+
# platform constraint: embedding-backed vector indexes cannot coexist
|
|
184
|
+
# with other indexes on the same table
|
|
185
|
+
raise ValueError(
|
|
186
|
+
"an embedding-backed Vector index (provider=...) cannot be "
|
|
187
|
+
"combined with other indexes on the same entry"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def decorate(func: Callable[..., Any]) -> Callable[..., MaterializedFrame]:
|
|
191
|
+
@functools.wraps(func)
|
|
192
|
+
def wrapper(*args: Any, **kwargs: Any) -> MaterializedFrame:
|
|
193
|
+
registry, store = get_runtime()
|
|
194
|
+
fingerprint = fingerprint_call(
|
|
195
|
+
func, args, kwargs, version=version, key_fn=key_fn
|
|
196
|
+
)
|
|
197
|
+
entry = None
|
|
198
|
+
try:
|
|
199
|
+
entry = registry.lookup(fingerprint)
|
|
200
|
+
except MaterializedError:
|
|
201
|
+
logger.warning(
|
|
202
|
+
"hit check for %s failed; running uncached",
|
|
203
|
+
func.__qualname__,
|
|
204
|
+
exc_info=True,
|
|
205
|
+
)
|
|
206
|
+
if (
|
|
207
|
+
entry is not None
|
|
208
|
+
and entry.status == STATUS_READY
|
|
209
|
+
and not entry.is_expired(store._now())
|
|
210
|
+
):
|
|
211
|
+
return MaterializedFrame(store, entry, cached=True)
|
|
212
|
+
|
|
213
|
+
result = func(*args, **kwargs)
|
|
214
|
+
table = to_arrow(result)
|
|
215
|
+
label = key or func.__qualname__
|
|
216
|
+
try:
|
|
217
|
+
pending = store.materialize(
|
|
218
|
+
fingerprint,
|
|
219
|
+
table,
|
|
220
|
+
key=label,
|
|
221
|
+
ttl=ttl,
|
|
222
|
+
version=version,
|
|
223
|
+
background=background,
|
|
224
|
+
indexes=declarations,
|
|
225
|
+
)
|
|
226
|
+
except MaterializedError:
|
|
227
|
+
logger.warning(
|
|
228
|
+
"persist of %s failed; serving the result uncached",
|
|
229
|
+
label,
|
|
230
|
+
exc_info=True,
|
|
231
|
+
)
|
|
232
|
+
pending = RegistryEntry(fingerprint=fingerprint, key=label)
|
|
233
|
+
return MaterializedFrame(store, pending, table=table, cached=False)
|
|
234
|
+
|
|
235
|
+
return wrapper
|
|
236
|
+
|
|
237
|
+
return decorate
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Exception hierarchy. Everything raised by this package subclasses
|
|
2
|
+
MaterializedError so callers can catch broadly."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MaterializedError(Exception):
|
|
6
|
+
"""Base class for all hotdata-materialized errors."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigurationError(MaterializedError):
|
|
10
|
+
"""The HOTDATA_MATERIALIZED setting is missing or incomplete."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FingerprintError(MaterializedError):
|
|
14
|
+
"""A call could not be fingerprinted deterministically."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RegistryError(MaterializedError):
|
|
18
|
+
"""A registry read or write failed."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StoreError(MaterializedError):
|
|
22
|
+
"""Materializing or evicting an entry failed."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Entry fingerprinting.
|
|
2
|
+
|
|
3
|
+
A fingerprint content-addresses one materialized entry. False misses are
|
|
4
|
+
safe; false hits are not — so canonicalization rejects anything it cannot
|
|
5
|
+
serialize deterministically instead of falling back to repr(), whose output
|
|
6
|
+
is process-dependent for arbitrary objects.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as _dt
|
|
12
|
+
import decimal
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
from .exceptions import FingerprintError
|
|
18
|
+
|
|
19
|
+
SHORT_LENGTH = 16
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _stable_default(value):
|
|
23
|
+
# Type-tagged so date(2026, 7, 22) and the string "2026-07-22" (etc.)
|
|
24
|
+
# cannot fingerprint identically — a false hit serves someone else's data.
|
|
25
|
+
if isinstance(value, _dt.datetime):
|
|
26
|
+
return f"datetime:{value.isoformat()}"
|
|
27
|
+
if isinstance(value, _dt.date):
|
|
28
|
+
return f"date:{value.isoformat()}"
|
|
29
|
+
if isinstance(value, _dt.time):
|
|
30
|
+
return f"time:{value.isoformat()}"
|
|
31
|
+
if isinstance(value, decimal.Decimal):
|
|
32
|
+
return f"decimal:{value}"
|
|
33
|
+
if isinstance(value, uuid.UUID):
|
|
34
|
+
return f"uuid:{value}"
|
|
35
|
+
if isinstance(value, (set, frozenset)):
|
|
36
|
+
return sorted(value)
|
|
37
|
+
if isinstance(value, (bytes, bytearray)):
|
|
38
|
+
return f"bytes:{value.hex()}"
|
|
39
|
+
raise FingerprintError(
|
|
40
|
+
f"cannot fingerprint value of type {type(value).__name__}; "
|
|
41
|
+
"pass JSON-serializable arguments or supply key_fn= on the decorator"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def canonical_json(obj) -> str:
|
|
46
|
+
try:
|
|
47
|
+
return json.dumps(
|
|
48
|
+
obj, sort_keys=True, separators=(",", ":"), default=_stable_default
|
|
49
|
+
)
|
|
50
|
+
except TypeError as exc:
|
|
51
|
+
raise FingerprintError(str(exc)) from exc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _digest(canonical: str) -> str:
|
|
55
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fingerprint_call(func, args=(), kwargs=None, *, version=0, key_fn=None) -> str:
|
|
59
|
+
ref = f"{func.__module__}.{func.__qualname__}"
|
|
60
|
+
if key_fn is not None:
|
|
61
|
+
call = key_fn(*args, **(kwargs or {}))
|
|
62
|
+
else:
|
|
63
|
+
call = {"args": list(args), "kwargs": kwargs or {}}
|
|
64
|
+
return _digest(canonical_json({"fn": ref, "version": version, "call": call}))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fingerprint_queryset(queryset, *, version=0) -> str:
|
|
68
|
+
# (sql, params) from the compiler, not str(queryset.query) — the latter
|
|
69
|
+
# inlines parameters unreliably and is not stable across backends.
|
|
70
|
+
sql, params = queryset.query.get_compiler(queryset.db).as_sql()
|
|
71
|
+
return _digest(
|
|
72
|
+
canonical_json({"sql": sql, "params": list(params), "version": version})
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def short(fingerprint: str) -> str:
|
|
77
|
+
return fingerprint[:SHORT_LENGTH]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Search-index declarations for @materialize(index=...).
|
|
2
|
+
|
|
3
|
+
Indexes are created right after the entry's data loads; builds run as
|
|
4
|
+
background jobs on the Hotdata side, so a freshly missed entry may briefly
|
|
5
|
+
error on search until its index is ready.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class BM25:
|
|
16
|
+
"""Keyword (BM25) index over a text column; query via frame.search()."""
|
|
17
|
+
|
|
18
|
+
column: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Vector:
|
|
23
|
+
"""Vector index; query via frame.vector_search().
|
|
24
|
+
|
|
25
|
+
With `provider` set (an embedding provider id), the column is treated
|
|
26
|
+
as text and embedded server-side; vector_search then targets the source
|
|
27
|
+
column and query text is embedded automatically. An embedding-backed
|
|
28
|
+
index cannot share a table with other indexes."""
|
|
29
|
+
|
|
30
|
+
column: str
|
|
31
|
+
provider: Optional[str] = None
|
|
32
|
+
metric: Optional[str] = None # "l2" | "cosine" | "dot"
|
|
33
|
+
dimensions: Optional[int] = None
|
|
File without changes
|