picopyn 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.
picopyn/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from .client import Client
2
+ from .connection import Connection
3
+ from .pool import Pool
4
+
5
+ __all__ = [
6
+ "Client",
7
+ "Connection",
8
+ "Pool",
9
+ ]
picopyn/client.py ADDED
@@ -0,0 +1,92 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+ import asyncpg
5
+
6
+ from .connection import Connection
7
+ from .pool import Pool
8
+
9
+
10
+ class Client:
11
+ """
12
+ Async client for managing connections to a picodata cluster using a connection pool.
13
+
14
+ This client handles connection pooling, automatic node discovery (if enabled),
15
+ and supports load balancing strategies for query distribution.
16
+
17
+ :param dsn (str): The data source name (e.g., "postgresql://user:pass@host:port") for the cluster.
18
+ :param balance_strategy (callable, optional): A custom strategy function to select a connection
19
+ from the pool. If None, round-robin strategy is used.
20
+ :param connect_kwargs: Additional keyword arguments passed to each connection.
21
+
22
+ Example:
23
+ >>> def random_strategy(connections):
24
+ ... import random
25
+ ... return random.choice(connections)
26
+
27
+ >>> client = Client(
28
+ ... dsn="postgresql://admin:pass@localhost:5432",
29
+ ... balance_strategy=random_strategy
30
+ ... )
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ dsn: str,
36
+ pool_size: int | None = None,
37
+ balance_strategy: Callable[[list[Connection]], Connection] | None = None,
38
+ **connect_kwargs: Any,
39
+ ) -> None:
40
+ self._pool = Pool(
41
+ dsn=dsn,
42
+ max_size=pool_size or 10,
43
+ enable_discovery=True,
44
+ balance_strategy=balance_strategy,
45
+ **connect_kwargs,
46
+ )
47
+
48
+ async def connect(self) -> None:
49
+ """
50
+ Prepares the client by connection connection pool.
51
+
52
+ This should be called before using the client to ensure connections are available.
53
+ """
54
+ await self._pool.connect()
55
+
56
+ async def execute(self, query: str, *args: Any) -> str:
57
+ """
58
+ Executes a query that does not return rows (e.g. INSERT, UPDATE, DELETE).
59
+
60
+ :param query: The SQL query string.
61
+ :param args: Optional parameters for the SQL query.
62
+ :return: The result of the query execution.
63
+ """
64
+ return await self._pool.execute(query, *args)
65
+
66
+ async def fetch(self, query: str, *args: Any) -> list[asyncpg.Record]:
67
+ """
68
+ Executes a query and fetches all resulting rows.
69
+
70
+ :param query: The SQL query string.
71
+ :param args: Optional parameters for the SQL query.
72
+ :return: A list of rows returned by the query.
73
+ """
74
+ return await self._pool.fetch(query, *args)
75
+
76
+ async def fetchrow(self, query: str, *args: Any) -> asyncpg.Record | None:
77
+ """
78
+ Executes a query and fetches a single row (first row).
79
+
80
+ :param query: The SQL query string.
81
+ :param args: Optional parameters for the SQL query.
82
+ :return: A single row returned by the query.
83
+ """
84
+ return await self._pool.fetchrow(query, *args)
85
+
86
+ async def close(self) -> None:
87
+ """
88
+ Closes all connections in the pool.
89
+
90
+ This should be called during application shutdown to clean up resources.
91
+ """
92
+ await self._pool.close()
picopyn/connection.py ADDED
@@ -0,0 +1,84 @@
1
+ from typing import Any
2
+
3
+ import asyncpg
4
+
5
+
6
+ class Connection:
7
+ """
8
+ A representation of a database session.
9
+
10
+ :param dsn (str): The data source name (e.g., "postgresql://user:pass@host:port") for the picodata node.
11
+ """
12
+
13
+ def __init__(self, dsn: str) -> None:
14
+ if dsn is None:
15
+ raise ValueError("dsn can not be None")
16
+ self.dsn = dsn
17
+ self.conn = None
18
+
19
+ async def connect(self) -> None:
20
+ """
21
+ Create new connection to Picodata
22
+ """
23
+
24
+ try:
25
+ self.conn = await asyncpg.connect(self.dsn)
26
+ except Exception as e:
27
+ raise RuntimeError(
28
+ f"Failed to connect to picodata instance using DSN {self.dsn}: {e}"
29
+ ) from e
30
+
31
+ async def execute(self, *args: Any, **kwargs: Any) -> str:
32
+ """
33
+ Execute an SQL command
34
+ """
35
+
36
+ if not self.conn:
37
+ raise OSError("No active connection. Try to call .connect() before.")
38
+
39
+ try:
40
+ return await self.conn.execute(*args, **kwargs)
41
+ except Exception as e:
42
+ raise RuntimeError(f"Failed to execute SQL query: {e}. Query: {args}") from e
43
+
44
+ async def fetchrow(self, *args: Any, **kwargs: Any) -> asyncpg.Record | None:
45
+ """
46
+ Run a query and return the first row.
47
+ """
48
+
49
+ if not self.conn:
50
+ raise OSError("No active connection. Try to call .connect() before")
51
+
52
+ try:
53
+ return await self.conn.fetchrow(*args, **kwargs)
54
+ except Exception as e:
55
+ raise RuntimeError(
56
+ f"Failed to execute SQL query and fetch row: {e}. Query: {args}"
57
+ ) from e
58
+
59
+ async def fetch(self, *args: Any, **kwargs: Any) -> list[asyncpg.Record]:
60
+ """
61
+ Run a query and return the results as a list.
62
+ """
63
+
64
+ if not self.conn:
65
+ raise OSError("No active connection. Try to call .connect() before")
66
+
67
+ try:
68
+ return await self.conn.fetch(*args, **kwargs)
69
+ except Exception as e:
70
+ raise RuntimeError(
71
+ f"Failed to execute SQL query and fetch result: {e}. Query: {args}"
72
+ ) from e
73
+
74
+ async def close(self, *args: Any, **kwargs: Any) -> None:
75
+ """
76
+ Close the connection gracefully.
77
+ """
78
+ if self.conn:
79
+ try:
80
+ return await self.conn.close(*args, **kwargs)
81
+ except Exception as e:
82
+ raise RuntimeError(
83
+ f"Failed to disconnect from picodata instance {self.dsn}: {e}"
84
+ ) from e
picopyn/pool.py ADDED
@@ -0,0 +1,272 @@
1
+ import asyncio
2
+ import json
3
+ import random
4
+ import time
5
+ from collections import deque
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ import asyncpg
11
+
12
+ from .connection import Connection
13
+
14
+
15
+ class Pool:
16
+ """A connection pool.
17
+
18
+ Connection pool can be used to manage a set of connections to the database.
19
+ Connections are first acquired from the pool, then used, and then released
20
+ back to the pool
21
+
22
+ :param dsn (str): The data source name (e.g., "postgresql://user:pass@host:port") for the cluster.
23
+ :param balance_strategy (callable, optional): A custom strategy function to select a connection
24
+ from the pool. If None, round-robin strategy is used.
25
+ :param max_size (int): Maximum number of connections in the pool. Must be at least 1.
26
+ :param enable_discovery (bool): If True, the pool will automatically discover available
27
+ picodata instances. If False, only the given `dsn` will be used.
28
+ :param balance_strategy (callable, optional): A function that selects a connection from the pool.
29
+ If None, a default round-robin strategy will be used.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ dsn: str,
35
+ max_size: int = 10,
36
+ enable_discovery: bool = False,
37
+ balance_strategy: Callable[[list[Connection]], Connection] | None = None,
38
+ **connect_kwargs: Any,
39
+ ) -> None:
40
+ if max_size < 1:
41
+ raise ValueError("max_size must be at least 1")
42
+
43
+ self._dsn = dsn
44
+ self._connect_kwargs = connect_kwargs
45
+ self._max_size = max_size
46
+ self._pool: deque[Connection] = deque()
47
+ self._used: set[Connection] = set()
48
+ self._lock: asyncio.Lock = asyncio.Lock()
49
+ self._default_acquire_timeout_sec = 5
50
+ # node discovery mode
51
+ # if disabled, pool will be filled with given address connections
52
+ # if enabled, pool will be filled with available picodata instances
53
+ self.enable_discovery = enable_discovery
54
+ # load balancing strategy:
55
+ # if None, a simple round-robin strategy will be used.
56
+ # otherwise, the provided callable will be used to select connections.
57
+ if balance_strategy is not None and not callable(balance_strategy):
58
+ raise ValueError("balance_strategy must be callable or None")
59
+ self._balance_strategy = balance_strategy
60
+
61
+ async def connect(self) -> None:
62
+ """
63
+ Prepares the pool by opening up to `max_size` connections.
64
+
65
+ This should be called before using the pool to ensure connections are available.
66
+ """
67
+ async with self._lock:
68
+ if len(self._pool) == self._max_size:
69
+ return
70
+
71
+ # if node discovery is enabled, then connect to all alive picodata instances
72
+ # (if they fit within the max_size limit)
73
+ if self.enable_discovery:
74
+ try:
75
+ instance_addrs = await self._discover_instances()
76
+ except Exception as e:
77
+ raise RuntimeError(
78
+ f"Failed to discover instances using DSN {self._dsn}: {e}"
79
+ ) from e
80
+
81
+ parsed_url = urlparse(self._dsn)
82
+
83
+ addr_index = 0
84
+ # fill the connection pool with connections to all available nodes, up to the max_size.
85
+ # this ensures the pool is evenly populated across all nodes.
86
+ # if a node fails to connect, it will be skipped and removed from the list.
87
+ # the loop will exit early if no nodes remain to avoid an infinite loop.
88
+ while len(self._pool) < self._max_size and instance_addrs:
89
+ address = instance_addrs[addr_index % len(instance_addrs)]
90
+ dsn = f"{parsed_url.scheme}://{parsed_url.username}:{parsed_url.password}@{address}"
91
+
92
+ try:
93
+ conn = Connection(dsn, **self._connect_kwargs)
94
+ await conn.connect()
95
+ self._pool.append(conn)
96
+ except Exception as e:
97
+ print(f"Could not connect to node {address} for pool: {e}")
98
+ instance_addrs.remove(address)
99
+ if not instance_addrs:
100
+ break
101
+ continue
102
+
103
+ addr_index += 1
104
+
105
+ # then fill the connection pool up to max_size with main mode connections
106
+ while len(self._pool) < self._max_size:
107
+ main_node_conn = Connection(self._dsn, **self._connect_kwargs)
108
+ try:
109
+ await main_node_conn.connect()
110
+ self._pool.append(main_node_conn)
111
+ except Exception as e:
112
+ raise RuntimeError(
113
+ f"Could not connect to main node {self._dsn} for pool: {e}"
114
+ ) from e
115
+
116
+ # rotate the pool to randomize the order of connections.
117
+ # this helps to distribute the initial load more evenly across nodes
118
+ # when using round-robin or when multiple clients start simultaneously.
119
+ shift = random.randint(0, len(self._pool) - 1)
120
+ self._pool.rotate(shift)
121
+
122
+ return
123
+
124
+ async def _discover_instances(self) -> list[str]:
125
+ # make temporary connection
126
+ temp_conn = Connection(self._dsn, **self._connect_kwargs)
127
+
128
+ try:
129
+ await temp_conn.connect()
130
+
131
+ # all instance addresses excluding connected node
132
+ alive_instances_info = await temp_conn.fetch(
133
+ """
134
+ WITH my_uuid AS (SELECT instance_uuid() AS uuid)
135
+ SELECT i.name, i.raft_id, i.current_state, p.address
136
+ FROM _pico_instance i
137
+ JOIN _pico_peer_address p ON i.raft_id = p.raft_id
138
+ JOIN my_uuid u ON 1 = 1
139
+ WHERE p.connection_type = 'pgproto' AND i.uuid != u.uuid;
140
+ """
141
+ )
142
+
143
+ online_addresses = []
144
+ # place connected node as first node to be sure that
145
+ # it will be in the pool independ on pool size
146
+ parsed_url = urlparse(self._dsn)
147
+ online_addresses.append(f"{parsed_url.hostname}:{parsed_url.port}")
148
+ for r in alive_instances_info:
149
+ if not r.get("current_state", None):
150
+ continue
151
+
152
+ try:
153
+ current_state = json.loads(r.get("current_state", None))
154
+ except json.JSONDecodeError:
155
+ print(
156
+ f"Failed to decode current state of picodata instance {r.get('current_state', None)}"
157
+ )
158
+ continue
159
+
160
+ if "Online" in current_state:
161
+ online_addresses.append(r["address"])
162
+
163
+ return online_addresses
164
+ finally:
165
+ await temp_conn.close()
166
+
167
+ async def acquire(self, timeout: float | None = None) -> Connection:
168
+ """
169
+ Acquire a connection from the pool.
170
+
171
+ If no connections are available, this method will wait until one is released.
172
+
173
+ :return: A database connection.
174
+ """
175
+ start_time = time.monotonic()
176
+ effective_timeout = timeout if timeout is not None else self._default_acquire_timeout_sec
177
+
178
+ while True:
179
+ async with self._lock:
180
+ # сheck if there are any available connections in the pool
181
+ if self._pool:
182
+ # round-robin strategy
183
+ if self._balance_strategy is None:
184
+ conn = self._pool.popleft()
185
+ # custom strategy
186
+ else:
187
+ try:
188
+ conn = self._balance_strategy(list(self._pool))
189
+ except Exception as e:
190
+ raise RuntimeError(f"balance_strategy raised an exception: {e}") from e
191
+
192
+ if conn not in self._pool:
193
+ raise RuntimeError("balance_strategy returned a connection not in pool")
194
+ self._pool.remove(conn)
195
+
196
+ # mark it as currently in use
197
+ self._used.add(conn)
198
+ return conn
199
+
200
+ if (time.monotonic() - start_time) >= effective_timeout:
201
+ raise TimeoutError("Timed out waiting for a free connection in the pool")
202
+
203
+ # if no connections are available, wait briefly before retrying
204
+ # this gives other coroutines (like `release`) a chance to return a connection to the pool
205
+ await asyncio.sleep(0.1)
206
+
207
+ async def release(self, conn: Connection) -> None:
208
+ """
209
+ Release a previously acquired connection back to the pool.
210
+
211
+ :param conn: The connection to release.
212
+ """
213
+ async with self._lock:
214
+ if conn in self._used:
215
+ self._used.remove(conn)
216
+ self._pool.append(conn)
217
+
218
+ async def close(self) -> None:
219
+ """
220
+ Closes all connections in the pool.
221
+
222
+ This should be called during application shutdown to clean up resources.
223
+ """
224
+ async with self._lock:
225
+ while self._pool:
226
+ conn = self._pool.popleft()
227
+ await conn.close()
228
+ for conn in self._used:
229
+ await conn.close()
230
+ self._used.clear()
231
+
232
+ async def execute(self, query: str, *args: Any) -> str:
233
+ """
234
+ Executes a query that does not return rows (e.g. INSERT, UPDATE, DELETE).
235
+
236
+ :param query: The SQL query string.
237
+ :param args: Optional parameters for the SQL query.
238
+ :return: The result of the query execution.
239
+ """
240
+ conn = await self.acquire()
241
+ try:
242
+ return await conn.execute(query, *args)
243
+ finally:
244
+ await self.release(conn)
245
+
246
+ async def fetch(self, query: str, *args: Any) -> list[asyncpg.Record]:
247
+ """
248
+ Executes a query and fetches all resulting rows.
249
+
250
+ :param query: The SQL query string.
251
+ :param args: Optional parameters for the SQL query.
252
+ :return: A list of rows returned by the query.
253
+ """
254
+ conn = await self.acquire()
255
+ try:
256
+ return await conn.fetch(query, *args)
257
+ finally:
258
+ await self.release(conn)
259
+
260
+ async def fetchrow(self, query: str, *args: Any) -> asyncpg.Record | None:
261
+ """
262
+ Executes a query and fetches a single row (first row).
263
+
264
+ :param query: The SQL query string.
265
+ :param args: Optional parameters for the SQL query.
266
+ :return: A single row returned by the query.
267
+ """
268
+ conn = await self.acquire()
269
+ try:
270
+ return await conn.fetchrow(query, *args)
271
+ finally:
272
+ await self.release(conn)
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: picopyn
3
+ Version: 0.1
@@ -0,0 +1,8 @@
1
+ picopyn/__init__.py,sha256=iS9NJS9kKcd6_s6s3lg7rOosRIxbMAc4EppPg5d7eK0,144
2
+ picopyn/client.py,sha256=ca7qhr2EHf63fiBEWoiTdqoQao4s0rwmXXjTAmq2BXQ,3070
3
+ picopyn/connection.py,sha256=Z-yqrCir2AKe4IOU7L4pkXeveJHtLnyRiIKFbTI43XA,2576
4
+ picopyn/pool.py,sha256=A2tdmtqYkAcM4HSkmPw7a3YQ4iCG4XfOH_ws47MMwmY,10882
5
+ picopyn-0.1.dist-info/METADATA,sha256=XvAyt9khfnh7B29JGAg59rG9tTrjxMiEuENjkUChBj0,49
6
+ picopyn-0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ picopyn-0.1.dist-info/top_level.txt,sha256=MAN9e0p63X638jEg5MflC2jOKv2hVMhZ0UuKvE8xjF8,8
8
+ picopyn-0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ picopyn