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,246 @@
|
|
|
1
|
+
"""The remote registry: one managed database holding one row per entry.
|
|
2
|
+
|
|
3
|
+
The hit/miss check is a SELECT against this database — the host application
|
|
4
|
+
keeps no local state. POST /v1/queries is read-only (DML and DDL are
|
|
5
|
+
rejected), so registry WRITES go through the managed-table load path instead:
|
|
6
|
+
the entries table is declared with key=["fingerprint"] at create time, and
|
|
7
|
+
every write is a one-row parquet load in `upsert` or `delete` mode. Values in
|
|
8
|
+
read-side WHERE clauses are encoded with _sql.quote_literal because the query
|
|
9
|
+
endpoint has no bind parameters.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime as _dt
|
|
15
|
+
import threading
|
|
16
|
+
from dataclasses import asdict, dataclass
|
|
17
|
+
from typing import Any, List, Optional
|
|
18
|
+
|
|
19
|
+
import pyarrow as pa
|
|
20
|
+
from hotdata.models.create_database_request import CreateDatabaseRequest
|
|
21
|
+
from hotdata.models.database_default_schema_decl import DatabaseDefaultSchemaDecl
|
|
22
|
+
from hotdata.models.database_default_table_decl import DatabaseDefaultTableDecl
|
|
23
|
+
from hotdata.models.load_managed_table_request import LoadManagedTableRequest
|
|
24
|
+
from hotdata.models.query_request import QueryRequest
|
|
25
|
+
|
|
26
|
+
from ._arrow import table_to_parquet_bytes
|
|
27
|
+
from ._sql import quote_literal
|
|
28
|
+
from .conf import Config
|
|
29
|
+
from .exceptions import RegistryError
|
|
30
|
+
|
|
31
|
+
REGISTRY_SCHEMA = "main"
|
|
32
|
+
ENTRIES_TABLE = "entries"
|
|
33
|
+
|
|
34
|
+
# Timestamps are ISO-8601 UTC strings ("YYYY-MM-DDTHH:MM:SS+00:00"): for a
|
|
35
|
+
# fixed format, lexicographic order equals chronological order, so expiry
|
|
36
|
+
# comparisons work as plain string comparisons in SQL.
|
|
37
|
+
ENTRIES_ARROW_SCHEMA = pa.schema(
|
|
38
|
+
[
|
|
39
|
+
("fingerprint", pa.string()),
|
|
40
|
+
("key", pa.string()),
|
|
41
|
+
("database_id", pa.string()),
|
|
42
|
+
("status", pa.string()),
|
|
43
|
+
("created_at", pa.string()),
|
|
44
|
+
("expires_at", pa.string()),
|
|
45
|
+
("row_count", pa.int64()),
|
|
46
|
+
("byte_size", pa.int64()),
|
|
47
|
+
("inline_payload", pa.string()),
|
|
48
|
+
("result_id", pa.string()),
|
|
49
|
+
("schema_json", pa.string()),
|
|
50
|
+
("version", pa.int64()),
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_COLUMNS = ENTRIES_ARROW_SCHEMA.names
|
|
55
|
+
|
|
56
|
+
STATUS_BUILDING = "building"
|
|
57
|
+
STATUS_READY = "ready"
|
|
58
|
+
STATUS_FAILED = "failed"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def utcnow_iso() -> str:
|
|
62
|
+
return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _iso_plus_seconds(iso: str, seconds: int) -> str:
|
|
66
|
+
moment = _dt.datetime.fromisoformat(iso)
|
|
67
|
+
return (moment + _dt.timedelta(seconds=seconds)).isoformat()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class RegistryEntry:
|
|
72
|
+
fingerprint: str
|
|
73
|
+
key: Optional[str] = None
|
|
74
|
+
database_id: Optional[str] = None
|
|
75
|
+
status: str = STATUS_BUILDING
|
|
76
|
+
created_at: str = ""
|
|
77
|
+
expires_at: Optional[str] = None
|
|
78
|
+
row_count: Optional[int] = None
|
|
79
|
+
byte_size: Optional[int] = None
|
|
80
|
+
inline_payload: Optional[str] = None
|
|
81
|
+
# Persisted result of SELECT * on the entry, minted at persist time:
|
|
82
|
+
# remote hits fetch it as Arrow directly instead of re-running the query.
|
|
83
|
+
result_id: Optional[str] = None
|
|
84
|
+
schema_json: Optional[str] = None
|
|
85
|
+
version: int = 0
|
|
86
|
+
|
|
87
|
+
def is_expired(self, now_iso: str) -> bool:
|
|
88
|
+
return self.expires_at is not None and self.expires_at <= now_iso
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_row(cls, columns: List[str], row: List[Any]) -> "RegistryEntry":
|
|
92
|
+
data = dict(zip(columns, row))
|
|
93
|
+
values = {name: data.get(name) for name in _COLUMNS}
|
|
94
|
+
values["version"] = values["version"] or 0
|
|
95
|
+
return cls(**values) # type: ignore[arg-type]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Registry:
|
|
99
|
+
def __init__(self, clients, config: Config, now_fn=utcnow_iso):
|
|
100
|
+
self._clients = clients
|
|
101
|
+
self._config = config
|
|
102
|
+
self._now = now_fn
|
|
103
|
+
self._database_id: Optional[str] = None
|
|
104
|
+
self._bootstrap_lock = threading.Lock()
|
|
105
|
+
|
|
106
|
+
# -- bootstrap ---------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def database_id(self) -> str:
|
|
109
|
+
with self._bootstrap_lock:
|
|
110
|
+
if self._database_id is None:
|
|
111
|
+
self._database_id = self._find_or_create_database()
|
|
112
|
+
return self._database_id
|
|
113
|
+
|
|
114
|
+
def _find_or_create_database(self) -> str:
|
|
115
|
+
listing = self._clients.databases.list_databases()
|
|
116
|
+
for database in listing.databases:
|
|
117
|
+
if database.name == self._config.registry_database:
|
|
118
|
+
return database.id
|
|
119
|
+
created = self._clients.databases.create_database(
|
|
120
|
+
CreateDatabaseRequest(
|
|
121
|
+
name=self._config.registry_database,
|
|
122
|
+
schemas=[
|
|
123
|
+
DatabaseDefaultSchemaDecl(
|
|
124
|
+
name=REGISTRY_SCHEMA,
|
|
125
|
+
tables=[
|
|
126
|
+
DatabaseDefaultTableDecl(
|
|
127
|
+
name=ENTRIES_TABLE, key=["fingerprint"]
|
|
128
|
+
)
|
|
129
|
+
],
|
|
130
|
+
)
|
|
131
|
+
],
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
# An empty replace-mode load publishes the entries schema so SELECTs
|
|
135
|
+
# work before the first real write.
|
|
136
|
+
self._apply_load(created.id, "replace", ENTRIES_ARROW_SCHEMA.empty_table())
|
|
137
|
+
return created.id
|
|
138
|
+
|
|
139
|
+
# -- plumbing ------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def _execute(self, sql: str, _retried: bool = False):
|
|
142
|
+
try:
|
|
143
|
+
return self._clients.query.query(
|
|
144
|
+
QueryRequest(sql=sql), x_database_id=self.database_id()
|
|
145
|
+
)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
# A registry database found by name may be declared but never
|
|
148
|
+
# seeded (e.g. a crashed bootstrap); publish the schema and retry.
|
|
149
|
+
if not _retried and f"{ENTRIES_TABLE}' not found" in str(exc):
|
|
150
|
+
self._apply_load(
|
|
151
|
+
self.database_id(), "replace", ENTRIES_ARROW_SCHEMA.empty_table()
|
|
152
|
+
)
|
|
153
|
+
return self._execute(sql, _retried=True)
|
|
154
|
+
raise RegistryError(f"registry query failed: {exc}") from exc
|
|
155
|
+
|
|
156
|
+
def _apply_load(self, database_id: str, mode: str, table: pa.Table) -> None:
|
|
157
|
+
try:
|
|
158
|
+
upload = self._clients.uploads.upload_file(
|
|
159
|
+
table_to_parquet_bytes(table),
|
|
160
|
+
filename=f"registry-{mode}.parquet",
|
|
161
|
+
content_type="application/vnd.apache.parquet",
|
|
162
|
+
)
|
|
163
|
+
self._clients.databases.load_database_table(
|
|
164
|
+
database_id,
|
|
165
|
+
REGISTRY_SCHEMA,
|
|
166
|
+
ENTRIES_TABLE,
|
|
167
|
+
LoadManagedTableRequest(
|
|
168
|
+
mode=mode,
|
|
169
|
+
upload_id=upload.upload_id,
|
|
170
|
+
format="parquet",
|
|
171
|
+
key=["fingerprint"],
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
raise RegistryError(f"registry {mode} write failed: {exc}") from exc
|
|
176
|
+
|
|
177
|
+
def _upsert(self, entry: RegistryEntry) -> None:
|
|
178
|
+
table = pa.Table.from_pylist([asdict(entry)], schema=ENTRIES_ARROW_SCHEMA)
|
|
179
|
+
self._apply_load(self.database_id(), "upsert", table)
|
|
180
|
+
|
|
181
|
+
# -- reads ---------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def lookup(self, fingerprint: str) -> Optional[RegistryEntry]:
|
|
184
|
+
response = self._execute(
|
|
185
|
+
f"SELECT {', '.join(_COLUMNS)} FROM {ENTRIES_TABLE} "
|
|
186
|
+
f"WHERE fingerprint = {quote_literal(fingerprint)}"
|
|
187
|
+
)
|
|
188
|
+
if not response.rows:
|
|
189
|
+
return None
|
|
190
|
+
return RegistryEntry.from_row(response.columns, response.rows[0])
|
|
191
|
+
|
|
192
|
+
def list_expired(self) -> List[RegistryEntry]:
|
|
193
|
+
now = self._now()
|
|
194
|
+
response = self._execute(
|
|
195
|
+
f"SELECT {', '.join(_COLUMNS)} FROM {ENTRIES_TABLE} "
|
|
196
|
+
f"WHERE expires_at IS NOT NULL AND expires_at <= {quote_literal(now)}"
|
|
197
|
+
)
|
|
198
|
+
return [RegistryEntry.from_row(response.columns, row) for row in response.rows]
|
|
199
|
+
|
|
200
|
+
# -- writes --------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def mark_ready(
|
|
203
|
+
self,
|
|
204
|
+
fingerprint: str,
|
|
205
|
+
*,
|
|
206
|
+
database_id: str,
|
|
207
|
+
ttl: Optional[int],
|
|
208
|
+
row_count: int,
|
|
209
|
+
byte_size: int,
|
|
210
|
+
inline_payload: Optional[str] = None,
|
|
211
|
+
result_id: Optional[str] = None,
|
|
212
|
+
schema_json: Optional[str] = None,
|
|
213
|
+
key: Optional[str] = None,
|
|
214
|
+
version: int = 0,
|
|
215
|
+
) -> RegistryEntry:
|
|
216
|
+
now = self._now()
|
|
217
|
+
entry = RegistryEntry(
|
|
218
|
+
fingerprint=fingerprint,
|
|
219
|
+
key=key,
|
|
220
|
+
database_id=database_id,
|
|
221
|
+
status=STATUS_READY,
|
|
222
|
+
created_at=now,
|
|
223
|
+
expires_at=_iso_plus_seconds(now, ttl) if ttl is not None else None,
|
|
224
|
+
row_count=row_count,
|
|
225
|
+
byte_size=byte_size,
|
|
226
|
+
inline_payload=inline_payload,
|
|
227
|
+
result_id=result_id,
|
|
228
|
+
schema_json=schema_json,
|
|
229
|
+
version=version,
|
|
230
|
+
)
|
|
231
|
+
self._upsert(entry)
|
|
232
|
+
return entry
|
|
233
|
+
|
|
234
|
+
def mark_failed(self, fingerprint: str) -> None:
|
|
235
|
+
existing = self.lookup(fingerprint)
|
|
236
|
+
if existing is None:
|
|
237
|
+
return
|
|
238
|
+
existing.status = STATUS_FAILED
|
|
239
|
+
self._upsert(existing)
|
|
240
|
+
|
|
241
|
+
def delete(self, fingerprint: str) -> None:
|
|
242
|
+
table = pa.Table.from_pylist(
|
|
243
|
+
[{"fingerprint": fingerprint}],
|
|
244
|
+
schema=pa.schema([("fingerprint", pa.string())]),
|
|
245
|
+
)
|
|
246
|
+
self._apply_load(self.database_id(), "delete", table)
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""The entry store: one managed database per materialized entry.
|
|
2
|
+
|
|
3
|
+
Persist path: create database -> upload parquet (presigned, direct to object
|
|
4
|
+
storage) -> load into main.data -> mark the registry row ready. Everything
|
|
5
|
+
from database creation through registry publication is one failure domain: if
|
|
6
|
+
any step fails, the entry database is deleted so a populated-but-unpublished
|
|
7
|
+
database cannot leak. Eviction deletes the registry row first, then the
|
|
8
|
+
database — a failure mid-evict leaves an orphaned database for the
|
|
9
|
+
reconciliation sweep, never a ready row pointing at deleted data.
|
|
10
|
+
|
|
11
|
+
Write-behind is the default: materialize() returns a pending entry as soon as
|
|
12
|
+
the data is captured and runs the persist on a small process-global thread
|
|
13
|
+
pool. Failures are logged loudly and surfaced by flush(); they leave no ready
|
|
14
|
+
registry row — the next request simply misses again. Until mark_ready lands
|
|
15
|
+
there is no registry row, so concurrent misses on other processes may
|
|
16
|
+
duplicate the source query (accepted for now).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime as _dt
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from concurrent.futures import Future, ThreadPoolExecutor, wait as futures_wait
|
|
26
|
+
from typing import Any, Dict, List, Optional, Sequence, Set, Union
|
|
27
|
+
|
|
28
|
+
import pyarrow as pa
|
|
29
|
+
from hotdata.arrow import ResultNotReadyError
|
|
30
|
+
from hotdata.exceptions import ApiException, ConflictException
|
|
31
|
+
from hotdata.models.create_database_request import CreateDatabaseRequest
|
|
32
|
+
from hotdata.models.database_default_schema_decl import DatabaseDefaultSchemaDecl
|
|
33
|
+
from hotdata.models.database_default_table_decl import DatabaseDefaultTableDecl
|
|
34
|
+
from hotdata.models.create_index_request import CreateIndexRequest
|
|
35
|
+
from hotdata.models.load_managed_table_request import LoadManagedTableRequest
|
|
36
|
+
from hotdata.models.query_request import QueryRequest
|
|
37
|
+
|
|
38
|
+
from . import fingerprint as fp
|
|
39
|
+
from ._arrow import (
|
|
40
|
+
decode_inline_payload,
|
|
41
|
+
encode_inline_payload,
|
|
42
|
+
schema_to_json,
|
|
43
|
+
table_to_parquet_bytes,
|
|
44
|
+
)
|
|
45
|
+
from .conf import Config
|
|
46
|
+
from .exceptions import StoreError
|
|
47
|
+
from .indexes import BM25, Vector
|
|
48
|
+
from .registry import STATUS_BUILDING, Registry, RegistryEntry, utcnow_iso
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"EntryStore",
|
|
52
|
+
"DATA_SCHEMA",
|
|
53
|
+
"DATA_TABLE",
|
|
54
|
+
"decode_inline_payload",
|
|
55
|
+
"encode_inline_payload",
|
|
56
|
+
"schema_to_json",
|
|
57
|
+
"table_to_parquet_bytes",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
DATA_SCHEMA = "main"
|
|
63
|
+
DATA_TABLE = "data"
|
|
64
|
+
|
|
65
|
+
_executor_lock = threading.Lock()
|
|
66
|
+
_executor: Optional[ThreadPoolExecutor] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_executor(max_workers: int) -> ThreadPoolExecutor:
|
|
70
|
+
"""Process-global persist pool, sized by the first caller's config."""
|
|
71
|
+
global _executor
|
|
72
|
+
with _executor_lock:
|
|
73
|
+
if _executor is None:
|
|
74
|
+
_executor = ThreadPoolExecutor(
|
|
75
|
+
max_workers=max_workers, thread_name_prefix="hotdata-materialized"
|
|
76
|
+
)
|
|
77
|
+
return _executor
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class EntryStore:
|
|
81
|
+
def __init__(self, clients, config: Config, registry: Registry, now_fn=utcnow_iso):
|
|
82
|
+
self._clients = clients
|
|
83
|
+
self._config = config
|
|
84
|
+
self._registry = registry
|
|
85
|
+
self._now = now_fn
|
|
86
|
+
self._inflight: Dict[str, Set["Future[None]"]] = {}
|
|
87
|
+
self._inflight_lock = threading.Lock()
|
|
88
|
+
|
|
89
|
+
def materialize(
|
|
90
|
+
self,
|
|
91
|
+
fingerprint: str,
|
|
92
|
+
table: pa.Table,
|
|
93
|
+
*,
|
|
94
|
+
key: Optional[str] = None,
|
|
95
|
+
ttl: Optional[int] = None,
|
|
96
|
+
version: int = 0,
|
|
97
|
+
background: Optional[bool] = None,
|
|
98
|
+
indexes: Optional[Sequence[Union[BM25, Vector]]] = None,
|
|
99
|
+
) -> RegistryEntry:
|
|
100
|
+
"""Persist a captured result as a materialized entry.
|
|
101
|
+
|
|
102
|
+
In background mode (the config default) this returns a pending entry
|
|
103
|
+
(status='building', no database_id) immediately; call flush() to wait
|
|
104
|
+
for in-flight persists. Synchronous mode returns the ready entry."""
|
|
105
|
+
if background is None:
|
|
106
|
+
background = self._config.background
|
|
107
|
+
if not background:
|
|
108
|
+
return self._persist(
|
|
109
|
+
fingerprint, table, key=key, ttl=ttl, version=version, indexes=indexes
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
executor = _get_executor(self._config.background_max_workers)
|
|
113
|
+
with self._inflight_lock:
|
|
114
|
+
future = executor.submit(
|
|
115
|
+
self._persist_background, fingerprint, table, key, ttl, version, indexes
|
|
116
|
+
)
|
|
117
|
+
self._inflight.setdefault(fingerprint, set()).add(future)
|
|
118
|
+
|
|
119
|
+
# The done-callback (not the job body) untracks the future: it fires
|
|
120
|
+
# even for a future that completed before this line, and discarding a
|
|
121
|
+
# set member means an old job can never untrack a newer one.
|
|
122
|
+
def _untrack(done: "Future[None]", fingerprint: str = fingerprint) -> None:
|
|
123
|
+
with self._inflight_lock:
|
|
124
|
+
bucket = self._inflight.get(fingerprint)
|
|
125
|
+
if bucket is not None:
|
|
126
|
+
bucket.discard(done)
|
|
127
|
+
if not bucket:
|
|
128
|
+
del self._inflight[fingerprint]
|
|
129
|
+
|
|
130
|
+
future.add_done_callback(_untrack)
|
|
131
|
+
return RegistryEntry(
|
|
132
|
+
fingerprint=fingerprint,
|
|
133
|
+
key=key,
|
|
134
|
+
status=STATUS_BUILDING,
|
|
135
|
+
created_at=self._now(),
|
|
136
|
+
version=version,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def flush(self, timeout: Optional[float] = None) -> List[BaseException]:
|
|
140
|
+
"""Wait for in-flight background persists; return their failures."""
|
|
141
|
+
with self._inflight_lock:
|
|
142
|
+
pending = [f for bucket in self._inflight.values() for f in bucket]
|
|
143
|
+
done, _ = futures_wait(pending, timeout=timeout)
|
|
144
|
+
return [e for e in (f.exception() for f in done) if e is not None]
|
|
145
|
+
|
|
146
|
+
def _persist_background(
|
|
147
|
+
self, fingerprint, table, key, ttl, version, indexes
|
|
148
|
+
) -> None:
|
|
149
|
+
try:
|
|
150
|
+
self._persist(
|
|
151
|
+
fingerprint, table, key=key, ttl=ttl, version=version, indexes=indexes
|
|
152
|
+
)
|
|
153
|
+
except Exception:
|
|
154
|
+
# Loud by design: a quietly failing write-behind path means the
|
|
155
|
+
# cache never fills and nobody notices.
|
|
156
|
+
logger.exception(
|
|
157
|
+
"background persist of entry %s failed; next request will miss",
|
|
158
|
+
fp.short(fingerprint),
|
|
159
|
+
)
|
|
160
|
+
raise
|
|
161
|
+
|
|
162
|
+
def _persist(
|
|
163
|
+
self,
|
|
164
|
+
fingerprint: str,
|
|
165
|
+
table: pa.Table,
|
|
166
|
+
*,
|
|
167
|
+
key: Optional[str] = None,
|
|
168
|
+
ttl: Optional[int] = None,
|
|
169
|
+
version: int = 0,
|
|
170
|
+
indexes: Optional[Sequence[Union[BM25, Vector]]] = None,
|
|
171
|
+
) -> RegistryEntry:
|
|
172
|
+
parquet_bytes = table_to_parquet_bytes(table)
|
|
173
|
+
created = self._create_entry_database(fingerprint, ttl)
|
|
174
|
+
database_id = created.id
|
|
175
|
+
try:
|
|
176
|
+
upload = self._clients.uploads.upload_file(
|
|
177
|
+
parquet_bytes,
|
|
178
|
+
filename=f"{fp.short(fingerprint)}.parquet",
|
|
179
|
+
content_type="application/vnd.apache.parquet",
|
|
180
|
+
)
|
|
181
|
+
# TODO: adopt the batch loads endpoint (commit_id idempotency)
|
|
182
|
+
# when available; single-table loads have no commit_id.
|
|
183
|
+
load = self._clients.databases.load_database_table(
|
|
184
|
+
database_id,
|
|
185
|
+
DATA_SCHEMA,
|
|
186
|
+
DATA_TABLE,
|
|
187
|
+
LoadManagedTableRequest(
|
|
188
|
+
mode="replace", upload_id=upload.upload_id, format="parquet"
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
inline_payload = encode_inline_payload(
|
|
192
|
+
table, self._config.inline_threshold_bytes
|
|
193
|
+
)
|
|
194
|
+
# For entries too big to inline, mint a persisted SELECT * result
|
|
195
|
+
# now (usually off the request path): remote hits then fetch it as
|
|
196
|
+
# Arrow in one call instead of re-running the query every read.
|
|
197
|
+
result_id = None
|
|
198
|
+
if inline_payload is None:
|
|
199
|
+
result_id = self._clients.query.query(
|
|
200
|
+
QueryRequest(sql=f"SELECT * FROM {DATA_TABLE}"),
|
|
201
|
+
x_database_id=database_id,
|
|
202
|
+
auto_follow=False,
|
|
203
|
+
).result_id
|
|
204
|
+
entry = self._registry.mark_ready(
|
|
205
|
+
fingerprint,
|
|
206
|
+
database_id=database_id,
|
|
207
|
+
ttl=ttl,
|
|
208
|
+
row_count=load.row_count,
|
|
209
|
+
byte_size=len(parquet_bytes),
|
|
210
|
+
inline_payload=inline_payload,
|
|
211
|
+
result_id=result_id,
|
|
212
|
+
schema_json=schema_to_json(table),
|
|
213
|
+
key=key,
|
|
214
|
+
version=version,
|
|
215
|
+
)
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
# One failure domain through registry publication: a populated
|
|
218
|
+
# database without a ready row must not outlive the attempt.
|
|
219
|
+
self._cleanup_failed_build(fingerprint, database_id)
|
|
220
|
+
raise StoreError(f"materializing entry failed: {exc}") from exc
|
|
221
|
+
|
|
222
|
+
# Index kickoff is deliberately OUTSIDE the publication failure
|
|
223
|
+
# domain: a bad provider id or a stuck table lock must not discard
|
|
224
|
+
# otherwise-valid cached data. The entry stays served; search errors
|
|
225
|
+
# at the search site until the index problem is fixed.
|
|
226
|
+
try:
|
|
227
|
+
for declaration in indexes or ():
|
|
228
|
+
self._create_index(created.default_connection_id, declaration)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
raise StoreError(
|
|
231
|
+
f"index creation for entry {fp.short(fingerprint)} failed "
|
|
232
|
+
f"(entry cached; search unavailable): {exc}"
|
|
233
|
+
) from exc
|
|
234
|
+
return entry
|
|
235
|
+
|
|
236
|
+
def read_table(self, entry: RegistryEntry) -> pa.Table:
|
|
237
|
+
"""Materialize an entry's data as a pyarrow.Table.
|
|
238
|
+
|
|
239
|
+
Inline-payload entries decode locally with no network call. Remote
|
|
240
|
+
entries fetch the persisted SELECT * result minted at persist time as
|
|
241
|
+
an Arrow IPC stream (one call once ready); if that result is gone
|
|
242
|
+
(expired server-side), fall back to re-running the query."""
|
|
243
|
+
if entry.inline_payload:
|
|
244
|
+
return decode_inline_payload(entry.inline_payload)
|
|
245
|
+
database_id = entry.database_id
|
|
246
|
+
if database_id is None:
|
|
247
|
+
raise StoreError(
|
|
248
|
+
f"entry {fp.short(entry.fingerprint)} has no database yet "
|
|
249
|
+
"(still building?)"
|
|
250
|
+
)
|
|
251
|
+
if entry.result_id:
|
|
252
|
+
try:
|
|
253
|
+
return self._fetch_arrow(entry.result_id, database_id)
|
|
254
|
+
except Exception:
|
|
255
|
+
logger.info(
|
|
256
|
+
"stored result for entry %s no longer readable; re-querying",
|
|
257
|
+
fp.short(entry.fingerprint),
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
return self._run_select(database_id, f"SELECT * FROM {DATA_TABLE}")
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
raise StoreError(
|
|
263
|
+
f"reading entry {fp.short(entry.fingerprint)} failed: {exc}"
|
|
264
|
+
) from exc
|
|
265
|
+
|
|
266
|
+
def query_table(self, entry: RegistryEntry, sql: str) -> pa.Table:
|
|
267
|
+
"""Run SQL server-side against the entry's database, returning Arrow."""
|
|
268
|
+
database_id = entry.database_id
|
|
269
|
+
if database_id is None:
|
|
270
|
+
raise StoreError(
|
|
271
|
+
f"entry {fp.short(entry.fingerprint)} has no database yet "
|
|
272
|
+
"(still building?)"
|
|
273
|
+
)
|
|
274
|
+
try:
|
|
275
|
+
return self._run_select(database_id, sql)
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
raise StoreError(
|
|
278
|
+
f"query on entry {fp.short(entry.fingerprint)} failed: {exc}"
|
|
279
|
+
) from exc
|
|
280
|
+
|
|
281
|
+
def _run_select(self, database_id: str, sql: str) -> pa.Table:
|
|
282
|
+
response = self._clients.query.query(
|
|
283
|
+
QueryRequest(sql=sql), x_database_id=database_id, auto_follow=False
|
|
284
|
+
)
|
|
285
|
+
if response.result_id is None:
|
|
286
|
+
# answered fully inline and never persisted; nothing to fetch.
|
|
287
|
+
# Keep the column names even for zero rows.
|
|
288
|
+
if not response.rows:
|
|
289
|
+
return pa.table({column: [] for column in response.columns})
|
|
290
|
+
return pa.Table.from_pylist(
|
|
291
|
+
[dict(zip(response.columns, row)) for row in response.rows]
|
|
292
|
+
)
|
|
293
|
+
return self._fetch_arrow(response.result_id, database_id)
|
|
294
|
+
|
|
295
|
+
def _fetch_arrow(self, result_id: str, database_id: str) -> pa.Table:
|
|
296
|
+
"""Fetch a persisted result as Arrow, waiting out async persistence
|
|
297
|
+
(the SDK polls status-only with limit=0) when it isn't ready yet."""
|
|
298
|
+
try:
|
|
299
|
+
return self._clients.results.get_result_arrow(result_id, database_id)
|
|
300
|
+
except ResultNotReadyError:
|
|
301
|
+
self._clients.query.wait_for_result(
|
|
302
|
+
result_id, database_id, results_api=self._clients.results
|
|
303
|
+
)
|
|
304
|
+
return self._clients.results.get_result_arrow(result_id, database_id)
|
|
305
|
+
|
|
306
|
+
def evict(self, fingerprint: str) -> None:
|
|
307
|
+
# A persist racing an evict would recreate the row after the delete;
|
|
308
|
+
# wait out every in-flight background persist of this entry first.
|
|
309
|
+
with self._inflight_lock:
|
|
310
|
+
pending = list(self._inflight.get(fingerprint, ()))
|
|
311
|
+
if pending:
|
|
312
|
+
futures_wait(pending)
|
|
313
|
+
entry = self._registry.lookup(fingerprint)
|
|
314
|
+
# Row first: a failure after this point orphans a database (the
|
|
315
|
+
# reconciliation sweep's job), never a ready row over deleted data.
|
|
316
|
+
self._registry.delete(fingerprint)
|
|
317
|
+
if entry is not None and entry.database_id:
|
|
318
|
+
self._delete_database(entry.database_id)
|
|
319
|
+
|
|
320
|
+
# -- internals -----------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def _create_entry_database(self, fingerprint: str, ttl: Optional[int]) -> Any:
|
|
323
|
+
# Server-side expiry (ttl + grace) is a best-effort backstop behind
|
|
324
|
+
# the sweep; label is display-only — identity is the id via registry.
|
|
325
|
+
expires_at = None
|
|
326
|
+
if ttl is not None:
|
|
327
|
+
moment = _dt.datetime.fromisoformat(self._now()) + _dt.timedelta(
|
|
328
|
+
seconds=ttl + self._config.entry_expiry_grace_seconds
|
|
329
|
+
)
|
|
330
|
+
expires_at = moment.isoformat()
|
|
331
|
+
try:
|
|
332
|
+
created = self._clients.databases.create_database(
|
|
333
|
+
CreateDatabaseRequest(
|
|
334
|
+
name=f"{self._config.entry_prefix}{fingerprint[:32]}",
|
|
335
|
+
expires_at=expires_at,
|
|
336
|
+
schemas=[
|
|
337
|
+
DatabaseDefaultSchemaDecl(
|
|
338
|
+
name=DATA_SCHEMA,
|
|
339
|
+
tables=[DatabaseDefaultTableDecl(name=DATA_TABLE)],
|
|
340
|
+
)
|
|
341
|
+
],
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
except Exception as exc:
|
|
345
|
+
raise StoreError(f"creating entry database failed: {exc}") from exc
|
|
346
|
+
return created
|
|
347
|
+
|
|
348
|
+
def _create_index(self, connection_id: str, declaration: Union[BM25, Vector]) -> None:
|
|
349
|
+
# index builds run as async jobs server-side; the persist does not wait
|
|
350
|
+
# "async" is a reserved word, so the SDK model aliases it; build
|
|
351
|
+
# requests from the wire shape
|
|
352
|
+
if isinstance(declaration, BM25):
|
|
353
|
+
request = CreateIndexRequest.model_validate({
|
|
354
|
+
"index_name": f"{DATA_TABLE}_{declaration.column}_bm25",
|
|
355
|
+
"index_type": "bm25",
|
|
356
|
+
"columns": [declaration.column],
|
|
357
|
+
"async": True,
|
|
358
|
+
})
|
|
359
|
+
else:
|
|
360
|
+
request = CreateIndexRequest.model_validate({
|
|
361
|
+
"index_name": f"{DATA_TABLE}_{declaration.column}_vector",
|
|
362
|
+
"index_type": "vector",
|
|
363
|
+
"columns": [declaration.column],
|
|
364
|
+
"metric": declaration.metric,
|
|
365
|
+
"embedding_provider_id": declaration.provider,
|
|
366
|
+
"dimensions": declaration.dimensions,
|
|
367
|
+
"async": True,
|
|
368
|
+
})
|
|
369
|
+
# the data load can still hold the table lock when this runs
|
|
370
|
+
# (observed: 409 RESOURCE_LOCKED with Retry-After); wait it out
|
|
371
|
+
for attempt in range(6):
|
|
372
|
+
try:
|
|
373
|
+
self._clients.indexes.create_index(
|
|
374
|
+
connection_id, DATA_SCHEMA, DATA_TABLE, request
|
|
375
|
+
)
|
|
376
|
+
return
|
|
377
|
+
except ConflictException:
|
|
378
|
+
if attempt == 5:
|
|
379
|
+
raise
|
|
380
|
+
time.sleep(5)
|
|
381
|
+
|
|
382
|
+
def _delete_database(self, database_id: str) -> None:
|
|
383
|
+
try:
|
|
384
|
+
self._clients.databases.delete_database(database_id)
|
|
385
|
+
except ApiException as exc:
|
|
386
|
+
if exc.status != 404:
|
|
387
|
+
raise StoreError(
|
|
388
|
+
f"deleting entry database {database_id} failed: {exc}"
|
|
389
|
+
) from exc
|
|
390
|
+
|
|
391
|
+
def _cleanup_failed_build(self, fingerprint: str, database_id: str) -> None:
|
|
392
|
+
try:
|
|
393
|
+
self._delete_database(database_id)
|
|
394
|
+
except Exception:
|
|
395
|
+
logger.warning(
|
|
396
|
+
"cleanup after failed build of %s left a database behind",
|
|
397
|
+
fp.short(fingerprint),
|
|
398
|
+
exc_info=True,
|
|
399
|
+
)
|
|
400
|
+
try:
|
|
401
|
+
self._registry.mark_failed(fingerprint)
|
|
402
|
+
except Exception:
|
|
403
|
+
logger.warning(
|
|
404
|
+
"could not mark entry %s failed", fp.short(fingerprint), exc_info=True
|
|
405
|
+
)
|