picopyn 2.0.0.dev1__tar.gz → 2.1.0__tar.gz

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.
Files changed (75) hide show
  1. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/AUTHORS.md +2 -1
  2. {picopyn-2.0.0.dev1/picopyn.egg-info → picopyn-2.1.0}/PKG-INFO +13 -12
  3. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/README.md +12 -11
  4. picopyn-2.1.0/docs/css/deprecated.css +21 -0
  5. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_pool_init_discovery_disabled.md +1 -1
  6. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_pool_init_discovery_enabled.md +1 -1
  7. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_pool_lifecycle.md +4 -4
  8. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_query_metadata_pool_init.md +2 -2
  9. picopyn-2.1.0/docs/diagrams/_rebalance_pool_after_reconcile.md +50 -0
  10. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_reconcile_pool_with_topology.md +12 -4
  11. picopyn-2.1.0/docs/diagrams/_reconcile_rebalance.md +45 -0
  12. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_topology_tracking.md +1 -1
  13. picopyn-2.1.0/docs/explain.md +21 -0
  14. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/index.md +13 -10
  15. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/installation.md +1 -1
  16. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/sync.md +4 -4
  17. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/asynchronous/client.py +9 -1
  18. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/asynchronous/connection.py +45 -9
  19. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/asynchronous/pool.py +518 -84
  20. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/asynchronous/topology_tracker.py +7 -7
  21. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/bucket.py +67 -4
  22. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/explain.py +160 -1
  23. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/synchronous/__init__.py +8 -1
  24. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/synchronous/client.py +8 -0
  25. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/synchronous/connection.py +14 -0
  26. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/synchronous/pool.py +35 -35
  27. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/topology.py +13 -8
  28. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/utils/__init__.py +8 -3
  29. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/utils/discovery.py +16 -3
  30. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/utils/dsn.py +28 -0
  31. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/utils/queries.py +1 -8
  32. {picopyn-2.0.0.dev1 → picopyn-2.1.0/picopyn.egg-info}/PKG-INFO +13 -12
  33. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn.egg-info/SOURCES.txt +3 -0
  34. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/pyproject.toml +1 -1
  35. picopyn-2.0.0.dev1/docs/explain.md +0 -11
  36. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/LICENSE +0 -0
  37. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/MANIFEST.in +0 -0
  38. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/benchmark/README.md +0 -0
  39. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/benchmark/bench.py +0 -0
  40. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/benchmark/examples/10k_rows_1_thread_1_batch/overall_speed_time.png +0 -0
  41. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/benchmark/examples/10k_rows_1_thread_1_batch/threads_speed_time.png +0 -0
  42. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/benchmark/examples/1m_rows_10_threads_100_batch/errors_cumulative.png +0 -0
  43. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/benchmark/examples/1m_rows_10_threads_100_batch/overall_speed_time.png +0 -0
  44. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/benchmark/examples/1m_rows_10_threads_100_batch/threads_speed_time.png +0 -0
  45. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/benchmark/generate_data_to_csv.py +0 -0
  46. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/async.md +0 -0
  47. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/async_internal.md +0 -0
  48. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/bucket.md +0 -0
  49. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/changelog.md +0 -0
  50. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/contributing.md +0 -0
  51. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_pool_init.md +0 -0
  52. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_query_metadata_usage_lookup.md +0 -0
  53. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_reconcile_node_outside_pool_died.md +0 -0
  54. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_reconcile_pooled_node_died_size_eq.md +0 -0
  55. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_reconcile_pooled_node_died_size_gt.md +0 -0
  56. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_reconcile_pooled_node_died_size_lt.md +0 -0
  57. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_request_if_missing.md +0 -0
  58. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_topology_apply_bucket_event.md +0 -0
  59. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_topology_apply_bucket_replicaset_event.md +0 -0
  60. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_topology_components.md +0 -0
  61. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/diagrams/_topology_refresh.md +0 -0
  62. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/query_metadata.md +0 -0
  63. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/docs/topology.md +0 -0
  64. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/__init__.py +0 -0
  65. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/asynchronous/__init__.py +0 -0
  66. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/asynchronous/metadata_service.py +0 -0
  67. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/query_metadata.py +0 -0
  68. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/ssl.py +0 -0
  69. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/synchronous/cursor.py +0 -0
  70. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/synchronous/errors.py +0 -0
  71. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn/utils/topology.py +0 -0
  72. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn.egg-info/dependency_links.txt +0 -0
  73. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn.egg-info/requires.txt +0 -0
  74. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/picopyn.egg-info/top_level.txt +0 -0
  75. {picopyn-2.0.0.dev1 → picopyn-2.1.0}/setup.cfg +0 -0
@@ -2,4 +2,5 @@
2
2
 
