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.
- sqlite_ursula/__init__.py +77 -0
- sqlite_ursula/__main__.py +7 -0
- sqlite_ursula/_args.py +39 -0
- sqlite_ursula/_connection.py +368 -0
- sqlite_ursula/_control.py +199 -0
- sqlite_ursula/_doctor.py +37 -0
- sqlite_ursula/_events.py +477 -0
- sqlite_ursula/_gate.py +136 -0
- sqlite_ursula/_host.py +142 -0
- sqlite_ursula/_open.py +432 -0
- sqlite_ursula/errors.py +171 -0
- sqlite_ursula/langgraph.py +474 -0
- sqlite_ursula/py.typed +0 -0
- sqlite_ursula-0.11.3.dev0.dist-info/METADATA +183 -0
- sqlite_ursula-0.11.3.dev0.dist-info/RECORD +17 -0
- sqlite_ursula-0.11.3.dev0.dist-info/WHEEL +4 -0
- sqlite_ursula-0.11.3.dev0.dist-info/licenses/LICENSE +201 -0
sqlite_ursula/_doctor.py
ADDED
|
@@ -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
|
sqlite_ursula/_events.py
ADDED
|
@@ -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
|