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/errors.py
ADDED
|
@@ -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
|