3
3
  - Asya Lomakina <a.lomakina@picodata.io>
4
4
  - Igor Kuznetsov <i.kuznetsov@picodata.io>
5
- - Dmitriy Koltsov <dkoltsov@picodata.io>
5
+ - Dmitriy Koltsov <dkoltsov@picodata.io>
6
+ - Daniil Gorlyakov <d.gorlyakov@picodata.io>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: picopyn
3
- Version: 2.0.0.dev1
3
+ Version: 2.1.0
4
4
  Summary: Python connector for working with the distributed Picodata database.
5
5
  Author-email: Asya Lomakina <a.lomakina@picodata.io>, Igor Kuznetsov <i.kuznetsov@picodata.io>, Dmitriy Koltsov <dkoltsov@picodata.io>, Daniil Gorlyakov <d.gorlyakov@picodata.io>
6
6
  License-Expression: BSD-2-Clause
@@ -55,7 +55,7 @@ Dynamic: license-file
55
55
  | >=25.2.1, <25.4.4 | 0.1.1 |
56
56
  | >=25.4.4, <25.5.1 | 0.2.0 |
57
57
  | >=25.5.1, <26.1.x | 1.0.0 |
58
- | >=26.1.1, <26.2.x | 2.0.0 |
58
+ | >=26.1.1, <26.2.x | 2.0.0, 2.1.0 |
59
59
  | 26.2.x | ⚠️ Unreleased yet |
60
60
 
61
61
  ## Features
@@ -64,8 +64,9 @@ Dynamic: license-file
64
64
  * Pluggable load-balancing strategies
