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 +1 -0
- bondi/_sync.py +122 -0
- bondi/couchbase/__init__.py +8 -0
- bondi/couchbase/executor.py +376 -0
- bondi/couchbase/explain.py +223 -0
- bondi/couchbase/query.py +187 -0
- bondi/couchbase/sync_executor.py +109 -0
- bondi/estimation.py +42 -0
- bondi/execution.py +58 -0
- bondi/mongodb/__init__.py +28 -0
- bondi/mongodb/_serialization.py +98 -0
- bondi/mongodb/aggregate.py +135 -0
- bondi/mongodb/collection.py +47 -0
- bondi/mongodb/estimator.py +331 -0
- bondi/mongodb/executor.py +380 -0
- bondi/mongodb/explain.py +172 -0
- bondi/mongodb/generation.py +508 -0
- bondi/mongodb/generation_config.py +178 -0
- bondi/mongodb/pattern.py +199 -0
- bondi/mongodb/query.py +188 -0
- bondi/mongodb/sync_executor.py +126 -0
- bondi/query.py +35 -0
- bondi/schema.py +329 -0
- bondi/summary.py +45 -0
- bondi/workload.py +284 -0
- bondi-0.1.0.dist-info/METADATA +100 -0
- bondi-0.1.0.dist-info/RECORD +29 -0
- bondi-0.1.0.dist-info/WHEEL +4 -0
- bondi-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|