tapestry-orm 0.0.1__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.
- tapestry/__init__.py +11 -0
- tapestry/base.py +401 -0
- tapestry/base.pyi +54 -0
- tapestry/edge.py +271 -0
- tapestry/edge.pyi +45 -0
- tapestry/engine.py +301 -0
- tapestry/field.py +315 -0
- tapestry/field.pyi +113 -0
- tapestry/node.py +169 -0
- tapestry/node.pyi +28 -0
- tapestry/py.typed +0 -0
- tapestry/query.py +404 -0
- tapestry/query.pyi +33 -0
- tapestry/table.py +464 -0
- tapestry/tokenizer.py +156 -0
- tapestry/utils.py +70 -0
- tapestry_orm-0.0.1.dist-info/METADATA +418 -0
- tapestry_orm-0.0.1.dist-info/RECORD +19 -0
- tapestry_orm-0.0.1.dist-info/WHEEL +4 -0
tapestry/edge.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from typing import Self, Iterable, get_type_hints, Any, ClassVar, Union, TYPE_CHECKING
|
|
4
|
+
from pydantic.fields import FieldInfo, ComputedFieldInfo
|
|
5
|
+
from surrealdb import AsyncWsSurrealConnection, AsyncHttpSurrealConnection, RecordID
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from .base import Base
|
|
9
|
+
from .node import Node
|
|
10
|
+
from .utils import replace_type
|
|
11
|
+
from .table import Table, Link, flatten_type
|
|
12
|
+
from .field import Traversal, Direction, Condition, LogicalCondition, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Edge(Base, ABC, discarded=True):
|
|
16
|
+
"""
|
|
17
|
+
Base class for SurrealDB edge (relation) tables.
|
|
18
|
+
|
|
19
|
+
Edge represents relationship tables in SurrealDB that connect two nodes.
|
|
20
|
+
All relationship tables should inherit from this class and define
|
|
21
|
+
'in_' and 'out_' fields to specify the connected node types.
|
|
22
|
+
|
|
23
|
+
Class Attributes:
|
|
24
|
+
_directed (bool): Whether the relationship is directional (default: True)
|
|
25
|
+
|
|
26
|
+
Required Fields:
|
|
27
|
+
in_: The source node of the relationship
|
|
28
|
+
out_: The target node of the relationship
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> from tapestry import Edge, Node
|
|
32
|
+
>>> from datetime import date
|
|
33
|
+
>>>
|
|
34
|
+
>>> class Person(Node):
|
|
35
|
+
... name: str
|
|
36
|
+
...
|
|
37
|
+
>>> class Role(Node):
|
|
38
|
+
... title: str
|
|
39
|
+
...
|
|
40
|
+
>>> class BelongsTo(Edge):
|
|
41
|
+
... in_: Person # Person belongs to Role
|
|
42
|
+
... out_: Role
|
|
43
|
+
... begin_date: date
|
|
44
|
+
... end_date: date | None = None
|
|
45
|
+
...
|
|
46
|
+
>>> # Create a relationship
|
|
47
|
+
>>> person = Person(name="John Doe")
|
|
48
|
+
>>> role = Role(title="Manager")
|
|
49
|
+
>>> belongs = BelongsTo(
|
|
50
|
+
... in_=person,
|
|
51
|
+
... out_=role,
|
|
52
|
+
... begin_date=date(2020, 1, 1)
|
|
53
|
+
... )
|
|
54
|
+
>>> await belongs.relate(db)
|
|
55
|
+
|
|
56
|
+
Notes:
|
|
57
|
+
- Requires both 'in_' and 'out_' fields to be defined
|
|
58
|
+
- Automatically creates SurrealDB RELATION tables
|
|
59
|
+
- Supports directional and bidirectional relationships
|
|
60
|
+
- Can have additional fields beyond in_ and out_
|
|
61
|
+
- Use relate() instead of create() for edge records
|
|
62
|
+
"""
|
|
63
|
+
# in_: Node
|
|
64
|
+
# out_: Node
|
|
65
|
+
#
|
|
66
|
+
_directed: bool = True
|
|
67
|
+
|
|
68
|
+
def __init_subclass__(cls, directed: bool = True, **kwargs):
|
|
69
|
+
"""
|
|
70
|
+
Configure edge subclasses with relationship properties.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
directed: Whether the relationship is directional. If False,
|
|
74
|
+
creates a bidirectional relationship where in/out
|
|
75
|
+
order doesn't matter.
|
|
76
|
+
**kwargs: Additional configuration passed to parent
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
TypeError: If the subclass doesn't define both 'in_' and 'out_' fields
|
|
80
|
+
"""
|
|
81
|
+
super().__init_subclass__(**kwargs)
|
|
82
|
+
|
|
83
|
+
# Get type hints for the subclass
|
|
84
|
+
hints = get_type_hints(cls)
|
|
85
|
+
|
|
86
|
+
# Check if required fields are annotated
|
|
87
|
+
required_fields = {'in_', 'out_'}
|
|
88
|
+
missing_fields = required_fields - set(hints.keys())
|
|
89
|
+
if missing_fields:
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"Class {cls.__name__} must have field annotations for: {missing_fields}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
cls._directed = directed
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def __pydantic_init_subclass__(cls, **kwargs):
|
|
99
|
+
relation = None
|
|
100
|
+
in_, out_ = cls.model_fields.pop('in_', None), cls.model_fields.pop('out_', None)
|
|
101
|
+
if in_ or out_:
|
|
102
|
+
if in_ and out_:
|
|
103
|
+
assert in_.annotation
|
|
104
|
+
assert out_.annotation
|
|
105
|
+
assert all(issubclass(t, Node) for t in flatten_type(in_.annotation))
|
|
106
|
+
assert all(issubclass(t, Node) for t in flatten_type(out_.annotation))
|
|
107
|
+
relation = Link(_in=in_.annotation, _out=out_.annotation, symetric=not cls._directed)
|
|
108
|
+
cls.is_relation = True
|
|
109
|
+
else:
|
|
110
|
+
raise Exception("You need to specify both `_in` and `_out` to define a relation")
|
|
111
|
+
else:
|
|
112
|
+
raise Exception("You need to specify `_in` and `_out` to define an Edge")
|
|
113
|
+
super().__pydantic_init_subclass__(relation=relation, **kwargs)
|
|
114
|
+
# need to add those back for validation at instanciation
|
|
115
|
+
# otherwise, the field is skipped
|
|
116
|
+
in_.annotation = replace_type(in_.annotation, Base, Union[Base, RecordID])
|
|
117
|
+
out_.annotation = replace_type(out_.annotation, Base, Union[Base, RecordID])
|
|
118
|
+
cls.model_fields["in_"] = in_
|
|
119
|
+
cls.model_fields["out_"] = out_
|
|
120
|
+
cls.model_rebuild(force=True)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def db_dump(self) -> dict[str, Any]:
|
|
125
|
+
"""
|
|
126
|
+
Serialize the edge instance for database insertion.
|
|
127
|
+
|
|
128
|
+
Converts Python field names to SurrealDB format by renaming
|
|
129
|
+
'in_' to 'in' and 'out_' to 'out'.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
dict[str, Any]: Dictionary ready for SurrealDB relation operations
|
|
133
|
+
"""
|
|
134
|
+
dump = super().db_dump()
|
|
135
|
+
dump["in"] = dump.pop("in_")
|
|
136
|
+
dump["out"] = dump.pop("out_")
|
|
137
|
+
return dump
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def relate(
|
|
141
|
+
self,
|
|
142
|
+
db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection
|
|
143
|
+
) -> Self:
|
|
144
|
+
"""
|
|
145
|
+
Create a relationship record in the database.
|
|
146
|
+
|
|
147
|
+
Creates an edge record connecting two nodes in SurrealDB.
|
|
148
|
+
This is the primary method for creating relationships.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
db: Active SurrealDB connection (WebSocket or HTTP)
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Self: The same instance with ID assigned
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
>>> person = await Person.create(db)
|
|
158
|
+
>>> role = await Role.create(db)
|
|
159
|
+
>>> belongs = BelongsTo(
|
|
160
|
+
... in_=person,
|
|
161
|
+
... out_=role,
|
|
162
|
+
... begin_date=date.today()
|
|
163
|
+
... )
|
|
164
|
+
>>> await belongs.relate(db)
|
|
165
|
+
>>> print(belongs.id) # Has an ID like belongs_to:xyz
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
Exception: If called on a non-relation table
|
|
169
|
+
|
|
170
|
+
Notes:
|
|
171
|
+
- Both in_ and out_ nodes must exist in the database
|
|
172
|
+
- Creates a directed or bidirectional edge based on class configuration
|
|
173
|
+
- The edge record gets a unique ID from SurrealDB
|
|
174
|
+
"""
|
|
175
|
+
if not self.is_relation:
|
|
176
|
+
raise Exception("You should use .create() to create a record")
|
|
177
|
+
thing = self.__class__.__name__.lower()
|
|
178
|
+
relation = await db.insert_relation(
|
|
179
|
+
thing,
|
|
180
|
+
self.db_dump()
|
|
181
|
+
)
|
|
182
|
+
if isinstance(relation, list):
|
|
183
|
+
self.id = relation[0]["id"]
|
|
184
|
+
else:
|
|
185
|
+
self.id = relation["id"]
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
async def insert(
|
|
191
|
+
cls,
|
|
192
|
+
db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection, others: Iterable[Self]
|
|
193
|
+
) -> list[Self]:
|
|
194
|
+
"""
|
|
195
|
+
Batch insert multiple edge records into the database.
|
|
196
|
+
|
|
197
|
+
Efficiently creates multiple relationships in a single operation.
|
|
198
|
+
All edge instances are updated with their assigned IDs.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
db: Active SurrealDB connection (WebSocket or HTTP)
|
|
202
|
+
others: Iterable of edge instances to insert
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
list[Self]: The same instances with IDs assigned
|
|
206
|
+
|
|
207
|
+
Example:
|
|
208
|
+
>>> relationships = [
|
|
209
|
+
... BelongsTo(in_=person1, out_=role1, begin_date=date(2020, 1, 1)),
|
|
210
|
+
... BelongsTo(in_=person2, out_=role2, begin_date=date(2021, 1, 1)),
|
|
211
|
+
... ]
|
|
212
|
+
>>> inserted = await BelongsTo.insert(db, relationships)
|
|
213
|
+
>>> # All relationships now have IDs
|
|
214
|
+
|
|
215
|
+
Notes:
|
|
216
|
+
- More efficient than multiple relate() calls
|
|
217
|
+
- All edges are inserted in a single transaction
|
|
218
|
+
- Original instances are modified with IDs
|
|
219
|
+
"""
|
|
220
|
+
others = list(others)
|
|
221
|
+
inserted = await db.insert_relation(
|
|
222
|
+
cls.__name__.lower(),
|
|
223
|
+
[other.db_dump() for other in others]
|
|
224
|
+
)
|
|
225
|
+
for insert, original in zip(inserted, others):
|
|
226
|
+
original.id = insert["id"]
|
|
227
|
+
return others
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def where(cls, condition):
|
|
231
|
+
"""
|
|
232
|
+
Add a WHERE condition to this edge for use in graph traversals.
|
|
233
|
+
|
|
234
|
+
Creates a conditional edge that can be used in query traversals
|
|
235
|
+
to filter relationships based on their properties.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
condition: A condition expression that filters edge records
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
EdgeWithCondition: A wrapped edge class with the condition attached
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
>>> # Find all people who belong to roles that started after 2020
|
|
245
|
+
>>> query = (Q(Person) >>
|
|
246
|
+
... BelongsTo.where(BelongsTo.begin_date > date(2020, 1, 1)) >>
|
|
247
|
+
... Role)
|
|
248
|
+
>>> results = await query.execute(db)
|
|
249
|
+
|
|
250
|
+
Notes:
|
|
251
|
+
- Used primarily in graph traversal queries
|
|
252
|
+
- Conditions are applied during traversal, not at definition
|
|
253
|
+
- Can filter based on any edge properties
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
# Create a new class-like object that wraps the Edge with a condition
|
|
257
|
+
class EdgeWithCondition:
|
|
258
|
+
def __init__(self, edge_class, condition):
|
|
259
|
+
self.edge_class = edge_class
|
|
260
|
+
self.condition = condition
|
|
261
|
+
|
|
262
|
+
def __repr__(self):
|
|
263
|
+
return f"{self.edge_class.__name__.lower()}[WHERE {self.condition.to_surreal()}]"
|
|
264
|
+
|
|
265
|
+
# Allow this to be used in traversals
|
|
266
|
+
def to_traversal(self, direction: Direction) -> Traversal:
|
|
267
|
+
traversal = Traversal(direction, self.edge_class)
|
|
268
|
+
traversal.where_condition = self.condition
|
|
269
|
+
return traversal
|
|
270
|
+
|
|
271
|
+
return EdgeWithCondition(cls, condition)
|
tapestry/edge.pyi
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import ClassVar, Iterable, TypeVar, Type
|
|
5
|
+
from surrealdb import AsyncWsSurrealConnection, AsyncHttpSurrealConnection
|
|
6
|
+
|
|
7
|
+
from .base import Base
|
|
8
|
+
from .node import Node
|
|
9
|
+
from .field import Condition, Direction, Traversal
|
|
10
|
+
|
|
11
|
+
T = TypeVar('T', bound='Edge')
|
|
12
|
+
|
|
13
|
+
class EdgeWithCondition:
|
|
14
|
+
edge_class: Type[Edge]
|
|
15
|
+
condition: Condition
|
|
16
|
+
|
|
17
|
+
def __init__(self, edge_class: Type[Edge], condition: Condition) -> None: ...
|
|
18
|
+
def __repr__(self) -> str: ...
|
|
19
|
+
def to_traversal(self, direction: Direction) -> Traversal: ...
|
|
20
|
+
|
|
21
|
+
class Edge(Base, ABC):
|
|
22
|
+
_directed: ClassVar[bool]
|
|
23
|
+
is_relation: ClassVar[bool]
|
|
24
|
+
|
|
25
|
+
def __init_subclass__(cls, directed: bool = True, **kwargs: object) -> None: ...
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def __pydantic_init_subclass__(cls, **kwargs: object) -> None: ...
|
|
29
|
+
|
|
30
|
+
def db_dump(self) -> dict[str, object]: ...
|
|
31
|
+
|
|
32
|
+
async def relate(
|
|
33
|
+
self: T,
|
|
34
|
+
db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection
|
|
35
|
+
) -> T: ...
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
async def insert(
|
|
39
|
+
cls: type[T],
|
|
40
|
+
db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection,
|
|
41
|
+
others: Iterable[T]
|
|
42
|
+
) -> list[T]: ...
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def where(cls: type[T], condition: Condition) -> EdgeWithCondition: ...
|
tapestry/engine.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import contextlib
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, AsyncGenerator
|
|
6
|
+
from surrealdb import AsyncSurreal, AsyncWsSurrealConnection, AsyncHttpSurrealConnection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
class SurrealClientPool:
|
|
12
|
+
"""
|
|
13
|
+
Connection pool manager for SurrealDB clients.
|
|
14
|
+
|
|
15
|
+
Manages a pool of authenticated SurrealDB connections that can be
|
|
16
|
+
reused across async operations. This improves performance by avoiding
|
|
17
|
+
the overhead of creating new connections for each operation.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
url: SurrealDB connection URL
|
|
21
|
+
auth_payload: Authentication credentials dictionary
|
|
22
|
+
pool_size: Number of connections to maintain in the pool
|
|
23
|
+
connect_timeout: Timeout for connection attempts in seconds
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
>>> pool = SurrealClientPool(
|
|
27
|
+
... url="ws://localhost:8000/rpc",
|
|
28
|
+
... auth_payload={"username": "root", "password": "root"},
|
|
29
|
+
... pool_size=5
|
|
30
|
+
... )
|
|
31
|
+
>>> await pool.start()
|
|
32
|
+
>>> async with pool.acquire() as client:
|
|
33
|
+
... await client.use("mydb", "myns")
|
|
34
|
+
... results = await client.select("person")
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
url: str,
|
|
40
|
+
auth_payload: dict,
|
|
41
|
+
pool_size: int = 4,
|
|
42
|
+
connect_timeout: float = 5.
|
|
43
|
+
):
|
|
44
|
+
"""
|
|
45
|
+
Initialize the connection pool.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
url: SurrealDB connection URL (ws:// or http://)
|
|
49
|
+
auth_payload: Dictionary with authentication credentials
|
|
50
|
+
pool_size: Number of connections to maintain (default: 4)
|
|
51
|
+
connect_timeout: Connection timeout in seconds (default: 5.0)
|
|
52
|
+
"""
|
|
53
|
+
self.url = url
|
|
54
|
+
self.auth_payload = auth_payload
|
|
55
|
+
self.pool_size = pool_size
|
|
56
|
+
self.connect_timeout = connect_timeout
|
|
57
|
+
self._queue: asyncio.Queue = asyncio.Queue()
|
|
58
|
+
self._clients = [] # keep references for shutdown
|
|
59
|
+
self._closed = False
|
|
60
|
+
|
|
61
|
+
async def _make_one(self):
|
|
62
|
+
"""
|
|
63
|
+
Create and authenticate a single SurrealDB client.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
An authenticated AsyncSurreal client ready for use
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
Exception: If connection or authentication fails
|
|
70
|
+
"""
|
|
71
|
+
c = AsyncSurreal(self.url)
|
|
72
|
+
await c.signin(self.auth_payload)
|
|
73
|
+
return c
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def start(self):
|
|
77
|
+
"""
|
|
78
|
+
Initialize the connection pool with authenticated clients.
|
|
79
|
+
|
|
80
|
+
Creates the specified number of clients, authenticates them,
|
|
81
|
+
and adds them to the pool queue for reuse.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
Exception: If unable to create the required number of clients
|
|
85
|
+
"""
|
|
86
|
+
for i in range(self.pool_size):
|
|
87
|
+
try:
|
|
88
|
+
client = await self._make_one()
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.exception("Failed to create surreal client #%d: %s", i, e)
|
|
91
|
+
# retry/backoff before giving up; here we re-raise after a small delay
|
|
92
|
+
await asyncio.sleep(0.5)
|
|
93
|
+
client = await self._make_one()
|
|
94
|
+
self._clients.append(client)
|
|
95
|
+
self._queue.put_nowait(client)
|
|
96
|
+
logger.info("SurrealDB pool started (%d clients)", len(self._clients))
|
|
97
|
+
|
|
98
|
+
async def close(self):
|
|
99
|
+
"""
|
|
100
|
+
Close all connections and shut down the pool.
|
|
101
|
+
|
|
102
|
+
Drains the queue and closes all client connections gracefully.
|
|
103
|
+
This should be called when the application shuts down.
|
|
104
|
+
"""
|
|
105
|
+
if self._closed:
|
|
106
|
+
return
|
|
107
|
+
self._closed = True
|
|
108
|
+
# drain queue so other tasks don't try to reuse
|
|
109
|
+
while not self._queue.empty():
|
|
110
|
+
try:
|
|
111
|
+
_ = self._queue.get_nowait()
|
|
112
|
+
self._queue.task_done()
|
|
113
|
+
except asyncio.QueueEmpty:
|
|
114
|
+
break
|
|
115
|
+
# close actual clients
|
|
116
|
+
for c in self._clients:
|
|
117
|
+
try:
|
|
118
|
+
await c.close()
|
|
119
|
+
except Exception:
|
|
120
|
+
logger.exception("Error closing surreal client")
|
|
121
|
+
self._clients.clear()
|
|
122
|
+
logger.info("SurrealDB pool closed")
|
|
123
|
+
|
|
124
|
+
@contextlib.asynccontextmanager
|
|
125
|
+
async def acquire(self) -> AsyncIterator[AsyncWsSurrealConnection | AsyncHttpSurrealConnection]:
|
|
126
|
+
"""
|
|
127
|
+
Acquire a connection from the pool as a context manager.
|
|
128
|
+
|
|
129
|
+
Gets an authenticated client from the pool, yields it for use,
|
|
130
|
+
and automatically returns it to the pool when done.
|
|
131
|
+
|
|
132
|
+
Yields:
|
|
133
|
+
AsyncWsSurrealConnection | AsyncHttpSurrealConnection: An authenticated client
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
>>> async with pool.acquire() as client:
|
|
137
|
+
... results = await client.select("person")
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
Exception: If the acquired client encounters a fatal error
|
|
141
|
+
"""
|
|
142
|
+
client = await self._queue.get()
|
|
143
|
+
try:
|
|
144
|
+
yield client
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
# If the client had a fatal error, attempt to reconnect/replace it
|
|
147
|
+
# Simple strategy: if exception looks like connection/auth error -> recreate
|
|
148
|
+
if self._is_fatal_client_exc(exc):
|
|
149
|
+
logger.warning("Client had fatal error, replacing: %s", exc)
|
|
150
|
+
await self._replace_client(client)
|
|
151
|
+
# re-raise so caller can handle retry logic
|
|
152
|
+
raise
|
|
153
|
+
else:
|
|
154
|
+
raise
|
|
155
|
+
finally:
|
|
156
|
+
# If pool is closing, don't put back
|
|
157
|
+
if not self._closed:
|
|
158
|
+
self._queue.put_nowait(client)
|
|
159
|
+
|
|
160
|
+
async def run_query(self, *args, retries: int = 1, backoff: float = 0.2, **kwargs):
|
|
161
|
+
"""
|
|
162
|
+
Execute a query using a pooled connection with automatic retry.
|
|
163
|
+
|
|
164
|
+
Acquires a client from the pool, executes the query, and handles
|
|
165
|
+
retries for transient failures.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
*args: Arguments to pass to the query method
|
|
169
|
+
retries: Number of retry attempts for transient errors (default: 1)
|
|
170
|
+
backoff: Base backoff time in seconds between retries (default: 0.2)
|
|
171
|
+
**kwargs: Keyword arguments to pass to the query method
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Query results from SurrealDB
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
Exception: If the query fails after all retry attempts
|
|
178
|
+
"""
|
|
179
|
+
for attempt in range(retries + 1):
|
|
180
|
+
async with self.acquire() as client:
|
|
181
|
+
try:
|
|
182
|
+
# Replace with client's actual query/call method
|
|
183
|
+
return await client.query(*args, **kwargs)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
logger.exception("Query failed on attempt %d: %s", attempt, exc)
|
|
186
|
+
if attempt < retries and self._is_retryable(exc):
|
|
187
|
+
await asyncio.sleep(backoff * (2 ** attempt))
|
|
188
|
+
continue
|
|
189
|
+
# If fatal to client, replace it so pool remains healthy
|
|
190
|
+
if self._is_fatal_client_exc(exc):
|
|
191
|
+
await self._replace_client(client)
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
async def _replace_client(self, dead_client):
|
|
195
|
+
"""
|
|
196
|
+
Replace a failed client with a new authenticated connection.
|
|
197
|
+
|
|
198
|
+
Closes the dead client, creates a new one, and adds it back to the pool
|
|
199
|
+
to maintain the pool size.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
dead_client: The client connection that failed
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
await dead_client.close()
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
try:
|
|
209
|
+
new_client = await self._make_one()
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.exception("Failed to create replacement client: %s", e)
|
|
212
|
+
# If we can't create a replacement immediately, put back the old one to avoid starvation.
|
|
213
|
+
# In production, you might want to implement more sophisticated retry/backoff + alerts.
|
|
214
|
+
self._queue.put_nowait(dead_client)
|
|
215
|
+
return
|
|
216
|
+
# replace in internal list
|
|
217
|
+
try:
|
|
218
|
+
idx = self._clients.index(dead_client)
|
|
219
|
+
self._clients[idx] = new_client
|
|
220
|
+
except ValueError:
|
|
221
|
+
self._clients.append(new_client)
|
|
222
|
+
self._queue.put_nowait(new_client)
|
|
223
|
+
|
|
224
|
+
def _is_retryable(self, exc: Exception) -> bool:
|
|
225
|
+
"""
|
|
226
|
+
Determine if an exception is retryable.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
exc: The exception to check
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
bool: True if the error is transient and worth retrying
|
|
233
|
+
"""
|
|
234
|
+
return isinstance(exc, (asyncio.TimeoutError, ConnectionError))
|
|
235
|
+
|
|
236
|
+
def _is_fatal_client_exc(self, exc: Exception) -> bool:
|
|
237
|
+
"""
|
|
238
|
+
Determine if an exception indicates a broken client connection.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
exc: The exception to check
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
bool: True if the client connection is likely broken and needs replacement
|
|
245
|
+
"""
|
|
246
|
+
return isinstance(exc, (ConnectionError, RuntimeError))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def create_engine(
|
|
251
|
+
url: str,
|
|
252
|
+
auth_payload: dict,
|
|
253
|
+
pool_size: int = 4,
|
|
254
|
+
connect_timeout: float = 5.
|
|
255
|
+
) -> AsyncGenerator[AsyncWsSurrealConnection | AsyncHttpSurrealConnection, None]:
|
|
256
|
+
"""
|
|
257
|
+
Create a connection pool for SurrealDB with automatic connection management.
|
|
258
|
+
|
|
259
|
+
This function creates a pool of authenticated SurrealDB connections that can
|
|
260
|
+
be reused across your application. It returns a context manager that handles
|
|
261
|
+
acquiring and releasing connections automatically.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
url: SurrealDB connection URL (e.g., "ws://localhost:8000/rpc")
|
|
265
|
+
auth_payload: Dictionary with authentication credentials
|
|
266
|
+
Example: {"username": "root", "password": "root"}
|
|
267
|
+
pool_size: Number of connections to maintain in the pool (default: 4)
|
|
268
|
+
connect_timeout: Timeout for connection attempts in seconds (default: 5.0)
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
AsyncGenerator: A context manager for acquiring connections from the pool
|
|
272
|
+
|
|
273
|
+
Example:
|
|
274
|
+
>>> # Create an engine
|
|
275
|
+
>>> engine = create_engine(
|
|
276
|
+
... "ws://localhost:8000/rpc",
|
|
277
|
+
... {"username": "root", "password": "root"},
|
|
278
|
+
... pool_size=5
|
|
279
|
+
... )
|
|
280
|
+
>>>
|
|
281
|
+
>>> # Use the engine to get connections
|
|
282
|
+
>>> async with engine() as db:
|
|
283
|
+
... await db.use("mydatabase", "mynamespace")
|
|
284
|
+
... people = await Person.insert(db, [
|
|
285
|
+
... Person(name="Alice"),
|
|
286
|
+
... Person(name="Bob")
|
|
287
|
+
... ])
|
|
288
|
+
|
|
289
|
+
Notes:
|
|
290
|
+
- The pool is not started automatically; you need to call pool.start()
|
|
291
|
+
- Connections are reused for better performance
|
|
292
|
+
- Failed connections are automatically replaced
|
|
293
|
+
- The pool should be closed when your application shuts down
|
|
294
|
+
|
|
295
|
+
See Also:
|
|
296
|
+
- SurrealClientPool: The underlying pool implementation
|
|
297
|
+
- Node.insert(): For batch operations using pooled connections
|
|
298
|
+
- Edge.relate(): For creating relationships using pooled connections
|
|
299
|
+
"""
|
|
300
|
+
pool = SurrealClientPool(url, auth_payload, pool_size, connect_timeout)
|
|
301
|
+
return pool.acquire
|