sqlite-ursula 0.11.3.dev0__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,37 @@
1
+ """``python -m sqlite_ursula``: whether this Python can host the extension, and if not, what to use
2
+ instead. It loads the extension as ``open()`` would, and prints what it found. The exit code is 0
3
+ when this Python can host it and 1 when it cannot.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import platform
9
+ import sqlite3
10
+ import sys
11
+ import sysconfig
12
+ from typing import TextIO
13
+
14
+ from . import __version__
15
+ from . import _host
16
+ from .errors import UrsulaSetupError
17
+
18
+
19
+ def main(out: TextIO = sys.stdout) -> int:
20
+ free_threaded = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
21
+ build = f"{platform.python_implementation()}{', free-threaded' if free_threaded else ''}"
22
+ print(f"sqlite-ursula {__version__}", file=out)
23
+ print(f"python: {platform.python_version()} ({build}) at {sys.executable}", file=out)
24
+ print(f"sqlite: {sqlite3.sqlite_version}", file=out)
25
+ print(f"extension loading: {'yes' if _host.loads_extensions() else 'no'}", file=out)
26
+ try:
27
+ lib = _host.library()
28
+ print(f"extension: {lib.path} ({lib.source})", file=out)
29
+ _host.connection(lib).close()
30
+ except UrsulaSetupError as error:
31
+ print(f"result: {error.kind}: this Python cannot host sqlite-ursula", file=out)
32
+ print(f"why: {error}", file=out)
33
+ if error.fix is not None:
34
+ print(f"fix: {error.fix}", file=out)
35
+ return 1
36
+ print("result: ok: this Python can host sqlite-ursula", file=out)
37
+ return 0
@@ -0,0 +1,477 @@
1
+ """The events thread (design: Pump): this process's view of the extension's events.
2
+
3
+ Calls into the extension never wait on the network: ``ursula_open`` and ``ursula_synced`` are
4
+ made with timeout 0 and either answer at once or hand back a request or a ticket, which an event
5
+ later resolves. One daemon thread, ``sqlite-ursula-events``, started at the first open or
6
+ ``synced()``, waits for those events on a connection of its own and applies them under the
7
+ control lock:
8
+
9
+ - ``synced {path, through}`` resolves every ticket of the path up to ``through``. The extension
10
+ keeps only the latest watermark of a binding in its queue, so any number of waiters costs one
11
+ event.
12
+ - ``sync_failed {ticket}`` fails that ticket alone.
13
+ - ``reset {path}`` fails the path's tickets, then runs the ``on_reset`` callbacks of its
14
+ connections on a thread of their own, so a callback may call into the package.
15
+ - ``gap`` (events fell out of the queue) asks for every pending ticket again under a new ticket,
16
+ wakes every open, and reads resets from status.
17
+ - ``opened {request}`` wakes the opens waiting for it, which call ``ursula_open`` again to
18
+ collect the result.
19
+
20
+ A waiter is a ``threading.Event`` for ``synced()``, ``gate`` and ``open()``, and a
21
+ :class:`LoopEvent` for ``asynced()``, ``agate`` and ``aopen()``, which the thread wakes through its
22
+ event loop's ``call_soon_threadsafe``. Nothing runs on the loop's default executor. One wait
23
+ covers the tickets of every database it was given, with one waiter that each ticket's outcome
24
+ wakes: it ends once every ticket resolved, or at the first that failed, and drops the rest.
25
+
26
+ The thread takes its cursor before any request or ticket is handed out, and requests and tickets
27
+ are registered under the lock in the same step that asks for them, so no event about one applies
28
+ before it is registered. A thread that fails is replaced while tickets wait or connections are
29
+ open, and the new one starts with a gap, since events may have passed between the two. After three
30
+ failures in a row, waits fail ``internal``.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import contextlib
37
+ import logging
38
+ import os
39
+ import threading
40
+ import time
41
+ import weakref
42
+ from collections.abc import Sequence
43
+ from typing import TYPE_CHECKING
44
+ from typing import Any
45
+
46
+ from . import _args
47
+ from . import _control
48
+ from .errors import UrsulaResetError
49
+ from .errors import UrsulaSetupError
50
+ from .errors import again
51
+ from .errors import error_of
52
+
53
+ if TYPE_CHECKING:
54
+ from ._connection import UrsulaConnection
55
+
56
+ log = logging.getLogger("sqlite_ursula")
57
+
58
+ #: After this many failures of the thread in a row, it stays down and its waits fail.
59
+ MAX_FAILURES = 3
60
+ #: How long one ``ursula_events`` call waits for an event.
61
+ POLL_MS = 1000
62
+
63
+
64
+ class LoopEvent:
65
+ """What a coroutine waits on where a thread waits on a ``threading.Event``: :meth:`set` from any
66
+ thread wakes it on its event loop. Made on the loop, by the coroutine that waits."""
67
+
68
+ __slots__ = ("_future", "_loop")
69
+
70
+ def __init__(self) -> None:
71
+ self._loop = asyncio.get_running_loop()
72
+ self._future: asyncio.Future[None] = self._loop.create_future()
73
+
74
+ def set(self) -> None:
75
+ # A loop that closed raises RuntimeError: nothing waits on it any more.
76
+ with contextlib.suppress(RuntimeError):
77
+ self._loop.call_soon_threadsafe(self._settle)
78
+
79
+ def clear(self) -> None:
80
+ """On the loop: the next :meth:`set` wakes the waiter again. A wake already on its way may
81
+ still come, which only makes the waiter look again."""
82
+ if self._future.done():
83
+ self._future = self._loop.create_future()
84
+
85
+ def _settle(self) -> None:
86
+ # A waiter that gave up (its timeout, a cancellation) cancelled the future.
87
+ if not self._future.done():
88
+ self._future.set_result(None)
89
+
90
+ async def wait(self, timeout: float | None) -> bool:
91
+ """Whether it was set within ``timeout`` seconds (``None``: no limit). Cancelling the
92
+ coroutine ends the wait."""
93
+ try:
94
+ async with asyncio.timeout(timeout):
95
+ await self._future
96
+ except TimeoutError:
97
+ return False
98
+ return True
99
+
100
+
101
+ Waiter = threading.Event | LoopEvent
102
+
103
+
104
+ class Wait:
105
+ """One ``synced()``, ``asynced()`` or gate call waiting for the tickets of its databases. Its
106
+ state changes under the lock, and each change wakes ``waiter``."""
107
+
108
+ __slots__ = ("failed", "left", "waiter")
109
+
110
+ def __init__(self, waiter: Waiter) -> None:
111
+ self.waiter = waiter
112
+ #: The tickets not resolved yet.
113
+ self.left = 0
114
+ #: The path and the error of the first ticket that failed.
115
+ self.failed: tuple[str, BaseException] | None = None
116
+
117
+ def done(self) -> bool:
118
+ return self.failed is not None or self.left == 0
119
+
120
+
121
+ class Ticket:
122
+ """The ticket of one database of a :class:`Wait`."""
123
+
124
+ __slots__ = ("path", "wait")
125
+
126
+ def __init__(self, path: str, wait: Wait) -> None:
127
+ self.path = path
128
+ self.wait = wait
129
+
130
+ def resolve(self) -> None:
131
+ self.wait.left -= 1
132
+ self.wait.waiter.set()
133
+
134
+ def reject(self, error: BaseException) -> None:
135
+ if self.wait.failed is None:
136
+ self.wait.failed = (self.path, error)
137
+ self.wait.waiter.set()
138
+
139
+
140
+ #: The pending tickets of the process, by ticket: kept across threads.
141
+ _tickets: dict[int, Ticket] = {}
142
+ #: The open bound connections by bound path, whose callbacks a reset runs. Held weakly: a
143
+ #: connection the application dropped is collected.
144
+ _targets: dict[str, weakref.WeakSet[UrsulaConnection]] = {}
145
+ _current: Events | None = None
146
+ #: Threads that failed in a row, before any delivered events.
147
+ _failures = 0
148
+
149
+
150
+ def reset_error(named: dict[str, Any]) -> UrsulaResetError:
151
+ """The error of the reset the extension named (a ``reset`` event, ``status()["reset"]``)."""
152
+ return UrsulaResetError(str(named["message"]), str(named["kind"]), named["action"])
153
+
154
+
155
+ def track(target: UrsulaConnection) -> None:
156
+ """Runs ``target``'s callbacks on the resets of its path."""
157
+ with _control.lock():
158
+ _targets.setdefault(target.path, weakref.WeakSet()).add(target)
159
+
160
+
161
+ def untrack(target: UrsulaConnection) -> None:
162
+ with _control.lock():
163
+ found = _targets.get(target.path)
164
+ if found is not None:
165
+ found.discard(target)
166
+ if not found:
167
+ del _targets[target.path]
168
+
169
+
170
+ def _live(path: str | None = None) -> list[UrsulaConnection]:
171
+ """The tracked connections of ``path``, or of every path."""
172
+ if path is None:
173
+ return [t for found in _targets.values() for t in found]
174
+ return list(_targets.get(path, ()))
175
+
176
+
177
+ def _fire(hit: list[tuple[UrsulaConnection, UrsulaResetError]]) -> None:
178
+ """Runs the callbacks of the connections ``hit`` on a thread of their own."""
179
+ if not hit:
180
+ return
181
+
182
+ def run() -> None:
183
+ for target, error in hit:
184
+ target._reset_by(error)
185
+
186
+ threading.Thread(target=run, name="sqlite-ursula-reset", daemon=True).start()
187
+
188
+
189
+ def _reissue() -> None:
190
+ """Asks for every pending ticket again (``ursula_synced``, timeout 0), under a new ticket.
191
+ Called with the lock held."""
192
+ pending = list(_tickets.values())
193
+ _tickets.clear()
194
+ for t in pending:
195
+ try:
196
+ again = _control.call("SELECT ursula_synced(?, 0)", t.path)
197
+ except Exception as error:
198
+ t.reject(error)
199
+ continue
200
+ if again["synced"]:
201
+ t.resolve()
202
+ else:
203
+ _tickets[int(again["ticket"])] = t
204
+
205
+
206
+ class Events:
207
+ """One events thread, from the cursor at its start."""
208
+
209
+ def __init__(self) -> None:
210
+ # From the cursor at this moment: every request and ticket handed out after it is
211
+ # answered by an event this thread sees, and no event of an earlier binding is taken for a
212
+ # later one's.
213
+ self._cursor = int(_control.call("SELECT ursula_events(0, 0)")["cursor"])
214
+ self._opens: dict[int, set[Waiter]] = {}
215
+ self._failed = False
216
+ self.conn: Any = None
217
+ self._thread = threading.Thread(target=self._run, name="sqlite-ursula-events", daemon=True)
218
+ self._thread.start()
219
+
220
+ def wait_open(self, request: int, waiter: Waiter) -> None:
221
+ """Wakes ``waiter`` for open ``request`` at its ``opened`` event, a gap, or the thread's
222
+ end. Called with the lock held, in the step that got the request."""
223
+ self._opens.setdefault(request, set()).add(waiter)
224
+
225
+ def forget_open(self, request: int, waiter: Waiter) -> None:
226
+ with _control.lock():
227
+ found = self._opens.get(request)
228
+ if found is not None:
229
+ found.discard(waiter)
230
+ if not found:
231
+ del self._opens[request]
232
+
233
+ def _wake(self, request: int) -> None:
234
+ for waiter in self._opens.pop(request, ()):
235
+ waiter.set()
236
+
237
+ def _run(self) -> None:
238
+ global _failures
239
+ try:
240
+ self.conn = _control.connection()
241
+ cursor = self._cursor
242
+ while True:
243
+ row = self.conn.execute("SELECT ursula_events(?, ?)", (cursor, POLL_MS)).fetchone()
244
+ batch = _control.parsed(row[0])
245
+ cursor = int(batch["cursor"])
246
+ if batch["events"]:
247
+ with _control.lock():
248
+ if self._failed:
249
+ return
250
+ _failures = 0
251
+ self.handle(batch["events"])
252
+ except Exception:
253
+ self._fail()
254
+
255
+ def handle(self, events: list[dict[str, Any]]) -> None:
256
+ """A batch of events, in order. Called with the lock held."""
257
+ for event in events:
258
+ kind = event.get("event")
259
+ if kind == "opened":
260
+ self._wake(int(event["request"]))
261
+ elif kind == "synced":
262
+ through = int(event["through"])
263
+ for ticket, t in list(_tickets.items()):
264
+ if t.path == event["path"] and ticket <= through:
265
+ del _tickets[ticket]
266
+ t.resolve()
267
+ elif kind == "sync_failed":
268
+ t = _tickets.pop(int(event["ticket"]), None)
269
+ if t is not None:
270
+ t.reject(error_of(event, str(event["message"]), None))
271
+ elif kind == "reset":
272
+ self._reset(event.get("path"), event)
273
+ elif kind == "gap":
274
+ self._gap()
275
+
276
+ def _reset(self, path: str | None, named: dict[str, Any]) -> None:
277
+ """A reset of ``path`` (``None``: a binding the process no longer has): its pending
278
+ ``synced()`` calls fail, then its callbacks run."""
279
+ hit: list[tuple[UrsulaConnection, UrsulaResetError]] = []
280
+ if path is None:
281
+ for target in _live():
282
+ error = target._current_reset()
283
+ if error is not None:
284
+ hit.append((target, error))
285
+ paths = {target.path for target, _ in hit}
286
+ else:
287
+ hit = [(target, reset_error(named)) for target in _live(path)]
288
+ paths = {path}
289
+ for ticket, t in list(_tickets.items()):
290
+ if t.path in paths:
291
+ del _tickets[ticket]
292
+ t.reject(reset_error(named))
293
+ _fire(hit)
294
+
295
+ def _gap(self) -> None:
296
+ """Events fell out of the queue before the thread read them. Every pending ticket is asked
297
+ for again under a new ticket, which no watermark of the batch covers, so a ticket whose
298
+ failure the gap dropped never passes for resolved. Opens ask again, and resets are read
299
+ from status."""
300
+ for request in list(self._opens):
301
+ self._wake(request)
302
+ _reissue()
303
+ hit = []
304
+ for target in _live():
305
+ error = target._current_reset()
306
+ if error is not None:
307
+ hit.append((target, error))
308
+ _fire(hit)
309
+
310
+ def _fail(self) -> None:
311
+ """The thread is gone: pending opens ask again, and while tickets wait or connections are
312
+ open a new thread starts with a gap, since events may have passed between the two. After
313
+ :data:`MAX_FAILURES` failures in a row the tickets fail instead, and so does every later
314
+ call."""
315
+ global _current, _failures
316
+ with _control.lock():
317
+ if self._failed:
318
+ return
319
+ self._failed = True
320
+ _failures += 1
321
+ if _current is self:
322
+ _current = None
323
+ for request in list(self._opens):
324
+ self._wake(request)
325
+ if not _tickets and not _live():
326
+ return
327
+ try:
328
+ replacement = events()
329
+ except Exception as error:
330
+ pending = list(_tickets.values())
331
+ _tickets.clear()
332
+ for t in pending:
333
+ t.reject(error)
334
+ return
335
+ replacement._gap()
336
+
337
+
338
+ def events() -> Events:
339
+ """The process's events thread, started at the first call that needs it."""
340
+ global _current
341
+ with _control.lock():
342
+ if _current is None:
343
+ if _failures >= MAX_FAILURES:
344
+ raise UrsulaSetupError(
345
+ f"internal: the sqlite-ursula events thread failed {_failures} times in a "
346
+ "row: check the extension, then restart the process",
347
+ "internal",
348
+ "reopen",
349
+ )
350
+ _current = Events()
351
+ return _current
352
+
353
+
354
+ def _ask(dbs: Sequence[UrsulaConnection], waiter: type[Waiter]) -> Wait | None:
355
+ """Asks for a ticket of every database in ``dbs`` (``ursula_synced``, timeout 0), in one step
356
+ under the lock: ``None`` when every one is synced already, else the :class:`Wait` of the
357
+ tickets, registered in the same step, with a new ``waiter``."""
358
+ for db in dbs:
359
+ db._usable()
360
+ with _control.lock():
361
+ # Started before the calls, so its events cursor precedes the tickets.
362
+ events()
363
+ wait = Wait(waiter())
364
+ try:
365
+ for db in dbs:
366
+ try:
367
+ answer = _control.call("SELECT ursula_synced(?, 0)", db.path)
368
+ except UrsulaResetError as error:
369
+ db._remember(error)
370
+ raise
371
+ if not answer["synced"]:
372
+ _tickets[int(answer["ticket"])] = Ticket(db.path, wait)
373
+ wait.left += 1
374
+ except BaseException:
375
+ _drop(wait)
376
+ raise
377
+ return wait if wait.left else None
378
+
379
+
380
+ def _drop(wait: Wait) -> None:
381
+ """The call of ``wait`` ended: no event resolves its tickets any more."""
382
+ with _control.lock():
383
+ for ticket, pending in list(_tickets.items()):
384
+ if pending.wait is wait:
385
+ del _tickets[ticket]
386
+
387
+
388
+ def _settled(wait: Wait) -> bool:
389
+ """Whether ``wait`` is over, else its waiter cleared for the next change: under the lock, so no
390
+ change falls between the two."""
391
+ with _control.lock():
392
+ if wait.done():
393
+ return True
394
+ wait.waiter.clear()
395
+ return False
396
+
397
+
398
+ def _left(deadline: float | None) -> float | None:
399
+ return None if deadline is None else max(0.0, deadline - time.monotonic())
400
+
401
+
402
+ def _outcome(wait: Wait, dbs: Sequence[UrsulaConnection]) -> None:
403
+ """Raises the first failure of ``wait``, if any, and the connections of its path remember a
404
+ reset."""
405
+ if wait.failed is None:
406
+ return
407
+ path, error = wait.failed
408
+ if isinstance(error, UrsulaResetError):
409
+ for db in dbs:
410
+ if db.path == path:
411
+ db._remember(error)
412
+ raise again(error)
413
+
414
+
415
+ def _timed_out(call: str, timeout: float | None) -> TimeoutError:
416
+ return TimeoutError(
417
+ f"{call} gave up after {timeout} s: the commits keep replicating, so call it again to wait "
418
+ "for them"
419
+ )
420
+
421
+
422
+ def synced(dbs: Sequence[UrsulaConnection], timeout: float | None) -> None:
423
+ """Waits until the stream of each database in ``dbs`` holds every commit the process logged on
424
+ it before the call, for at most ``timeout`` seconds (``None``: no limit). Raises
425
+ ``TimeoutError`` on the timeout, and the first failure, such as a reset, at once."""
426
+ limit = _args.limit(timeout)
427
+ wait = _ask(dbs, threading.Event)
428
+ if wait is None:
429
+ return
430
+ assert isinstance(wait.waiter, threading.Event)
431
+ deadline = None if limit is None else time.monotonic() + limit
432
+ try:
433
+ while not _settled(wait):
434
+ left = _left(deadline)
435
+ if left == 0.0:
436
+ raise _timed_out("synced()", timeout)
437
+ wait.waiter.wait(left)
438
+ finally:
439
+ _drop(wait)
440
+ _outcome(wait, dbs)
441
+
442
+
443
+ async def asynced(dbs: Sequence[UrsulaConnection], timeout: float | None) -> None:
444
+ """:func:`synced` for a coroutine: it waits on the event loop, and cancelling it ends the wait
445
+ while the commits keep replicating."""
446
+ limit = _args.limit(timeout)
447
+ wait = _ask(dbs, LoopEvent)
448
+ if wait is None:
449
+ return
450
+ assert isinstance(wait.waiter, LoopEvent)
451
+ deadline = None if limit is None else time.monotonic() + limit
452
+ try:
453
+ while not _settled(wait):
454
+ left = _left(deadline)
455
+ if left == 0.0:
456
+ raise _timed_out("asynced()", timeout)
457
+ await wait.waiter.wait(left)
458
+ finally:
459
+ _drop(wait)
460
+ _outcome(wait, dbs)
461
+
462
+
463
+ def _after_fork_in_child() -> None:
464
+ global _current, _failures
465
+ if _current is not None:
466
+ _control._inherited.append(_current)
467
+ # The parent's connections stay referenced, so collecting one never closes a bound file in
468
+ # the child.
469
+ _control._inherited.extend(_live())
470
+ _current = None
471
+ _failures = 0
472
+ _tickets.clear()
473
+ _targets.clear()
474
+
475
+
476
+ if hasattr(os, "register_at_fork"):
477
+ os.register_at_fork(after_in_child=_after_fork_in_child)
sqlite_ursula/_gate.py ADDED
@@ -0,0 +1,136 @@
1
+ """``gate`` and ``agate``: the output gate as a context manager (design: Gates).
2
+
3
+ The block runs, and leaving it waits for a ``synced()`` call made after the block ended, on every
4
+ database given, before the result or the block's error goes on. ``synced()`` is the gate: this
5
+ module only decides when to wait and which errors not to hold, and adds no call of the extension
6
+ but ``classify`` for an error a framework's own connection raised.
7
+
8
+ - An :class:`UrsulaError` the block raises passes at once, as the block raised it, also when a
9
+ library wrapped it (as ``__cause__``, as SQLAlchemy's ``.orig``, or in an ``ExceptionGroup``):
10
+ it carries no application state. A refused write committed nothing, and a reset is what the wait
11
+ would report. So does a SQLite error, also wrapped, that the extension classifies
12
+ (``ursula_classify``) on one of the databases, which a framework's own ``sqlite3`` connection
13
+ raised unclassified. A bare SQLite error that one database's state names a refused write or a
14
+ reset raises as that :class:`UrsulaError`, which is a ``sqlite3.OperationalError`` too. With
15
+ several databases a statement's I/O error does not say whose it is, so it passes unchanged.
16
+ - Every other ``Exception`` waits as a result does, then passes. A ``BaseException`` that is not an
17
+ ``Exception`` (Ctrl-C, a cancellation, ``SystemExit``) passes at once.
18
+ - The wait covers every database at once, and the first failure, such as a reset, ends it and
19
+ replaces the outcome.
20
+ - ``timeout`` bounds the wait, not the block: past it, ``TimeoutError``, while the commits keep
21
+ replicating.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import sqlite3
28
+ from collections.abc import AsyncIterator
29
+ from collections.abc import Iterator
30
+
31
+ from . import _control
32
+ from . import _events
33
+ from ._args import invalid
34
+ from ._args import limit
35
+ from ._connection import UrsulaConnection
36
+ from ._connection import bound
37
+ from .errors import UrsulaError
38
+ from .errors import UrsulaResetError
39
+ from .errors import UrsulaWriteRefusedError
40
+
41
+
42
+ def _databases(dbs: tuple[object, ...]) -> tuple[UrsulaConnection, ...]:
43
+ if not dbs:
44
+ raise invalid("gate needs at least one database")
45
+ return tuple(bound(db, "gate") for db in dbs)
46
+
47
+
48
+ def _passing(error: Exception, dbs: tuple[UrsulaConnection, ...]) -> BaseException | None:
49
+ """What raises at once instead of waiting, or ``None`` when ``error`` waits: ``error`` itself
50
+ when its chain holds an :class:`UrsulaError` or a SQLite error the extension classifies, or
51
+ the refused write or reset that a bare SQLite error is on the one database given."""
52
+ classified = _control.classify_wrapped(error, [db.path for db in dbs])
53
+ if not isinstance(classified, UrsulaError):
54
+ return None
55
+ if (
56
+ len(dbs) == 1
57
+ and classified.__cause__ is error
58
+ and isinstance(error, sqlite3.Error)
59
+ and isinstance(classified, (UrsulaWriteRefusedError, UrsulaResetError))
60
+ ):
61
+ return classified
62
+ return error
63
+
64
+
65
+ def _gave_up(timeout: float | None, dbs: tuple[UrsulaConnection, ...]) -> TimeoutError:
66
+ names = ", ".join(db.name for db in dbs)
67
+ return TimeoutError(
68
+ f"the gate gave up after {timeout:g} s waiting for {names}: the commits keep replicating"
69
+ )
70
+
71
+
72
+ @contextlib.contextmanager
73
+ def gate(*dbs: UrsulaConnection, timeout: float | None = None) -> Iterator[None]:
74
+ """Runs the block, then lets its outcome go on only once a ``synced()`` call made after the
75
+ block ended returned on every database in ``dbs``, so nothing outside the process acts on a
76
+ result, or an error, that rests on an unacknowledged commit or on ownership this process is
77
+ about to lose. A block that only read waits too: it may have read another connection's pending
78
+ commit::
79
+
80
+ with sqlite_ursula.gate(db):
81
+ reply = handle(request)
82
+ send(reply)
83
+
84
+ An :class:`UrsulaError` the block raises, also wrapped, passes at once as it was raised, and so
85
+ does the error of a framework's own connection that the extension classifies. Every other error
86
+ waits, then passes. The wait covers every database at once, and the first failure on one, such
87
+ as a reset, raises at once. ``timeout`` (seconds, ``None``: no limit) bounds the wait and raises
88
+ ``TimeoutError``. Effects inside the block, such as an outbound call, are not gated: call
89
+ ``db.synced()`` before them.
90
+ """
91
+ targets = _databases(dbs)
92
+ bound = limit(timeout)
93
+ try:
94
+ yield
95
+ except Exception as error:
96
+ passing = _passing(error, targets)
97
+ if passing is error:
98
+ raise
99
+ if passing is not None:
100
+ raise passing from error
101
+ _wait(targets, bound)
102
+ raise
103
+ _wait(targets, bound)
104
+
105
+
106
+ def _wait(dbs: tuple[UrsulaConnection, ...], timeout: float | None) -> None:
107
+ try:
108
+ _events.synced(dbs, timeout)
109
+ except TimeoutError as error:
110
+ raise _gave_up(timeout, dbs) from error
111
+
112
+
113
+ @contextlib.asynccontextmanager
114
+ async def agate(*dbs: UrsulaConnection, timeout: float | None = None) -> AsyncIterator[None]:
115
+ """:func:`gate` for asyncio: ``async with agate(db):``. It waits on the event loop instead of
116
+ blocking it, and cancelling the task ends the wait."""
117
+ targets = _databases(dbs)
118
+ bound = limit(timeout)
119
+ try:
120
+ yield
121
+ except Exception as error:
122
+ passing = _passing(error, targets)
123
+ if passing is error:
124
+ raise
125
+ if passing is not None:
126
+ raise passing from error
127
+ await _await(targets, bound)
128
+ raise
129
+ await _await(targets, bound)
130
+
131
+
132
+ async def _await(dbs: tuple[UrsulaConnection, ...], timeout: float | None) -> None:
133
+ try:
134
+ await _events.asynced(dbs, timeout)
135
+ except TimeoutError as error:
136
+ raise _gave_up(timeout, dbs) from error