65
65
  * Asynchronous API based on [asyncpg](https://github.com/MagicStack/asyncpg)
66
66
  * (async only) Shard-aware routing
67
- * (async only) Automatic pool membership reconciliation
68
- * Synchronous [DPAPI-compatible](https://peps.python.org/pep-0249/) API based on [psycopg](https://www.psycopg.org/)
67
+ * (async only) Automatic pool membership reconciliation, including rebalancing a
68
+ full pool across the nodes
69
+ * Synchronous [DB-API 2.0-compatible](https://peps.python.org/pep-0249/) API based on [psycopg](https://www.psycopg.org/)
69
70
 
70
71
  ## Navigation
71
72
  * [Installation](#installation)
@@ -97,24 +98,24 @@ Async:
97
98
 
98
99
  ```python
99
100
  import asyncio
100
- from picopyn.asynchronous import Client
101
+ from picopyn.asynchronous import Pool
101
102
 
102
103
  async def main():
103
- # create and connect client to the picodata cluster
104
- client = Client(dsn="postgresql://admin:pass@localhost:5432")
105
- await client.connect()
104
+ # create and open pool to the picodata cluster
105
+ pool = Pool(dsn="postgresql://admin:pass@localhost:5432", enable_discovery=True)
106
+ await pool.open()
106
107
 
107
108
  # execute DDL operations
108
- await client.execute('''
109
+ await pool.execute('''
109
110
  CREATE TABLE "warehouse" (id INTEGER NOT NULL, item TEXT NOT NULL, PRIMARY KEY (id)) USING memtx DISTRIBUTED BY (id) OPTION (TIMEOUT = 3.0);
110
111
  ''')
111
112
 
112
113
  # execute DML/DQL operations
113
- await client.execute('INSERT INTO "warehouse" VALUES ($1::int, $2::varchar)', 1, "test")
114
- rows = await client.fetch('SELECT * FROM "warehouse"')
114
+ await pool.execute('INSERT INTO "warehouse" VALUES ($1::int, $2::varchar)', 1, "test")
115
+ rows = await pool.fetch('SELECT * FROM "warehouse"')
115
116
  print(rows)
116
117
 
117
- await client.close()
118
+ await pool.close()
118
119
 
119
120
  asyncio.run(main())
120
121
  ```
@@ -12,7 +12,7 @@
12
12
  | >=25.2.1, <25.4.4 | 0.1.1 |
13
13
  | >=25.4.4, <25.5.1 | 0.2.0 |
14
14
  | >=25.5.1, <26.1.x | 1.0.0 |
15
- | >=26.1.1, <26.2.x | 2.0.0 |
15
+ | >=26.1.1, <26.2.x | 2.0.0, 2.1.0 |
16
16
  | 26.2.x | ⚠️ Unreleased yet |
17
17
 
18
18
  ## Features
@@ -21,8 +21,9 @@
21
21
  * Pluggable load-balancing strategies
22
22
  * Asynchronous API based on [asyncpg](https://github.com/MagicStack/asyncpg)
23
23
  * (async only) Shard-aware routing
24
- * (async only) Automatic pool membership reconciliation
25
- * Synchronous [DPAPI-compatible](https://peps.python.org/pep-0249/) API based on [psycopg](https://www.psycopg.org/)
24
+ * (async only) Automatic pool membership reconciliation, including rebalancing a
25
+ full pool across the nodes
26
+ * Synchronous [DB-API 2.0-compatible](https://peps.python.org/pep-0249/) API based on [psycopg](https://www.psycopg.org/)
26
27
 
27
28
  ## Navigation
28
29
  * [Installation](#installation)
@@ -54,24 +55,24 @@ Async:
54
55
 
55
56
  ```python
56
57
  import asyncio
57
- from picopyn.asynchronous import Client
58
+ from picopyn.asynchronous import Pool
58
59
 
59
60
  async def main():
60
- # create and connect client to the picodata cluster
61
- client = Client(dsn="postgresql://admin:pass@localhost:5432")
62
- await client.connect()
61
+ # create and open pool to the picodata cluster
62
+ pool = Pool(dsn="postgresql://admin:pass@localhost:5432", enable_discovery=True)
63
+ await pool.open()
63
64
 
64
65
  # execute DDL operations
65
- await client.execute('''
66
+ await pool.execute('''
66
67
  CREATE TABLE "warehouse" (id INTEGER NOT NULL, item TEXT NOT NULL, PRIMARY KEY (id)) USING memtx DISTRIBUTED BY (id) OPTION (TIMEOUT = 3.0);
67
68
  ''')
68
69
 
69
70
  # execute DML/DQL operations
70
- await client.execute('INSERT INTO "warehouse" VALUES ($1::int, $2::varchar)', 1, "test")
71
- rows = await client.fetch('SELECT * FROM "warehouse"')
71
+ await pool.execute('INSERT INTO "warehouse" VALUES ($1::int, $2::varchar)', 1, "test")
72
+ rows = await pool.fetch('SELECT * FROM "warehouse"')
72
73
  print(rows)
73
74
 
74
- await client.close()
75
+ await pool.close()
75
76
 
76
77
  asyncio.run(main())
77
78
  ```
@@ -0,0 +1,21 @@
1
+ /* mkdocstrings renders a docstring "Deprecated:" section as <details class="deprecated">.
2
+ That class is not part of Material's admonition set, so it falls back to the neutral
3
+ blue "note" look and deprecations read like ordinary remarks. Paint it as a warning. */
4
+
5
+ .md-typeset details.deprecated {
6
+ border-color: #ff9100;
7
+ }
8
+
9
+ .md-typeset details.deprecated:focus-within {
10
+ box-shadow: 0 0 0 0.2rem #ff91001a;
11
+ }
12
+
13
+ .md-typeset details.deprecated > summary {
14
+ background-color: #ff91001a;
15
+ }
16
+
17
+ .md-typeset details.deprecated > summary::before {
18
+ background-color: #ff9100;
19
+ -webkit-mask-image: var(--md-admonition-icon--warning);
20
+ mask-image: var(--md-admonition-icon--warning);
21
+ }
@@ -17,7 +17,7 @@ ERROR([Initialization failed]):::error
17
17
  CFG[/"Pool settings<br>· DSN string<br>· pool size<br>· forbidden tiers<br>· cluster discovery disabled"/]
18
18
 
19
19
  GENERAL_DETAILS_NOTE>"Return to general algorithm"]:::note
20
- click GENERAL_DETAILS_NOTE "#picopyn.asynchronous.Pool.connect" "Return to general algorithm"
20
+ click GENERAL_DETAILS_NOTE "#picopyn.asynchronous.Pool.open" "Return to general algorithm"
21
21
 
22
22
  %% === Data objects ===
23
23
  B_DSN[[DSN node list]]
@@ -18,7 +18,7 @@ ERROR([Initialization failed]):::error
18
18
  CFG[/"Pool settings<br>· DSN string<br>· pool_size<br>· forbidden tiers<br>· cluster discovery enabled"/]
19
19
 
20
20
  GENERAL_DETAILS_NOTE>"Return to general algorithm"]:::note
21
- click GENERAL_DETAILS_NOTE "#picopyn.asynchronous.Pool.connect" "Return to general algorithm"
21
+ click GENERAL_DETAILS_NOTE "#picopyn.asynchronous.Pool.open" "Return to general algorithm"
22
22
 
23
23
  %% === Data objects ===
24
24
  D_DSN[[DSN node list]]
@@ -17,14 +17,14 @@ DONE([Pool closed]):::neutral
17
17
 
18
18
  CFG[/"Pool settings<br>· DSN<br>· pool size<br>· forbidden tiers<br>· cluster discovery mode"/]
19
19
 
20
- START --> CFG --> CREATE["Pool.__init__"] --> CONNECT["pool.connect()"] --> UsePool
20
+ START --> CFG --> CREATE["Pool.__init__"] --> CONNECT["pool.open()"] --> UsePool
21
21
  CONNECT <-. write .-> POOL
22
22
 
23
- CONNECT_DETAILS_NOTE>"Connect algorithm details"]:::note
24
- click CONNECT_DETAILS_NOTE "#picopyn.asynchronous.Pool.connect" "Connect algorithm details"
23
+ CONNECT_DETAILS_NOTE>"Open algorithm details"]:::note
24
+ click CONNECT_DETAILS_NOTE "#picopyn.asynchronous.Pool.open" "Open algorithm details"
25
25
  CONNECT_DETAILS_NOTE -.-> CONNECT
26
26
 
27
- RECONCILE_NOTE>"(discovery mode only):<br>pool membership changes<br>in the background:<br>connections are opened/closed<br>to match topology changes"]:::note
27
+ RECONCILE_NOTE>"pool membership changes<br>in the background:<br>connections are opened/closed<br>to match topology changes<br>(discovery mode: any cluster node,<br>bootstrap mode: DSN nodes only)"]:::note
28
28
  click RECONCILE_NOTE "#picopyn.asynchronous.Pool.topology" "Reconcile algorithm details"
29
29
  RECONCILE_NOTE -.-> POOL
30
30
 
@@ -15,8 +15,8 @@ INIT --> CACHE_EMPTY["Create empty query meta cache"] --> CONNECT["Fill the pool
15
15
  NOTE_CONN>"connection options require<br>statement invalidation signal only"]:::note
16
16
  NOTE_CONN -.-> CONNECT
17
17
 
18
- CONNECT_DETAILS_NOTE>"Connect algorithm details"]:::note
19
- click CONNECT_DETAILS_NOTE "#picopyn.asynchronous.Pool.connect" "Connect algorithm details"
18
+ CONNECT_DETAILS_NOTE>"Open algorithm details"]:::note
19
+ click CONNECT_DETAILS_NOTE "#picopyn.asynchronous.Pool.open" "Open algorithm details"
20
20
  CONNECT_DETAILS_NOTE -.-> CONNECT
