bondi 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.
bondi/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """A Python toolkit for document database research."""
bondi/_sync.py ADDED
@@ -0,0 +1,122 @@
1
+ """Private background-loop bridge for synchronous database facades."""
2
+
3
+ import asyncio
4
+ from concurrent.futures import Future
5
+ from threading import Event, Thread, get_ident
6
+ from typing import Self
7
+
8
+
9
+ class _SyncExecutor:
10
+ """Own an async executor on a background loop; calls stay on the owner thread."""
11
+
12
+ def __init__(self):
13
+ raise TypeError("Use from_uri() to create an owned executor")
14
+
15
+ @classmethod
16
+ def _create(cls, factory):
17
+ self = object.__new__(cls)
18
+ self._thread = get_ident()
19
+ self._closed = False
20
+ self._executor = None
21
+ ready = Event()
22
+
23
+ def serve():
24
+ with asyncio.Runner() as runner:
25
+ self._loop = runner.get_loop()
26
+ ready.set()
27
+ self._loop.run_forever()
28
+
29
+ self._worker = Thread(target=serve, name=f"bondi-{cls.__name__}", daemon=True)
30
+ self._worker.start()
31
+ ready.wait()
32
+
33
+ async def connect():
34
+ self._executor = await factory()
35
+
36
+ try:
37
+ self._submit(connect)
38
+ except BaseException:
39
+ try:
40
+ if self._executor is not None:
41
+ self._submit(self._executor.close, cancel_on_interrupt=False)
42
+ finally:
43
+ self._shutdown()
44
+ raise
45
+ return self
46
+
47
+ def _check(self):
48
+ if get_ident() != self._thread:
49
+ raise RuntimeError("Use this executor only from its creating thread")
50
+ if self._closed:
51
+ raise RuntimeError("Executor is closed")
52
+
53
+ def _submit(self, operation, *, cancel_on_interrupt=True):
54
+ """Wait for an operation, including its cancellation cleanup on interrupt."""
55
+ self._check()
56
+ outcome = Future()
57
+ task = None
58
+
59
+ async def invoke():
60
+ try:
61
+ value = await operation()
62
+ except BaseException as error:
63
+ outcome.set_exception(error)
64
+ else:
65
+ outcome.set_result(value)
66
+
67
+ def start():
68
+ nonlocal task
69
+ task = self._loop.create_task(invoke())
70
+
71
+ def cancel():
72
+ if task is not None and not task.done():
73
+ # Schedule after the already-queued first task step.
74
+ self._loop.call_soon(task.cancel)
75
+
76
+ self._loop.call_soon_threadsafe(start)
77
+ try:
78
+ return outcome.result()
79
+ except KeyboardInterrupt:
80
+ if cancel_on_interrupt:
81
+ self._loop.call_soon_threadsafe(cancel)
82
+ # Do not return control until the coroutine has finished unwinding.
83
+ while not outcome.done():
84
+ try:
85
+ outcome.result(timeout=0.1)
86
+ except (KeyboardInterrupt, TimeoutError):
87
+ continue
88
+ except BaseException:
89
+ break
90
+ raise
91
+
92
+ def _call(self, method, *args, **kwargs):
93
+ async def invoke():
94
+ return await getattr(self._executor, method)(*args, **kwargs)
95
+
96
+ return self._submit(invoke)
97
+
98
+ def _shutdown(self):
99
+ self._closed = True
100
+ self._loop.call_soon_threadsafe(self._loop.stop)
101
+ while self._worker.is_alive():
102
+ try:
103
+ self._worker.join(timeout=0.1)
104
+ except KeyboardInterrupt:
105
+ continue
106
+
107
+ def close(self) -> None:
108
+ """Close the connection, stop its loop and join its thread; idempotent."""
109
+ if self._closed:
110
+ return
111
+ self._check()
112
+ try:
113
+ self._submit(self._executor.close, cancel_on_interrupt=False)
114
+ finally:
115
+ self._shutdown()
116
+
117
+ def __enter__(self) -> Self:
118
+ self._check()
119
+ return self
120
+
121
+ def __exit__(self, exc_type, exc, traceback) -> None:
122
+ self.close()
@@ -0,0 +1,8 @@
1
+ """Native Couchbase query descriptions without an SDK connection."""
2
+
3
+ from bondi.couchbase.executor import AsyncCouchbaseExecutor
4
+ from bondi.couchbase.explain import summarize
5
+ from bondi.couchbase.query import CouchbaseQuery
6
+ from bondi.couchbase.sync_executor import CouchbaseExecutor
7
+
8
+ __all__ = ["CouchbaseExecutor", "CouchbaseQuery", "AsyncCouchbaseExecutor", "summarize"]
@@ -0,0 +1,376 @@
1
+ """Async SQL++ operations using the optional Couchbase SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ from collections.abc import Mapping
8
+ from datetime import timedelta
9
+ from time import perf_counter
10
+ from typing import TYPE_CHECKING, Any, Literal, Self
11
+
12
+ from bondi.couchbase.query import CouchbaseQuery
13
+ from bondi.execution import ExecutionResult
14
+ from bondi.workload import Workload
15
+
16
+ if TYPE_CHECKING:
17
+ from acouchbase.cluster import Cluster
18
+
19
+
20
+ # raw could bypass these controls through native request fields. Keep this escape
21
+ # hatch closed; query_kwargs supports the SDK's named QueryOptions instead.
22
+ _RESERVED = {
23
+ "statement",
24
+ "named_parameters",
25
+ "positional_parameters",
26
+ "query_context",
27
+ "profile",
28
+ "metrics",
29
+ "timeout",
30
+ "raw",
31
+ "serializer",
32
+ }
33
+
34
+
35
+ def _options(value: Mapping[str, Any] | None, reserved: set[str]) -> dict[str, Any]:
36
+ if value is None:
37
+ return {}
38
+ if not isinstance(value, Mapping) or any(not isinstance(k, str) for k in value):
39
+ raise TypeError("Options must be a mapping with string keys")
40
+ conflicts = reserved.intersection(value)
41
+ if conflicts:
42
+ raise ValueError(f"Options override reserved fields: {', '.join(sorted(conflicts))}")
43
+ return dict(value)
44
+
45
+
46
+ def _metadata(meta) -> dict[str, Any]:
47
+ """Copy public SDK metadata into JSON-compatible data, without interpreting plans."""
48
+ metrics = meta.metrics()
49
+ return {
50
+ "request_id": meta.request_id(),
51
+ "client_context_id": meta.client_context_id(),
52
+ "status": meta.status().value,
53
+ "signature": meta.signature(),
54
+ "warnings": [{"code": w.code(), "message": w.message()} for w in meta.warnings()],
55
+ "profile": meta.profile(),
56
+ "metrics": None
57
+ if metrics is None
58
+ else {
59
+ "elapsed_ms": metrics.elapsed_time().total_seconds() * 1000,
60
+ "execution_ms": metrics.execution_time().total_seconds() * 1000,
61
+ **{
62
+ name: getattr(metrics, name)().value
63
+ for name in (
64
+ "result_count",
65
+ "result_size",
66
+ "sort_count",
67
+ "mutation_count",
68
+ "error_count",
69
+ "warning_count",
70
+ )
71
+ },
72
+ },
73
+ }
74
+
75
+
76
+ class AsyncCouchbaseExecutor:
77
+ """Run, explain and profile SQL++ queries with bounded async workload workers.
78
+
79
+ Args:
80
+ cluster: Borrowed acouchbase Cluster; the caller closes it.
81
+ concurrency: Positive maximum in-flight entries per workload call.
82
+ continue_on_error: Continue scheduling after database/timeout failures.
83
+ timeout_seconds: Positive finite per-operation deadline, including row
84
+ consumption. None retains the SDK's configured query timeout.
85
+
86
+ Install the couchbase extra to instantiate this class. Inputs are retained by
87
+ reference and must not be modified during calls. Use one event loop per cluster.
88
+ Single operations raise; workloads capture SDK operation errors. Programming
89
+ errors and cancellation propagate. No additional retries or write guard are added.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ cluster: Cluster,
95
+ *,
96
+ concurrency: int = 1,
97
+ continue_on_error: bool = False,
98
+ timeout_seconds: float | None = None,
99
+ ) -> None:
100
+ try:
101
+ import acouchbase.cluster # noqa: F401
102
+ except ModuleNotFoundError as error:
103
+ if error.name not in ("acouchbase", "couchbase", "acouchbase.cluster"):
104
+ raise
105
+ raise ImportError(
106
+ "Install Couchbase support with: uv sync --extra couchbase"
107
+ ) from error
108
+ if type(concurrency) is not int or concurrency < 1:
109
+ raise ValueError("concurrency must be a positive integer")
110
+ if type(continue_on_error) is not bool:
111
+ raise TypeError("continue_on_error must be a boolean")
112
+ if timeout_seconds is not None and (
113
+ type(timeout_seconds) not in (int, float)
114
+ or not math.isfinite(timeout_seconds)
115
+ or timeout_seconds <= 0
116
+ ):
117
+ raise ValueError("timeout_seconds must be positive and finite, or None")
118
+ self.cluster = cluster
119
+ self.concurrency = concurrency
120
+ self.continue_on_error = continue_on_error
121
+ self.timeout_seconds = timeout_seconds
122
+ self._owns_cluster = False
123
+ self._closed = False
124
+
125
+ @classmethod
126
+ async def from_uri(
127
+ cls,
128
+ uri: str,
129
+ *,
130
+ username: str,
131
+ password: str,
132
+ concurrency: int = 1,
133
+ continue_on_error: bool = False,
134
+ timeout_seconds: float | None = None,
135
+ cluster_kwargs: Mapping[str, Any] | None = None,
136
+ ) -> Self:
137
+ """Connect an owned cluster and wait up to 30 seconds for the Query service.
138
+
139
+ Await this factory before using async with. cluster_kwargs supplies native
140
+ ClusterOptions; authentication comes from username/password. On readiness
141
+ failure or cancellation the newly connected cluster is closed. Connection
142
+ setup is outside per-query timing and uses SDK connection timeout settings.
143
+ """
144
+ executor = cls(
145
+ None,
146
+ concurrency=concurrency,
147
+ continue_on_error=continue_on_error,
148
+ timeout_seconds=timeout_seconds,
149
+ )
150
+ from acouchbase.cluster import Cluster
151
+ from couchbase.auth import PasswordAuthenticator
152
+ from couchbase.diagnostics import ServiceType
153
+ from couchbase.options import ClusterOptions, WaitUntilReadyOptions
154
+
155
+ options = _options(cluster_kwargs, {"authenticator"})
156
+ executor.cluster = await Cluster.connect(
157
+ uri, ClusterOptions(PasswordAuthenticator(username, password), **options)
158
+ )
159
+ executor._owns_cluster = True
160
+ try:
161
+ await executor.cluster.wait_until_ready(
162
+ timedelta(seconds=30), WaitUntilReadyOptions(service_types=[ServiceType.Query])
163
+ )
164
+ except BaseException:
165
+ await executor.close()
166
+ raise
167
+ return executor
168
+
169
+ async def close(self) -> None:
170
+ """Close an owned cluster once; leave borrowed clusters open."""
171
+ if self._owns_cluster and not self._closed:
172
+ await self.cluster.close()
173
+ self._closed = True
174
+
175
+ async def __aenter__(self) -> Self:
176
+ self._check_open()
177
+ return self
178
+
179
+ async def __aexit__(self, exc_type, exc, traceback) -> None:
180
+ await self.close()
181
+
182
+ def _check_open(self) -> None:
183
+ if self._closed:
184
+ raise RuntimeError("Executor is closed")
185
+
186
+ def _validate_query(self, query: CouchbaseQuery) -> None:
187
+ self._check_open()
188
+ if not isinstance(query, CouchbaseQuery):
189
+ raise TypeError("Expected CouchbaseQuery")
190
+ query.validate()
191
+
192
+ @staticmethod
193
+ def _collect(value: bool) -> None:
194
+ if type(value) is not bool:
195
+ raise TypeError("collect_results must be a boolean")
196
+
197
+ async def run(
198
+ self,
199
+ query: CouchbaseQuery,
200
+ *,
201
+ collect_results: bool = False,
202
+ query_kwargs: Mapping[str, Any] | None = None,
203
+ ) -> ExecutionResult:
204
+ """Consume all rows, optionally retain them, and return metrics in raw.
205
+
206
+ query_kwargs accepts native QueryOptions such as scan_consistency or
207
+ read_only. Bindings, context, timeout, metrics, profile, raw and serializer
208
+ are reserved. SDK consistency defaults apply unless explicitly overridden.
209
+ """
210
+ return await self._execute(query, "run", collect_results, query_kwargs)
211
+
212
+ async def explain(
213
+ self, query: CouchbaseQuery, *, query_kwargs: Mapping[str, Any] | None = None
214
+ ) -> ExecutionResult:
215
+ """Prefix the supplied statement with EXPLAIN and return its native plan in raw.
216
+
217
+ Supply the original SQL++ statement, without an EXPLAIN prefix. No execution
218
+ statistics are collected for that statement. row_count/documents remain None;
219
+ EXPLAIN request metrics are not measurements of the underlying query.
220
+ """
221
+ return await self._execute(query, "explain", False, query_kwargs)
222
+
223
+ async def profile(
224
+ self,
225
+ query: CouchbaseQuery,
226
+ *,
227
+ collect_results: bool = False,
228
+ query_kwargs: Mapping[str, Any] | None = None,
229
+ ) -> ExecutionResult:
230
+ """Execute with TIMINGS profiling, consuming all rows and returning metadata.
231
+
232
+ Unlike MongoDB profiling, Couchbase can also return the actual query rows.
233
+ When executionTimings is absent, retain basic metrics and add a warning.
234
+ No extra EXPLAIN request or automatic summary parsing is performed.
235
+ """
236
+ return await self._execute(query, "profile", collect_results, query_kwargs)
237
+
238
+ async def _execute(
239
+ self, query, mode: Literal["run", "explain", "profile"], collect_results, query_kwargs
240
+ ) -> ExecutionResult:
241
+ from couchbase.n1ql import QueryProfile
242
+ from couchbase.options import QueryOptions
243
+
244
+ self._validate_query(query)
245
+ self._collect(collect_results)
246
+ options = _options(query_kwargs, _RESERVED)
247
+ options.update(
248
+ named_parameters=query.named_parameters,
249
+ positional_parameters=query.positional_parameters,
250
+ query_context=query.query_context,
251
+ metrics=True,
252
+ profile=QueryProfile.TIMINGS if mode == "profile" else QueryProfile.OFF,
253
+ )
254
+ if self.timeout_seconds is not None:
255
+ options["timeout"] = timedelta(seconds=self.timeout_seconds)
256
+ statement = "EXPLAIN " + query.statement if mode == "explain" else query.statement
257
+ rows = [] if collect_results or mode == "explain" else None
258
+ count = 0
259
+ start = perf_counter()
260
+ # SDK cancellation finalizes its async streaming iterator. A local deadline
261
+ # also bounds time spent consuming rows; the SDK timeout bounds server work.
262
+ async with asyncio.timeout(self.timeout_seconds):
263
+ response = self.cluster.query(statement, QueryOptions(**options))
264
+ async for row in response.rows():
265
+ count += 1
266
+ if rows is not None:
267
+ rows.append(row)
268
+ raw = _metadata(response.metadata())
269
+ warnings = tuple(f"{w['code']}: {w['message']}" for w in raw["warnings"])
270
+ if mode == "profile" and not (raw["profile"] or {}).get("executionTimings"):
271
+ warnings += (
272
+ "Detailed profiling unavailable: server did not return executionTimings. "
273
+ "Basic metrics are retained; detailed profiling requires Enterprise support.",
274
+ )
275
+ if mode == "explain":
276
+ if len(rows) != 1 or not isinstance(rows[0], dict):
277
+ raise ValueError("Expected one plan document from Couchbase EXPLAIN")
278
+ raw = rows[0]
279
+ return ExecutionResult(
280
+ (perf_counter() - start) * 1000,
281
+ row_count=None if mode == "explain" else count,
282
+ documents=rows if collect_results else None,
283
+ raw=raw,
284
+ warnings=warnings,
285
+ )
286
+
287
+ async def run_workload(
288
+ self,
289
+ workload: Workload,
290
+ *,
291
+ collect_results: bool = False,
292
+ query_kwargs: Mapping[str, Any] | None = None,
293
+ ) -> list[ExecutionResult | None]:
294
+ """Run entries with input-aligned results and bounded concurrency.
295
+
296
+ None marks unstarted entries. With continue_on_error=False, stop scheduling
297
+ after observing a failure and drain in-flight operations. True attempts all
298
+ entries. Partial rows are discarded on failure. Validate all inputs first.
299
+ """
300
+ self._collect(collect_results)
301
+ options = _options(query_kwargs, _RESERVED)
302
+ return await self._workload(
303
+ workload,
304
+ lambda query: self.run(query, collect_results=collect_results, query_kwargs=options),
305
+ )
306
+
307
+ async def explain_workload(
308
+ self, workload: Workload, *, query_kwargs: Mapping[str, Any] | None = None
309
+ ) -> list[ExecutionResult | None]:
310
+ """Explain entries with run_workload's scheduling and failure policy."""
311
+ options = _options(query_kwargs, _RESERVED)
312
+ return await self._workload(
313
+ workload, lambda query: self.explain(query, query_kwargs=options)
314
+ )
315
+
316
+ async def profile_workload(
317
+ self,
318
+ workload: Workload,
319
+ *,
320
+ collect_results: bool = False,
321
+ query_kwargs: Mapping[str, Any] | None = None,
322
+ ) -> list[ExecutionResult | None]:
323
+ """Profile entries with run_workload's scheduling and failure policy."""
324
+ self._collect(collect_results)
325
+ options = _options(query_kwargs, _RESERVED)
326
+ return await self._workload(
327
+ workload,
328
+ lambda query: self.profile(
329
+ query, collect_results=collect_results, query_kwargs=options
330
+ ),
331
+ )
332
+
333
+ async def _workload(self, workload, operation) -> list[ExecutionResult | None]:
334
+ from couchbase.exceptions import (
335
+ AlreadyQueriedException,
336
+ CouchbaseException,
337
+ InvalidArgumentException,
338
+ InvalidValueException,
339
+ )
340
+
341
+ self._check_open()
342
+ if not isinstance(workload, Workload):
343
+ raise TypeError("workload must be a Workload")
344
+ workload.validate()
345
+ for entry in workload:
346
+ self._validate_query(entry.query)
347
+ results: list[ExecutionResult | None] = [None] * len(workload)
348
+ next_index = 0
349
+ stopped = False
350
+
351
+ async def worker():
352
+ nonlocal next_index, stopped
353
+ while not stopped and next_index < len(workload):
354
+ index = next_index
355
+ next_index += 1
356
+ start = perf_counter()
357
+ try:
358
+ results[index] = await operation(workload[index].query)
359
+ except (InvalidArgumentException, InvalidValueException, AlreadyQueriedException):
360
+ raise # Invalid SDK options are API misuse, not database outcomes.
361
+ except (CouchbaseException, TimeoutError) as error:
362
+ results[index] = ExecutionResult((perf_counter() - start) * 1000, error=error)
363
+ if not self.continue_on_error:
364
+ stopped = True
365
+
366
+ workers = [
367
+ asyncio.create_task(worker()) for _ in range(min(self.concurrency, len(workload)))
368
+ ]
369
+ try:
370
+ await asyncio.gather(*workers)
371
+ except BaseException:
372
+ for task in workers:
373
+ task.cancel()
374
+ await asyncio.gather(*workers, return_exceptions=True)
375
+ raise
376
+ return results