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,171 @@
1
+ """The errors the package raises (design: Errors).
2
+
3
+ Which failure is which error is the extension's to say (``ursula_classify``): its kind, its action
4
+ and its class. :func:`error_of` turns that answer into one of the four classes below. Only the
5
+ errors the package makes itself (an invalid argument, a host that cannot load the extension, a
6
+ forked process, a cancelled open) name their action at the place that raises them.
7
+
8
+ A refused write and a reset replace a ``sqlite3.OperationalError`` that a statement raised, so
9
+ their classes derive from it too: an ``except sqlite3.OperationalError`` clause, and the DB-API
10
+ wrapping of SQLAlchemy, keep working. The SQLite error is ``__cause__``, and its
11
+ ``sqlite_errorcode`` and ``sqlite_errorname`` are copied.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ import sqlite3
18
+ from collections import deque
19
+ from collections.abc import Callable
20
+ from collections.abc import Mapping
21
+ from typing import Any
22
+ from typing import Literal
23
+ from typing import TypeVar
24
+
25
+ __all__ = [
26
+ "Action",
27
+ "UrsulaError",
28
+ "UrsulaResetError",
29
+ "UrsulaSetupError",
30
+ "UrsulaUnavailableError",
31
+ "UrsulaWriteRefusedError",
32
+ ]
33
+
34
+ Action = Literal["retry", "reopen", "stop", "fix"]
35
+ """What to do about a failure. Branch on it.
36
+
37
+ - ``retry``: nothing was lost and the database stays usable. For ``backlog_full``, call
38
+ ``synced()`` and run the transaction again. For a failed ``open()``, call it again later.
39
+ - ``reopen``: close every connection of the database and ``open()`` it again.
40
+ - ``stop``: another owner took the stream over. Stop using the database, and never reopen it in a
41
+ loop: opening it takes the stream back.
42
+ - ``fix``: it cannot succeed as asked. Fix the URL, the host, the stream or the transaction.
43
+ """
44
+
45
+
46
+ class UrsulaError(Exception):
47
+ """A failure of the extension or of an open: the base class of every error the package raises.
48
+
49
+ ``kind`` names what failed and ``action`` what to do (:data:`Action`). ``fix`` says how to fix
50
+ a host the package cannot run on, and is ``None`` otherwise.
51
+ """
52
+
53
+ kind: str
54
+ action: Action
55
+ fix: str | None
56
+ sqlite_errorcode: int | None
57
+ sqlite_errorname: str | None
58
+
59
+ def __init__(
60
+ self,
61
+ message: str,
62
+ kind: str,
63
+ action: Action,
64
+ *,
65
+ fix: str | None = None,
66
+ cause: BaseException | None = None,
67
+ ) -> None:
68
+ super().__init__(message)
69
+ self.kind = kind
70
+ self.action = action
71
+ self.fix = fix
72
+ self.sqlite_errorcode = getattr(cause, "sqlite_errorcode", None)
73
+ self.sqlite_errorname = getattr(cause, "sqlite_errorname", None)
74
+ if cause is not None:
75
+ self.__cause__ = cause
76
+
77
+ def __reduce__(self) -> tuple[Any, ...]:
78
+ # Picklable across processes (multiprocessing), whatever the constructor's keywords.
79
+ return (type(self), (str(self), self.kind, self.action), self.__dict__)
80
+
81
+
82
+ class UrsulaWriteRefusedError(UrsulaError, sqlite3.OperationalError):
83
+ """A write or COMMIT refused with nothing committed (``SQLITE_IOERR_AUTH``, 7178).
84
+
85
+ SQLite rolled the whole transaction back, and the database stays usable. ``backlog_full``
86
+ (``retry``): more than ``max_pending_bytes`` of commits wait for the stream, so call
87
+ ``synced()`` and run the transaction again. ``unsupported`` (``fix``): a write the extension
88
+ does not capture, such as one in EXCLUSIVE locking mode. ``too_large`` (``fix``): the
89
+ transaction's commit holds more bytes of pages than the commit limit.
90
+ """
91
+
92
+
93
+ class UrsulaResetError(UrsulaError, sqlite3.OperationalError):
94
+ """The database was reset (design: Resets).
95
+
96
+ Every statement of it raises this, reads included, ``synced()`` raises it, and the
97
+ ``on_reset`` callbacks get it. ``fenced`` (``stop``): another owner took the stream over. The
98
+ other kinds say to ``reopen`` (a new ``open()`` of the URL) or to ``fix`` the stream first.
99
+ """
100
+
101
+
102
+ class UrsulaUnavailableError(UrsulaError):
103
+ """``open()`` found no trusted state in time.
104
+
105
+ ``kind`` is the last condition the open met (``unreachable``, ``unauthorized``, ...), or
106
+ ``cancelled`` when it met none yet.
107
+ """
108
+
109
+
110
+ class UrsulaSetupError(UrsulaError):
111
+ """It cannot succeed as asked: the message says what to change.
112
+
113
+ ``host_unsupported`` says this Python cannot load the extension, and ``fix`` names a Python
114
+ that can. ``forked`` says the process forked after its parent opened a database.
115
+ """
116
+
117
+
118
+ def error_of(named: Mapping[str, Any], message: str, cause: BaseException | None) -> UrsulaError:
119
+ """The error of a failure the extension named (``ursula_classify``'s answer, an event, a status
120
+ field), with ``message`` and ``cause``: its class picks the error, and its action is the
121
+ extension's."""
122
+ kind = str(named["kind"])
123
+ action: Action = named["action"]
124
+ fix = named.get("fix")
125
+ cls: type[UrsulaError] = {
126
+ "write_refused": UrsulaWriteRefusedError,
127
+ "reset": UrsulaResetError,
128
+ "unavailable": UrsulaUnavailableError,
129
+ }.get(str(named.get("class")), UrsulaSetupError)
130
+ return cls(message, kind, action, fix=fix if isinstance(fix, str) else None, cause=cause)
131
+
132
+
133
+ E = TypeVar("E", bound=BaseException)
134
+ T = TypeVar("T")
135
+
136
+ #: How many errors of a chain :func:`find_wrapped` visits at most.
137
+ MAX_WRAPPED = 16
138
+
139
+
140
+ def find_wrapped(error: BaseException, match: Callable[[BaseException], T | None]) -> T | None:
141
+ """The first value ``match`` returns for an error in the chain of ``error``, which a library
142
+ may have wrapped: ``error`` itself, the errors an ``ExceptionGroup`` holds, and the errors they
143
+ wrap (``__cause__``, and SQLAlchemy's ``.orig``), in that order. Each error is visited once, and
144
+ at most :data:`MAX_WRAPPED` are. ``None`` when ``match`` returns it for every one."""
145
+ pending: deque[BaseException] = deque([error])
146
+ seen: set[int] = set()
147
+ while pending and len(seen) < MAX_WRAPPED:
148
+ next_ = pending.popleft()
149
+ if id(next_) in seen:
150
+ continue
151
+ seen.add(id(next_))
152
+ found = match(next_)
153
+ if found is not None:
154
+ return found
155
+ if isinstance(next_, BaseExceptionGroup):
156
+ pending.extend(next_.exceptions)
157
+ if next_.__cause__ is not None:
158
+ pending.append(next_.__cause__)
159
+ orig = getattr(next_, "orig", None)
160
+ if isinstance(orig, BaseException):
161
+ pending.append(orig)
162
+ return None
163
+
164
+
165
+ def again(error: E) -> E:
166
+ """A copy of ``error``, an error the package keeps, to raise once more: with its cause and
167
+ without a traceback. Each raise of one object adds its frames to that object's
168
+ ``__traceback__``, which a retry loop grows without end and threads change at once."""
169
+ raised = copy.copy(error)
170
+ raised.__cause__ = error.__cause__
171
+ return raised
@@ -0,0 +1,474 @@
1
+ """LangGraph's SQLite checkpointers and stores on a replicated database (design: LangGraph).
2
+
3
+ The only module of the package that imports LangGraph, through the ``langgraph`` extra
4
+ (``pip install 'sqlite-ursula[langgraph]'``), which pins ``langgraph-checkpoint-sqlite`` to the
5
+ releases CI tests. Each class is a subclass of LangGraph's own, unmodified but for its connection
6
+ and its writes::
7
+
8
+ from sqlite_ursula.langgraph import UrsulaSaver
9
+
10
+ db = sqlite_ursula.open(url)
11
+ with UrsulaSaver(db) as saver:
12
+ graph = builder.compile(checkpointer=saver)
13
+ reply = graph.invoke(inputs, {"configurable": {"thread_id": "1"}})
14
+ send(reply) # every checkpoint of the run is in the stream
15
+
16
+ Each class runs on a connection of its own on ``db``'s binding, which it opens and closes:
17
+ ``close()`` or ``with`` for :class:`UrsulaSaver` and :class:`UrsulaStore`, ``aclose()`` or
18
+ ``async with`` for :class:`AsyncUrsulaSaver` and :class:`AsyncUrsulaStore`, which ``open(db)``
19
+ makes on an aiosqlite connection. LangGraph writes checkpoints from threads of its own while the
20
+ graph's nodes run, so a connection the application also writes on would share its transactions.
21
+ The savers' connections keep SQLite's default transaction control, which makes each checkpoint,
22
+ each task's writes and each deleted thread one transaction, and the stores' open no transactions
23
+ of their own, since ``SqliteStore`` runs its own ``BEGIN`` and ``COMMIT``. Each is bound as ``db``
24
+ is, so its errors are the package's too.
25
+
26
+ Every write returns only once ``db.synced()`` (``asynced()``) returned after it: a checkpoint, a
27
+ task's writes, a deleted thread, a store put or delete, a store get or search that refreshes TTLs,
28
+ a TTL sweep. So a graph's ``invoke`` returns once the checkpoints of its run are in the stream,
29
+ since LangGraph waits for its background checkpoint writes before it returns. Reads do not wait,
30
+ and neither does the setup that creates the tables: the next write's wait covers it. A write
31
+ refused with ``backlog_full`` committed nothing: it waits for ``synced()`` and runs again, which
32
+ is safe because each of these writes is one transaction. LangGraph's setup commits its statements
33
+ one by one, and a store's migrations are not safe to run twice, so there each refused statement
34
+ waits and runs again by itself. A reset raises the :class:`~sqlite_ursula.UrsulaResetError`
35
+ itself, from a write or a read. Keep ``db`` open while the graph runs.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import contextlib
41
+ import sqlite3
42
+ from collections.abc import Awaitable
43
+ from collections.abc import Callable
44
+ from collections.abc import Iterable
45
+ from collections.abc import Iterator
46
+ from collections.abc import Sequence
47
+ from typing import Any
48
+ from typing import Self
49
+ from typing import TypeVar
50
+ from typing import cast
51
+
52
+ import aiosqlite
53
+ from langgraph.checkpoint.serde.base import SerializerProtocol
54
+ from langgraph.checkpoint.sqlite import SqliteSaver
55
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
56
+ from langgraph.store.base import GetOp
57
+ from langgraph.store.base import Op
58
+ from langgraph.store.base import PutOp
59
+ from langgraph.store.base import Result
60
+ from langgraph.store.base import SearchOp
61
+ from langgraph.store.sqlite import AsyncSqliteStore
62
+ from langgraph.store.sqlite import SqliteStore
63
+
64
+ from ._args import invalid
65
+ from ._connection import UrsulaConnection
66
+ from ._connection import bound
67
+ from ._connection import on_binding
68
+ from .errors import UrsulaSetupError
69
+ from .errors import UrsulaWriteRefusedError
70
+
71
+ __all__ = ["AsyncUrsulaSaver", "AsyncUrsulaStore", "UrsulaSaver", "UrsulaStore"]
72
+
73
+ T = TypeVar("T")
74
+
75
+ #: How long a store's ``close()`` or ``aclose()`` waits for its TTL sweeper to stop, since a sweep
76
+ #: that waits for the stream during an outage would not stop by itself. ``aclose()`` then cancels
77
+ #: it.
78
+ SWEEPER_STOP_S = 1.0
79
+
80
+
81
+ def _backlog_full(error: UrsulaWriteRefusedError) -> bool:
82
+ return error.kind == "backlog_full"
83
+
84
+
85
+ def _gated(db: UrsulaConnection, writes: bool, call: Callable[..., T], *args: Any) -> T:
86
+ """``call(*args)``'s result, once ``db.synced()`` returned after it when it ``writes``. A
87
+ ``backlog_full`` refusal committed nothing, since ``call`` runs one transaction, and the
88
+ extension admits any commit while nothing is pending: it runs again once ``synced()``
89
+ returned."""
90
+ while True:
91
+ try:
92
+ result = call(*args)
93
+ except UrsulaWriteRefusedError as error:
94
+ if not _backlog_full(error):
95
+ raise
96
+ else:
97
+ if writes:
98
+ db.synced()
99
+ return result
100
+ db.synced()
101
+
102
+
103
+ async def _agated(
104
+ db: UrsulaConnection, writes: bool, call: Callable[..., Awaitable[T]], *args: Any, **kw: Any
105
+ ) -> T:
106
+ """:func:`_gated` for a coroutine function, on ``db.asynced()``. ``AsyncSqliteStore`` runs the
107
+ calls of one tick as one batch, so a call that does not write is refused only with a write of
108
+ another call: it runs again at once, and that call waits."""
109
+ while True:
110
+ try:
111
+ result = await call(*args, **kw)
112
+ except UrsulaWriteRefusedError as error:
113
+ if not _backlog_full(error):
114
+ raise
115
+ else:
116
+ if writes:
117
+ await db.asynced()
118
+ return result
119
+ if writes:
120
+ await db.asynced()
121
+
122
+
123
+ class _Connection(UrsulaConnection):
124
+ """An adapter's own connection on its database's binding.
125
+
126
+ LangGraph's ``setup`` commits its statements one by one, and a store's migrations are not safe
127
+ to run twice: run again after its ``INSERT INTO store_migrations`` was refused, an
128
+ ``ALTER TABLE ... ADD COLUMN`` fails with "duplicate column name". So while ``setting_up``, a
129
+ statement run outside a transaction that is refused with ``backlog_full``, which committed
130
+ nothing, waits for ``synced()`` and runs again by itself. Each of setup's scripts is one
131
+ statement, or statements that are safe to run twice (``CREATE TABLE IF NOT EXISTS``).
132
+
133
+ For the async adapters that wait blocks aiosqlite's thread, which cancelling the setup's caller
134
+ does not end: the connection's other statements and ``aclose()`` wait behind it until the
135
+ stream acknowledges the commits pending before it."""
136
+
137
+ setting_up = False
138
+
139
+ def execute(self, sql: str, parameters: Any = (), /) -> Any:
140
+ return self._admitted(super().execute, sql, parameters)
141
+
142
+ def executescript(self, script: str, /) -> Any:
143
+ return self._admitted(super().executescript, script)
144
+
145
+ def _admitted(self, run: Callable[..., T], *args: Any) -> T:
146
+ if not self.setting_up or self._in_transaction():
147
+ return run(*args)
148
+ while True:
149
+ try:
150
+ return run(*args)
151
+ except UrsulaWriteRefusedError as error:
152
+ if not _backlog_full(error):
153
+ raise
154
+ self.synced()
155
+
156
+
157
+ @contextlib.contextmanager
158
+ def _setting_up(conn: _Connection) -> Iterator[None]:
159
+ conn.setting_up = True
160
+ try:
161
+ yield
162
+ finally:
163
+ conn.setting_up = False
164
+
165
+
166
+ def _connect(db: UrsulaConnection, **kwargs: Any) -> _Connection:
167
+ """A connection of an adapter's own on ``db``'s binding, which any thread may use."""
168
+ database, binding = on_binding(db, _Connection)
169
+ conn = sqlite3.connect(database, check_same_thread=False, **binding, **kwargs)
170
+ return cast(_Connection, conn)
171
+
172
+
173
+ async def _aconnect(
174
+ db: UrsulaConnection, **kwargs: Any
175
+ ) -> tuple[aiosqlite.Connection, _Connection]:
176
+ """An aiosqlite connection of an adapter's own on ``db``'s binding, and the sqlite3
177
+ connection it made on its thread."""
178
+ database, binding = on_binding(db, _Connection)
179
+ bind = binding.pop("factory")
180
+ made: list[_Connection] = []
181
+
182
+ def factory(*args: Any, **kw: Any) -> UrsulaConnection:
183
+ conn = cast(_Connection, bind(*args, **kw))
184
+ made.append(conn)
185
+ return conn
186
+
187
+ conn = await aiosqlite.connect(database, factory=factory, **binding, **kwargs)
188
+ return conn, made[0]
189
+
190
+
191
+ def _refreshes(ttl: Any, refresh_ttl: bool | None) -> bool:
192
+ """Whether a get or a search with ``refresh_ttl`` (``None``: the store's default) writes:
193
+ ``SqliteStore`` refreshes the TTLs of what it reads when its ``ttl`` sets
194
+ ``refresh_on_read``."""
195
+ return bool(ttl and ttl.get("refresh_on_read", False)) and refresh_ttl is not False
196
+
197
+
198
+ def _writes(ops: Sequence[Op], ttl: Any) -> bool:
199
+ """Whether a store batch writes: a put, a delete, or a get or a search that refreshes TTLs."""
200
+ return any(
201
+ isinstance(op, PutOp)
202
+ or (isinstance(op, (GetOp, SearchOp)) and _refreshes(ttl, op.refresh_ttl))
203
+ for op in ops
204
+ )
205
+
206
+
207
+ def _no_conn_string(cls: type, use: str) -> UrsulaSetupError:
208
+ return invalid(f"{cls.__name__} replicates a database open() made, not a file: use {use}")
209
+
210
+
211
+ class UrsulaSaver(SqliteSaver):
212
+ """LangGraph's ``SqliteSaver`` on a connection of its own on ``db``'s binding, ``db`` a
213
+ connection ``open()`` made: ``put``, ``put_writes`` and ``delete_thread`` return once
214
+ ``db.synced()`` returned after them, and a ``backlog_full`` refusal runs again after
215
+ ``synced()``.
216
+
217
+ LangGraph runs a graph's checkpoint writes in the background and waits for them before
218
+ ``invoke`` returns, so ``invoke``'s return is gated in every durability mode.
219
+ ``durability="sync"`` also gates what a node does after the step before it, at one round trip
220
+ per step. ``close()``, or ``with``, closes its connection. ``db`` stays open.
221
+ """
222
+
223
+ conn: _Connection
224
+
225
+ def __init__(self, db: UrsulaConnection, *, serde: SerializerProtocol | None = None) -> None:
226
+ self.db = bound(db, type(self).__name__)
227
+ conn = _connect(self.db)
228
+ try:
229
+ super().__init__(conn, serde=serde)
230
+ except BaseException:
231
+ conn.close()
232
+ raise
233
+
234
+ @classmethod
235
+ def from_conn_string(cls, conn_string: str) -> Any:
236
+ raise _no_conn_string(cls, f"{cls.__name__}(sqlite_ursula.open(url))")
237
+
238
+ def setup(self) -> None:
239
+ if not self.is_setup:
240
+ with _setting_up(self.conn):
241
+ super().setup()
242
+
243
+ def put(self, config: Any, checkpoint: Any, metadata: Any, new_versions: Any) -> Any:
244
+ return _gated(self.db, True, super().put, config, checkpoint, metadata, new_versions)
245
+
246
+ def put_writes(
247
+ self, config: Any, writes: Sequence[tuple[str, Any]], task_id: str, task_path: str = ""
248
+ ) -> None:
249
+ _gated(self.db, True, super().put_writes, config, writes, task_id, task_path)
250
+
251
+ def delete_thread(self, thread_id: str) -> None:
252
+ _gated(self.db, True, super().delete_thread, thread_id)
253
+
254
+ def close(self) -> None:
255
+ """Closes the saver's connection, after the call that holds it. ``db`` stays open."""
256
+ with self.lock:
257
+ self.conn.close()
258
+
259
+ def __enter__(self) -> Self:
260
+ return self
261
+
262
+ def __exit__(self, *exc: object) -> None:
263
+ self.close()
264
+
265
+
266
+ class AsyncUrsulaSaver(AsyncSqliteSaver):
267
+ """LangGraph's ``AsyncSqliteSaver`` on an aiosqlite connection of its own on ``db``'s
268
+ binding: ``aput``, ``aput_writes`` and ``adelete_thread`` return once ``db.asynced()``
269
+ returned after them, and a ``backlog_full`` refusal runs again after ``asynced()``. Its
270
+ synchronous calls, which LangGraph runs from other threads, are those.
271
+
272
+ Make it with :meth:`open`, and close it with :meth:`aclose` or ``async with``.
273
+ """
274
+
275
+ def __init__(
276
+ self,
277
+ db: UrsulaConnection,
278
+ conn: aiosqlite.Connection,
279
+ *,
280
+ serde: SerializerProtocol | None = None,
281
+ _own: _Connection,
282
+ ) -> None:
283
+ """Made by :meth:`open`: ``conn`` is the aiosqlite connection it made on ``db``'s
284
+ binding, and ``_own`` the sqlite3 connection under it."""
285
+ self.db = bound(db, type(self).__name__)
286
+ self._own = _own
287
+ super().__init__(conn, serde=serde)
288
+
289
+ @classmethod
290
+ async def open(cls, db: UrsulaConnection, *, serde: SerializerProtocol | None = None) -> Self:
291
+ """The saver of ``db``, a connection ``open()`` or ``aopen()`` made."""
292
+ checked = bound(db, cls.__name__)
293
+ conn, own = await _aconnect(checked)
294
+ try:
295
+ return cls(checked, conn, serde=serde, _own=own)
296
+ except BaseException:
297
+ await conn.close()
298
+ raise
299
+
300
+ @classmethod
301
+ def from_conn_string(cls, conn_string: str) -> Any:
302
+ raise _no_conn_string(cls, f"await {cls.__name__}.open(sqlite_ursula.open(url))")
303
+
304
+ async def setup(self) -> None:
305
+ if not self.is_setup:
306
+ with _setting_up(self._own):
307
+ await super().setup()
308
+
309
+ async def aput(self, config: Any, checkpoint: Any, metadata: Any, new_versions: Any) -> Any:
310
+ return await _agated(
311
+ self.db, True, super().aput, config, checkpoint, metadata, new_versions
312
+ )
313
+
314
+ async def aput_writes(
315
+ self, config: Any, writes: Sequence[tuple[str, Any]], task_id: str, task_path: str = ""
316
+ ) -> None:
317
+ await _agated(self.db, True, super().aput_writes, config, writes, task_id, task_path)
318
+
319
+ async def adelete_thread(self, thread_id: str) -> None:
320
+ await _agated(self.db, True, super().adelete_thread, thread_id)
321
+
322
+ async def aclose(self) -> None:
323
+ """Closes the saver's connection. ``db`` stays open."""
324
+ await self.conn.close()
325
+
326
+ async def __aenter__(self) -> Self:
327
+ return self
328
+
329
+ async def __aexit__(self, *exc: object) -> None:
330
+ await self.aclose()
331
+
332
+
333
+ class UrsulaStore(SqliteStore):
334
+ """LangGraph's ``SqliteStore`` on a connection of its own on ``db``'s binding, ``db`` a
335
+ connection ``open()`` made, without implicit transactions as ``SqliteStore``'s own
336
+ connections are: a batch that writes (a put, a delete, a get or a search that refreshes TTLs)
337
+ and a TTL sweep return once ``db.synced()`` returned after them, and a ``backlog_full`` refusal
338
+ runs again after ``synced()``. ``close()``, or ``with``, stops its TTL sweeper and closes its
339
+ connection. ``db`` stays open."""
340
+
341
+ conn: _Connection
342
+
343
+ def __init__(
344
+ self,
345
+ db: UrsulaConnection,
346
+ *,
347
+ deserializer: Callable[[Any], dict[str, Any]] | None = None,
348
+ index: Any = None,
349
+ ttl: Any = None,
350
+ ) -> None:
351
+ self.db = bound(db, type(self).__name__)
352
+ conn = _connect(self.db, isolation_level=None)
353
+ try:
354
+ super().__init__(conn, deserializer=deserializer, index=index, ttl=ttl)
355
+ except BaseException:
356
+ conn.close()
357
+ raise
358
+
359
+ @classmethod
360
+ def from_conn_string(cls, conn_string: str, **kwargs: Any) -> Any:
361
+ raise _no_conn_string(cls, f"{cls.__name__}(sqlite_ursula.open(url))")
362
+
363
+ def setup(self) -> None:
364
+ if not self.is_setup:
365
+ with _setting_up(self.conn):
366
+ super().setup()
367
+
368
+ def batch(self, ops: Iterable[Op]) -> list[Result]:
369
+ listed = list(ops)
370
+ return _gated(self.db, _writes(listed, self.ttl_config), super().batch, listed)
371
+
372
+ def sweep_ttl(self) -> int:
373
+ return _gated(self.db, True, super().sweep_ttl)
374
+
375
+ def close(self) -> None:
376
+ """Stops the TTL sweeper, waiting at most a second for it, and closes the store's
377
+ connection after the call that holds it. ``db`` stays open."""
378
+ self.stop_ttl_sweeper(timeout=SWEEPER_STOP_S)
379
+ with self.lock:
380
+ self.conn.close()
381
+
382
+ def __enter__(self) -> Self:
383
+ return self
384
+
385
+ def __exit__(self, *exc: object) -> None:
386
+ self.close()
387
+
388
+
389
+ class AsyncUrsulaStore(AsyncSqliteStore):
390
+ """LangGraph's ``AsyncSqliteStore`` on an aiosqlite connection of its own on ``db``'s binding,
391
+ without implicit transactions: ``aput`` and ``adelete``, an ``aget`` or ``asearch`` that
392
+ refreshes TTLs, and a TTL sweep return once ``db.asynced()`` returned after them, and each
393
+ write refused with ``backlog_full`` runs again after ``asynced()``. A read batched with a
394
+ refused write runs again at once. The waits are the callers', so the task that runs the store's
395
+ batches never waits for the stream, and a read never waits behind another call's write. A
396
+ statement of the setup refused with ``backlog_full`` is the exception: it waits on aiosqlite's
397
+ thread (``_Connection``). ``abatch`` and ``batch``, which take operations, are LangGraph's own:
398
+ call ``db.asynced()`` after them.
399
+
400
+ Make it with :meth:`open`, and close it with :meth:`aclose` or ``async with``, which also stop
401
+ its TTL sweeper.
402
+ """
403
+
404
+ def __init__(
405
+ self,
406
+ db: UrsulaConnection,
407
+ conn: aiosqlite.Connection,
408
+ *,
409
+ deserializer: Callable[[Any], dict[str, Any]] | None = None,
410
+ index: Any = None,
411
+ ttl: Any = None,
412
+ _own: _Connection,
413
+ ) -> None:
414
+ """Made by :meth:`open`: ``conn`` is the aiosqlite connection it made on ``db``'s
415
+ binding, and ``_own`` the sqlite3 connection under it."""
416
+ self.db = bound(db, type(self).__name__)
417
+ self._own = _own
418
+ super().__init__(conn, deserializer=deserializer, index=index, ttl=ttl)
419
+
420
+ @classmethod
421
+ async def open(
422
+ cls,
423
+ db: UrsulaConnection,
424
+ *,
425
+ deserializer: Callable[[Any], dict[str, Any]] | None = None,
426
+ index: Any = None,
427
+ ttl: Any = None,
428
+ ) -> Self:
429
+ """The store of ``db``, a connection ``open()`` or ``aopen()`` made."""
430
+ checked = bound(db, cls.__name__)
431
+ conn, own = await _aconnect(checked, isolation_level=None)
432
+ try:
433
+ return cls(checked, conn, deserializer=deserializer, index=index, ttl=ttl, _own=own)
434
+ except BaseException:
435
+ await conn.close()
436
+ raise
437
+
438
+ @classmethod
439
+ def from_conn_string(cls, conn_string: str, **kwargs: Any) -> Any:
440
+ raise _no_conn_string(cls, f"await {cls.__name__}.open(sqlite_ursula.open(url))")
441
+
442
+ async def setup(self) -> None:
443
+ if not self.is_setup:
444
+ with _setting_up(self._own):
445
+ await super().setup()
446
+
447
+ async def aget(self, *args: Any, **kwargs: Any) -> Any:
448
+ writes = _refreshes(self.ttl_config, kwargs.get("refresh_ttl"))
449
+ return await _agated(self.db, writes, super().aget, *args, **kwargs)
450
+
451
+ async def asearch(self, *args: Any, **kwargs: Any) -> Any:
452
+ writes = _refreshes(self.ttl_config, kwargs.get("refresh_ttl"))
453
+ return await _agated(self.db, writes, super().asearch, *args, **kwargs)
454
+
455
+ async def alist_namespaces(self, *args: Any, **kwargs: Any) -> Any:
456
+ return await _agated(self.db, False, super().alist_namespaces, *args, **kwargs)
457
+
458
+ async def aput(self, *args: Any, **kwargs: Any) -> None:
459
+ await _agated(self.db, True, super().aput, *args, **kwargs)
460
+
461
+ async def adelete(self, *args: Any, **kwargs: Any) -> None:
462
+ await _agated(self.db, True, super().adelete, *args, **kwargs)
463
+
464
+ async def sweep_ttl(self) -> int:
465
+ return await _agated(self.db, True, super().sweep_ttl)
466
+
467
+ async def aclose(self) -> None:
468
+ """Stops the TTL sweeper, cancelling it after a second, and closes the store's
469
+ connection. ``db`` stays open."""
470
+ await self.stop_ttl_sweeper(timeout=SWEEPER_STOP_S)
471
+ await self.conn.close()
472
+
473
+ async def __aexit__(self, *exc: object) -> None:
474
+ await self.aclose()
sqlite_ursula/py.typed ADDED
File without changes