21
21
 
22
22
  subgraph Pool1["Pool"]
@@ -0,0 +1,50 @@
1
+ ```mermaid
2
+ ---
3
+ title: Rebalance pool after reconcile
4
+ ---
5
+ flowchart TD
6
+
7
+ classDef neutral fill:#f5f5f5,stroke:#9e9e9e,color:#424242
8
+ classDef note fill:#fffde7,stroke:#f9a825,stroke-dasharray:4,color:#555
9
+ classDef success fill:#e8f5e9,stroke:#4caf50,color:#1b5e20
10
+
11
+ START(["START"]):::neutral --> COUNT
12
+
13
+ COUNT_DETAILS_NOTE>"connections already marked<br>to evict are not counted:<br>they are closed on<br>release and refilled elsewhere"]:::note
14
+ COUNT_DETAILS_NOTE -.-> COUNT
15
+
16
+ COUNT_DETAILS_NOTE2>"candidate nodes are<br>online Picodata nodes<br>from allowed tiers"]:::note
17
+ COUNT_DETAILS_NOTE2 -.-> COUNT
18
+
19
+ COUNT["count free and in-use pool<br>connections per candidate uuid"] --> GAP
20
+
21
+ GAP_DETAILS_NOTE>"a gap of one<br>is not an imbalance"]:::note
22
+ GAP_DETAILS_NOTE -.-> GAP
23
+
24
+ GAP{"gap between<br>max count and min count<br>(most overloaded and<br>most underloaded nodes)<br>>= 2?"}
25
+ GAP -->|no| POOL_OK(["pool is balanced<br>(exit)"]):::success
26
+ GAP -->|yes| FREE{"is there a free connection<br>to the overloaded uuid?"}
27
+
28
+ FREE -->|no| EVICT_USED["mark one in-use connection<br>to the overloaded uuid to evict"]
29
+ FREE -->|yes| MIGRATE["migrate one connection:<br>take the free one out, connect<br>to the underloaded uuid,<br>close the old one"]
30
+
31
+ EVICT_DETAILS_NOTE>"release closes it, and the next<br>refresh refills the free slot to<br>the underloaded uuid"]:::note
32
+ EVICT_DETAILS_NOTE -.-> EVICT_USED
33
+
34
+ EVICT_USED --> POOL_OK3(["(exit)"]):::success
35
+
36
+ MIGRATE -->|migrated| LIMIT
37
+ MIGRATE -->|"could not connect to<br>the underloaded uuid"| EXCLUDE["put the connection back and<br>exclude the underloaded uuid<br>from the candidate list<br>in this reconcile"]
38
+ EXCLUDE --> LIMIT
39
+
40
+ LIMIT{"migration limit for<br>this reconcile reached?"}
41
+ LIMIT -->|no| GAP
42
+ LIMIT -->|yes| POOL_OK4(["(exit)"]):::success
43
+
44
+ LIMIT_DETAILS_NOTE>"the limit is proportional to<br>the pool size, see<br>Pool.rebalance_pool_divisor"]:::note
45
+ click LIMIT_DETAILS_NOTE "#picopyn.asynchronous.Pool" "Pool.rebalance_pool_divisor"
46
+ LIMIT_DETAILS_NOTE -.-> LIMIT
47
+
48
+ LIMIT_DETAILS_NOTE2>"the rest is left to<br>the next refresh"]:::note
49
+ LIMIT_DETAILS_NOTE2 -.-> POOL_OK4
50
+ ```
@@ -10,7 +10,6 @@ classDef success fill:#e8f5e9,stroke:#4caf50,color:#1b5e20
10
10
  classDef error fill:#ffebee,stroke:#f44336,color:#b71c1c
