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.
@@ -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
+ )