11
11
 
12
12
  subgraph Pool["Pool"]
13
- Settings[/"forbidden tiers=[...]<br>max size=N"/]
14
13
  FreeConns["free conns=[...]"]
15
14
  UsedConns["used conns=[...]"]
16
15
  Topology[/"Topology"/]
@@ -25,14 +24,16 @@ TOPOLOGY_DETAILS_NOTE>"Topology details"]:::note
25
24
  click TOPOLOGY_DETAILS_NOTE "../topology/#picopyn.topology.Topology" "Topology details"
26
25
  TOPOLOGY_DETAILS_NOTE -.-> TOPOLOGY_APPLY
27
26
 
28
- TOPOLOGY_APPLY --> GET_LIST["build candidate_list from Topology:<br>- uuids with allowed tiers<br>- online uuids"]
27
+ TOPOLOGY_APPLY --> COMPLETE{"topology has both<br>instances and replicasets?"}
28
+ COMPLETE -->|no| NOT_TRUSTED(["snapshot is not trustworthy:<br>pool is left as is<br>(exit)"]):::error
29
+ COMPLETE -->|yes| GET_LIST["build candidate_list from Topology:<br>- uuids with allowed tiers<br>- online uuids<br>- (bootstrap mode) only<br>DSN node addresses"]
29
30
  GET_LIST --> REMOVE["close all connections whose<br>uuid not in candidate_list"] --> CHECK_SIZE_START
30
31
 
31
32
  REMOVE -.-> FreeConns
32
33
  REMOVE -.-> UsedConns
33
34
 
34
35
  CHECK_SIZE_START{"current pool size < max size?"}
35
- CHECK_SIZE_START -->|no| POOL_OK(["Pool okay<br>(exit)"]):::success
36
+ CHECK_SIZE_START -->|no| REBALANCE
36
37
  CHECK_SIZE_START -->|yes| MIN["choose uuid from candidate_list<br>with min connections count"]
37
38
 
38
39
  MIN -->|got candidate| ADD_CONN["try to connect to candidate<br>and add connection to pool..."]
@@ -41,6 +42,13 @@ MIN -->|no candidate| NO_CANDIDATE2(["pool is temporarily not<br>full until next
41
42
  ADD_CONN -->|connection failed| EXCLUDE["exclude the candidate uuid<br/>from list in this loop"] --> CHECK_SIZE_LOOP
42
43
  ADD_CONN-->|connected| CHECK_SIZE_LOOP{"current size < max size?"}
43
44
 
44
- CHECK_SIZE_LOOP -->|no| POOL_OK2(["Pool okay<br>(exit)"]):::success
45
+ CHECK_SIZE_LOOP -->|no| REBALANCE
45
46
  CHECK_SIZE_LOOP -->|yes| MIN
47
+
48
+ REBALANCE["rebalance the pool:<br>move connections from overloaded<br>nodes to underloaded ones"]
49
+ REBALANCE --> POOL_OK(["Pool okay<br>(exit)"]):::success
50
+
51
+ REBALANCE_DETAILS_NOTE>"Rebalance details"]:::note
52
+ click REBALANCE_DETAILS_NOTE "#picopyn.asynchronous.Pool.topology--rebalance" "Rebalance details"
53
+ REBALANCE_DETAILS_NOTE -.-> REBALANCE
46
54
  ```
@@ -0,0 +1,45 @@
1
+ ```mermaid
2
+ ---
3
+ title: (Rebalance) a pooled node returned to Online (pool is already full)
4
+ ---
5
+
6
+ flowchart TD
7
+
8
+ classDef offline fill:#ffebee,stroke:#f44336,color:#b71c1c
9
+ classDef online fill:#e8f5e9,stroke:#4caf50,color:#1b5e20
10
+ classDef neutral fill:#f5f5f5,stroke:#9e9e9e,color:#424242
11
+
12
+ classDef note fill:#fffde7,stroke:#f9a825,stroke-dasharray:4,color:#555
13
+
14
+ subgraph before["Before"]
15
+ subgraph Picodata_before["Picodata"]
16
+ p1[("ins 1")]:::online
17
+ p2[("ins 2")]:::online
18
+ p3[("ins 3")]:::offline
19
+ end
20
+
21
+ subgraph Pool_before["Pool: max_size=6"]
22
+ c1["conn x3"] --> p1
23
+ c2["conn x3"] --> p2
24
+ end
25
+ end
26
+ EVENT1[/"Event<br>Instance 3 back Online"/]:::neutral -.-> REBALANCE{{"reconcile pool"}}
27
+ before -.-> REBALANCE
28
+ NOTE>"Pool is full, so refill<br>does nothing.<br>Initial connection counts:<br>[3/3/0], gap=3<br>=> migrate: [2/3/1], gap=2<br>=> migrate: [2/2/2], gap=0<br>Pool rebalanced in 2 refreshes"]:::note
29
+ NOTE -.-> REBALANCE
30
+ REBALANCE --> after
31
+
32
+ subgraph after["After (2 refreshes)"]
33
+ subgraph Picodata_after["Picodata"]
34
+ p21[("ins 1")]:::online
35
+ p22[("ins 2")]:::online
36
+ p23[("ins 3")]:::online
37
+ end
38
+
39
+ subgraph Pool_after["Pool: max_size=6"]
40
+ c21["conn x2"] --> p21
41
+ c22["conn x2"] --> p22
42
+ c23["conn x2"] --> p23
43
+ end
44
+ end
45
+ ```
@@ -12,7 +12,7 @@ click ADR_DETAILS_NOTE "https://git.picodata.io/core/picodata/-/blob/master/doc/
12
12
 
13
13
  TOPOLOGY[[Topology<br>·instances<br>·replicasets<br>·buckets<br>·tiers]]
14
14
 
15
- CONNECT["Pool.connect()"] --> CFG[/"Topology tracker settings<br>· update interval"/] --> START["start topology tracker"] --> INITIAL["initial refresh<br>(sync, imitate ReadyForQuery)"] --> WAIT
15
+ CONNECT["Pool.open()"] --> CFG[/"Topology tracker settings<br>· update interval"/] --> START["start topology tracker"] --> INITIAL["initial refresh<br>(sync, imitate ReadyForQuery)"] --> WAIT
16
16
  INITAL_DETAILS_NOTE>"Picodata doesn't send it yet,<br>but it's planned: Picodata will send<br>ReadyForQuery once the initial snapshot<br>is fully delivered -- meaning our<br>topology view is consistent"]:::note
17
17
 
18
18
  INITAL_DETAILS_NOTE -.-> INITIAL
@@ -0,0 +1,21 @@
1
+ # Explain
2
+
3
+ ::: picopyn.explain
4
+ options:
5
+ show_root_heading: false
6
+ show_root_toc_entry: false
7
+ heading_level: 1
8
+ members: []
9
+
10
+ ## API Reference
11
+
12
+ ::: picopyn.explain
13
+ options:
14
+ show_root_heading: false
15
+ show_root_toc_entry: false
16
+ heading_level: 3
17
+ members_order: source
18
+ extra:
19
+ hide_docstring: true
20
+ filters:
21
+ - "!^_"
@@ -1,3 +1,6 @@
1
+ [![Version](https://img.shields.io/pypi/v/picopyn.svg?maxAge=86400)](https://pypi.org/project/picopyn/)
2
+ [![Supported Versions](https://img.shields.io/pypi/pyversions/picopyn.svg)](https://pypi.org/project/picopyn)
3
+
1
4
  # picopyn
2
5
 
3
6
  **picopyn** is a Python connector for working with the distributed [Picodata](https://picodata.io/en/) database.
@@ -9,7 +12,7 @@
9
12
  | >=25.2.1, <25.4.4 | 0.1.1 |
10
13
  | >=25.4.4, <25.5.1 | 0.2.0 |
11
14
  | >=25.5.1, <26.1.x | 1.0.0 |
12
- | >=26.1.1, <26.2.x | 2.0.0 |
15
+ | >=26.1.1, <26.2.x | 2.0.0, 2.1.0 |
13
16
  | 26.2.x | ⚠️ Unreleased yet |
14
17
 
15
18
  ## Features
@@ -19,8 +22,8 @@
19
22
  * Pluggable load-balancing strategies
20
23
  * Asynchronous API based on [asyncpg](https://github.com/MagicStack/asyncpg)
21
24
  * (async only) [Shard-aware routing][picopyn.asynchronous.pool.Pool.execute]
22
- * (async only) Automatic [pool membership reconciliation][picopyn.asynchronous.pool.Pool.topology] according to picodata topology
23
- * Synchronous [DPAPI-compatible](https://peps.python.org/pep-0249/) API based on [psycopg](https://www.psycopg.org/)
25
+ * (async only) Automatic [pool membership reconciliation][picopyn.asynchronous.pool.Pool.topology] according to picodata topology, including rebalancing a full pool across the nodes
26
+ * Synchronous [DB-API 2.0-compatible](https://peps.python.org/pep-0249/) API based on [psycopg](https://www.psycopg.org/)
24
27
 
25
28
  ## Quick start
26
29
 
@@ -32,22 +35,22 @@ Async:
32
35
 
33
36
  ```python
34
37
  import asyncio
35
- from picopyn.asynchronous import Client
38
+ from picopyn.asynchronous import Pool
36
39
 
37
40
  async def main():
38
- client = Client(dsn="postgresql://admin:pass@localhost:5432")
39
- await client.connect()
41
+ pool = Pool(dsn="postgresql://admin:pass@localhost:5432", enable_discovery=True)
42
+ await pool.open()
40
43
 
41
- await client.execute('''
44
+ await pool.execute('''
42
45
  CREATE TABLE "warehouse" (id INTEGER NOT NULL, item TEXT NOT NULL, PRIMARY KEY (id))
43
46
  USING memtx DISTRIBUTED BY (id) OPTION (TIMEOUT = 3.0);
44
47
  ''')
45
48
 
46
- await client.execute('INSERT INTO "warehouse" VALUES ($1::int, $2::varchar)', 1, "test")
47
- rows = await client.fetch('SELECT * FROM "warehouse"')
49
+ await pool.execute('INSERT INTO "warehouse" VALUES ($1::int, $2::varchar)', 1, "test")
50
+ rows = await pool.fetch('SELECT * FROM "warehouse"')
48
51
  print(rows)
49
52
 
50
- await client.close()
53
+ await pool.close()
51
54
 
52
55
  asyncio.run(main())
53
56
  ```
@@ -4,7 +4,7 @@
4
4
 
5
5
  - Python 3.10+
6
6
 
7
- ## Install from PyPI
7
+ ## Install from [PyPI](https://pypi.org/project/picopyn/)
8
8
 
9
9
  ```sh
10
10
  pip install picopyn
@@ -2,10 +2,10 @@
2
2
 
3
3
  ::: picopyn.synchronous
4
4
  options:
5
- members:
6
- - connect
7
- - Connection
8
- - Cursor
5
+ filters:
6
+ - "!^_[^_]"
7
+ - "!Error$"
8
+ - "!^Warning$"
9
9
 
10
10
  ### Errors
11
11
 
@@ -2,6 +2,7 @@ from collections.abc import Callable
2
2
  from typing import Any
3
3
 
4
4
  import asyncpg
5
+ from typing_extensions import deprecated
5
6
 
6
7
  from picopyn.explain import ExplainPlan, ExplainRawPlan
7
8
 
@@ -9,9 +10,16 @@ from .connection import Connection
9
10
  from .pool import Pool
10
11
 
11
12
 
13
+ @deprecated(
14
+ "picopyn.asynchronous.Client is deprecated and will be removed in the next major release, use picopyn.asynchronous.Pool instead"
15
+ )
12
16
  class Client:
13
17
  """Async client for managing connections to a picodata cluster using a connection pool.
14
18
 
19
+ Deprecated:
20
+ This class will be removed in the next major release.
21
+ Use [picopyn.asynchronous.Pool][picopyn.asynchronous.Pool] instead.
22
+
15
23
  This client handles connection pooling, automatic cluster node discovery,
16
24
  and supports load balancing strategies for query distribution. See details about [Pool][picopyn.asynchronous.Pool].
17
25
 
@@ -94,7 +102,7 @@ class Client:
94
102
  await client.connect()
95
103
  ```
96
104
  """
97
- await self._pool.connect()
105
+ await self._pool.open()
98
106
 
99
107
  async def execute(self, query: str, *args: Any) -> str:
100
108
  """Executes a query that does not return rows (e.g. INSERT, UPDATE, DELETE).
@@ -21,29 +21,54 @@ from picopyn.query_metadata import (
21
21
  parse_query_metadata_notice,
22
22
  )
23
23
  from picopyn.ssl import validate_ssl_source
24
- from picopyn.utils import _redact_dsn
24
+ from picopyn.utils import _dsn_options, _redact_dsn
25
25
 
26
26
 
27
27
  def _with_picodata_startup_options(
28
- connect_kwargs: dict[str, Any], *, request_query_metadata: bool
28
+ connect_kwargs: dict[str, Any], dsn: str, *, request_query_metadata: bool
29
29
  ) -> dict[str, Any]:
30
30
  """Append Picodata-specific startup options to `server_settings["options"]`,
31
- without discarding options the caller already set.
31
+ without discarding options the caller already set in `connect_kwargs` or in the DSN.
32
+
33
+ Both `connect_kwargs` and the DSN can carry an `options` value. asyncpg merges the
34
+ DSN query params into `server_settings` and resolves the conflict per key in favor of
35
+ `connect_kwargs`, so a `server_settings["options"]` passed as a keyword argument
36
+ replaces the DSN one entirely.
37
+
38
+ In the example below `sql_vdbe_opcode_max` never reaches the server -- only the
39
+ Picodata options do:
40
+ ```python
41
+ dsn = "postgresql://user:pass@host1:5432/db?options=sql_vdbe_opcode_max%3D1"
42
+ asyncpg.connect(dsn, server_settings={"options": picodata_options})
43
+ ```
44
+
45
+ So, to keep the caller's options and the driver functionality:
46
+
47
+ - if `connect_kwargs` already sets `server_settings["options"]`, the Picodata options
48
+ are appended to it -- the DSN `options` would be discarded by asyncpg anyway;
49
+ - otherwise the value is taken from the DSN, so it survives the keyword argument this
50
+ function builds;
51
+ - the Picodata options are appended last on purpose => even if the caller tries to turn
52
+ it off, the last value of a repeated option is kept.
32
53
 
33
54
  Args:
34
- connect_kwargs: Keyword arguments originally passed to [connect][picopyn.asynchronous.connection.Connection.connect].
55
+ connect_kwargs: Keyword arguments originally passed to
56
+ [connect][picopyn.asynchronous.connection.Connection.connect].
57
+ dsn: The DSN the connection is about to be opened with; only its `options` query
58
+ parameter is read.
35
59
  request_query_metadata: Whether to also [request query metadata][picopyn.query_metadata.QUERY_METADATA_OPTION],
36
- in addition to [statements invalidation option][picopyn.query_metadata.STMT_INVALIDATION_OPTION] (always requested).
60
+ in addition to [statements invalidation option][picopyn.query_metadata.STMT_INVALIDATION_OPTION]
61
+ (always requested).
37
62
 
38
63
  Returns:
39
- A copy of the original arguments with the merged Picodata pgproto options.
64
+ A copy of `connect_kwargs` with the merged Picodata pgproto options.
40
65
  """
41
66
  options = STMT_INVALIDATION_OPTION
42
67
  if request_query_metadata:
43
68
  options = f"{options},{QUERY_METADATA_OPTION}"
44
69
 
45
70
  server_settings = dict(connect_kwargs.get("server_settings") or {})
46
- existing_options = server_settings.get("options")
71
+ existing_options = server_settings.get("options") or _dsn_options(dsn)
47
72
  server_settings["options"] = f"{existing_options},{options}" if existing_options else options
48
73
  return {**connect_kwargs, "server_settings": server_settings}
49
74
 
@@ -120,7 +145,9 @@ class Connection:
120
145
  await self.close()
121
146
 
122
147
  connect_kwargs = _with_picodata_startup_options(
123
- self._connect_kwargs, request_query_metadata=self._on_query_metadata is not None
148
+ self._connect_kwargs,
149
+ self._connect_dsn,
150
+ request_query_metadata=self._on_query_metadata is not None,
124
151
  )
125
152
  try:
126
153
  self.conn = await asyncpg.connect(self._connect_dsn, **connect_kwargs)
@@ -315,9 +342,18 @@ class Connection:
315
342
  Returns:
316
343
  ExplainPlan for plain EXPLAIN or ExplainRawPlan for RAW mode.
317
344
 
345
+ Raises:
346
+ RuntimeError: If executing EXPLAIN fails or its output cannot be parsed. See
347
+ [`parse_plain_explain_lines()`][picopyn.explain.parse_plain_explain_lines]
348
+ and [`parse_raw_explain_lines()`][picopyn.explain.parse_raw_explain_lines]
349
+ for the underlying parsing functions and their errors.
350
+
318
351
  Examples:
319
352
  ```python
320
- plan = await conn.explain("SELECT * FROM warehouse")
353
+ plan = await conn.explain(
354
+ 'SELECT * FROM "warehouse" WHERE id = $1',
355
+ 1,
356
+ )
321
357
  raw_plan = await conn.explain("SELECT * FROM warehouse", raw=True)
322
358
  ```
323
359
  """