sqlodin 0.6.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.
- sqlodin-0.6.0/.gitignore +38 -0
- sqlodin-0.6.0/LICENSE +21 -0
- sqlodin-0.6.0/PKG-INFO +253 -0
- sqlodin-0.6.0/README.md +238 -0
- sqlodin-0.6.0/pyproject.toml +26 -0
- sqlodin-0.6.0/src/sqlodin/__init__.py +15 -0
- sqlodin-0.6.0/src/sqlodin/client.py +333 -0
- sqlodin-0.6.0/src/sqlodin/dbapi.py +324 -0
- sqlodin-0.6.0/src/sqlodin/errors.py +42 -0
- sqlodin-0.6.0/src/sqlodin/parameters.py +86 -0
- sqlodin-0.6.0/src/sqlodin/py.typed +0 -0
- sqlodin-0.6.0/src/sqlodin/results.py +67 -0
- sqlodin-0.6.0/src/sqlodin/search.py +110 -0
- sqlodin-0.6.0/src/sqlodin/sqlalchemy.py +138 -0
- sqlodin-0.6.0/src/sqlodin/transaction.py +49 -0
- sqlodin-0.6.0/src/sqlodin/transport.py +97 -0
- sqlodin-0.6.0/src/sqlodin/vector.py +47 -0
- sqlodin-0.6.0/tests/test_api.py +169 -0
- sqlodin-0.6.0/tests/test_search_sqlalchemy.py +125 -0
- sqlodin-0.6.0/uv.lock +206 -0
sqlodin-0.6.0/.gitignore
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Build outputs
|
|
2
|
+
bin/
|
|
3
|
+
build/
|
|
4
|
+
docs/build/
|
|
5
|
+
*.dSYM/
|
|
6
|
+
|
|
7
|
+
# Object files and dynamic libraries
|
|
8
|
+
*.o
|
|
9
|
+
*.so
|
|
10
|
+
*.dylib
|
|
11
|
+
*.dll
|
|
12
|
+
|
|
13
|
+
# Compiled static archive for sqlite-vec is preserved
|
|
14
|
+
!src/sqlite/libsqlite_vec.a
|
|
15
|
+
|
|
16
|
+
# OS files
|
|
17
|
+
.DS_Store
|
|
18
|
+
Thumbs.db
|
|
19
|
+
|
|
20
|
+
# Python caches
|
|
21
|
+
__pycache__/
|
|
22
|
+
*.py[cod]
|
|
23
|
+
|
|
24
|
+
# Python development environments
|
|
25
|
+
.venv/
|
|
26
|
+
.pytest_cache/
|
|
27
|
+
|
|
28
|
+
# Python distribution artifacts
|
|
29
|
+
languages/python/dist/
|
|
30
|
+
|
|
31
|
+
# Private CLI session and unresolved write state
|
|
32
|
+
*.shell.db
|
|
33
|
+
*.shell.db-*
|
|
34
|
+
*.shell.db.lock
|
|
35
|
+
|
|
36
|
+
# Formal checker caches
|
|
37
|
+
.tlacache/
|
|
38
|
+
specs/states/
|
sqlodin-0.6.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vikrant Rathore and Ronak Rathore
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sqlodin-0.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sqlodin
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: A small, explicit Python client for SQLodin's durable multi-master SQL service
|
|
5
|
+
Author: Vikrant Rathore, Ronak Rathore
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Provides-Extra: sqlalchemy
|
|
10
|
+
Requires-Dist: sqlalchemy<2.1,>=2.0.43; extra == 'sqlalchemy'
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest<10,>=8; extra == 'test'
|
|
13
|
+
Requires-Dist: sqlalchemy<2.1,>=2.0.43; extra == 'test'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# SQLodin for Python
|
|
17
|
+
|
|
18
|
+
A small synchronous client for SQLodin's native mTLS SQL service. Written by
|
|
19
|
+
Vikrant Rathore with assistance from Ronak Rathore. Python 3.11+, no runtime dependencies.
|
|
20
|
+
This is an initial client for the bounded SQLodin service, not a production-qualified release.
|
|
21
|
+
|
|
22
|
+
From this repository:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
uv add ./languages/python
|
|
26
|
+
# Development
|
|
27
|
+
cd languages/python
|
|
28
|
+
uv sync --extra test
|
|
29
|
+
uv run pytest
|
|
30
|
+
uv build
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Connect once and reuse the connection. Each endpoint's server name must match an
|
|
34
|
+
exact DNS SAN in its certificate; the address is the reachable host and port.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import sqlodin
|
|
38
|
+
|
|
39
|
+
nodes = [
|
|
40
|
+
sqlodin.Endpoint("10.175.52.19:7600", "node1.sqlodin.test"),
|
|
41
|
+
sqlodin.Endpoint("10.175.52.20:7600", "node2.sqlodin.test"),
|
|
42
|
+
sqlodin.Endpoint("10.175.52.21:7600", "node3.sqlodin.test"),
|
|
43
|
+
]
|
|
44
|
+
tls = sqlodin.TLS(ca="ca.pem", cert="app.pem", key="app.key")
|
|
45
|
+
|
|
46
|
+
with sqlodin.connect(nodes, cluster="orders", tls=tls) as db:
|
|
47
|
+
db.execute("INSERT INTO orders(id, customer) VALUES (?, ?)", (42, "Ada"))
|
|
48
|
+
order = db.query("SELECT id, customer FROM orders WHERE id = ?", (42,)).one()
|
|
49
|
+
print(order["customer"]) # Ada
|
|
50
|
+
print(order[0]) # 42
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`execute()` returns `WriteResult(changes, applied, node, sequence)`. Successful writes
|
|
54
|
+
have durable quorum acceptance and local application. `query()` returns immutable
|
|
55
|
+
`Rows` with `columns`, iteration/indexing, `first()`, `one()`, and `scalar()`.
|
|
56
|
+
Rows support column names, numeric positions, `dict(row)` and `row.as_tuple()`.
|
|
57
|
+
Duplicate column names resolve to the first occurrence by name; positions preserve
|
|
58
|
+
all columns. Prefer SQL aliases for unambiguous names. SQL NULL becomes `None`,
|
|
59
|
+
integers retain 64-bit precision, and BLOB query values become `bytes`.
|
|
60
|
+
|
|
61
|
+
Queries default to a fresh quorum barrier. `consistency="local"` explicitly permits
|
|
62
|
+
stale results without a quorum. `status()` describes the contacted node's local
|
|
63
|
+
state; it does not prove quorum availability. Queries may run while a write is
|
|
64
|
+
uncertain, but their results do not resolve that write's identity.
|
|
65
|
+
|
|
66
|
+
## Atomic write batches
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
with db.transaction() as tx:
|
|
70
|
+
tx.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (20, 1))
|
|
71
|
+
tx.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (20, 2))
|
|
72
|
+
print(tx.result.changes)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The context buffers SQL, then submits one atomic transaction body on successful
|
|
76
|
+
exit. A Python exception discards the unsent batch. A SQL constraint rolls back the
|
|
77
|
+
whole batch and raises `ConstraintError`. This is a write-only buffered batch:
|
|
78
|
+
there is no live transaction or query inside it. Do not include `BEGIN`, `COMMIT`,
|
|
79
|
+
or `ROLLBACK`. Statements use plain `?` placeholders. Values remain bound parameters,
|
|
80
|
+
including when the batch assigns distinct parameter positions to each statement.
|
|
81
|
+
`execute()` does not return rows; use `query()` for reads. SQL `RETURNING` rows are
|
|
82
|
+
currently discarded by the engine and are not exposed by this API.
|
|
83
|
+
|
|
84
|
+
## A timeout is not a rollback
|
|
85
|
+
|
|
86
|
+
The client retries connection failures across the supplied endpoints under one
|
|
87
|
+
operation deadline, preserving the same session, sequence, SQL, and parameters.
|
|
88
|
+
If it cannot learn the result, it raises `UnknownOutcome` and retains `db.pending`.
|
|
89
|
+
It refuses a new write until that request is resolved:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
try:
|
|
93
|
+
db.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (20, 1))
|
|
94
|
+
except sqlodin.UnknownOutcome as exc:
|
|
95
|
+
# Restore connectivity, then retry the exact identity.
|
|
96
|
+
result = db.resolve_pending()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
For recovery after closing the connection, persist `exc.pending.to_json()` securely
|
|
100
|
+
and open a new connection with `pending=sqlodin.PendingWrite.from_json(saved)`.
|
|
101
|
+
Then call `resolve_pending()`. Saved requests contain application SQL and values.
|
|
102
|
+
Only one owner may advance a session. `Expired` and `Identity_Conflict` raise
|
|
103
|
+
`SessionError`; they do not silently create another session or rerun a payment.
|
|
104
|
+
Recovery of a request lost in a Python process crash *before its pending identity
|
|
105
|
+
was persisted* is not automatic. Use application-level unique operation IDs and
|
|
106
|
+
reconciliation for that case. This client makes no general exactly-once claim.
|
|
107
|
+
|
|
108
|
+
A connection serializes calls with a lock; use distinct reusable connections for
|
|
109
|
+
concurrency. Do not share connections across forked processes. New sessions consume
|
|
110
|
+
persistent server capacity, so reuse connections rather than opening one per write.
|
|
111
|
+
The native client has no async interface or connection pool. The optional SQLAlchemy
|
|
112
|
+
adapter provides optimistic serializable transactions by default, described below.
|
|
113
|
+
|
|
114
|
+
## Current bounds
|
|
115
|
+
|
|
116
|
+
The server supports fixed voter membership, at most 8 statements / 4096 SQL bytes /
|
|
117
|
+
16 parameters per transaction, and 256 UTF-8 bytes per text parameter. Parameters
|
|
118
|
+
support `str`, signed 64-bit `int`, finite `float`, `Vector`, and `None`; arbitrary BLOB parameters are
|
|
119
|
+
not yet supported. Queries are read-only, one statement, at most 4096 rows, with a
|
|
120
|
+
256 KiB internal result budget and an instruction budget. Large results fail as a
|
|
121
|
+
whole (`QueryError`), so use bounded application pagination. Replicated SQL follows
|
|
122
|
+
the engine's deterministic function policy. Host addresses are currently numeric
|
|
123
|
+
IPv4 endpoints. Live voter changes and production qualification remain open.
|
|
124
|
+
|
|
125
|
+
## Vector, full-text, and hybrid search
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
with sqlodin.connect(nodes, cluster="orders", tls=tls) as db:
|
|
129
|
+
docs = db.create_search_index("documents", dimensions=3) # once
|
|
130
|
+
docs.put(1, title="Consensus", body="Durable Paxos replication",
|
|
131
|
+
vector=[0.9, 0.1, 0.0])
|
|
132
|
+
print(docs.full_text("Paxos").one()["title"])
|
|
133
|
+
print(docs.nearest([1, 0, 0], metric="l2").first())
|
|
134
|
+
print(docs.hybrid("durable", [1, 0, 0], limit=5, candidates=30))
|
|
135
|
+
# On later connections, open a handle without creating tables:
|
|
136
|
+
docs = db.search_index("documents", dimensions=3)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`put()` and `delete()` keep ordinary content/vector storage and the FTS5 table
|
|
140
|
+
consistent in one durable transaction. Use these methods for all index mutations;
|
|
141
|
+
direct SQL can bypass that relationship. Opening a handle does not validate an
|
|
142
|
+
existing schema. Creation fails if either table already exists.
|
|
143
|
+
|
|
144
|
+
`full_text()` uses [FTS5 query syntax and BM25](https://www.sqlite.org/fts5.html);
|
|
145
|
+
lower scores rank first. `nearest()` performs an **exact distance scan**, using
|
|
146
|
+
sqlite-vec's `vec_distance_l2` or `vec_distance_cosine`; it is not an ANN index.
|
|
147
|
+
Use nonzero stored and query vectors for cosine distance. Hybrid retrieval performs
|
|
148
|
+
reciprocal-rank fusion, summing `1 / (rank_constant + rank)` over the two candidate
|
|
149
|
+
lists. Higher fused scores rank first; ties use document IDs. Both lists are
|
|
150
|
+
computed by one query against one fresh fenced snapshot. A candidate missing from
|
|
151
|
+
one list contributes only its other rank. Limits and candidate counts are 1–100.
|
|
152
|
+
A candidate limit bounds sorting/results, not the work needed to scan embeddings.
|
|
153
|
+
|
|
154
|
+
Vectors use immutable `sqlodin.Vector` values: 1–384 finite float32 components,
|
|
155
|
+
canonicalized before encoding. The whole request has a 384-component budget.
|
|
156
|
+
Ordinary SQL accepts these as typed parameters and returns vector BLOBs as `bytes`:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
v = sqlodin.Vector([0.9, 0.1, 0.0])
|
|
160
|
+
row = db.query("SELECT vec_distance_l2(embedding, ?) FROM documents WHERE id=?",
|
|
161
|
+
(v, 1)).one()
|
|
162
|
+
# Decode a selected embedding with sqlodin.Vector.from_bytes(blob).
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Titles, bodies and FTS expressions retain the 256-byte UTF-8 parameter limit.
|
|
166
|
+
This is currently a bounded document/chunk API, not unrestricted document ingestion.
|
|
167
|
+
Searches remain subject to the read instruction/result budgets. The durable service
|
|
168
|
+
admits built-in FTS5 and protects its shadow tables using SQLite defensive mode;
|
|
169
|
+
`vec0` virtual-table creation remains unsupported. General FTS maintenance commands
|
|
170
|
+
and custom tokenizers are outside the tested API.
|
|
171
|
+
|
|
172
|
+
## SQLAlchemy ORM and Core (optional)
|
|
173
|
+
|
|
174
|
+
Install the optional dependency from this checkout:
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
uv pip install './languages/python[sqlalchemy]'
|
|
178
|
+
# For repository development:
|
|
179
|
+
uv sync --project languages/python --extra sqlalchemy --extra test
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table, select
|
|
184
|
+
from sqlodin.sqlalchemy import VectorType, create_engine
|
|
185
|
+
|
|
186
|
+
engine = create_engine(nodes, cluster="orders", tls=tls)
|
|
187
|
+
items = Table("items", MetaData(),
|
|
188
|
+
Column("id", Integer, primary_key=True, autoincrement=False),
|
|
189
|
+
Column("title", String),
|
|
190
|
+
Column("embedding", VectorType(3)),
|
|
191
|
+
)
|
|
192
|
+
items.create(engine, checkfirst=True)
|
|
193
|
+
with engine.begin() as conn:
|
|
194
|
+
conn.execute(items.insert(), {"id": 1, "title": "Paxos", "embedding": [1, 0, 0]})
|
|
195
|
+
row = conn.execute(select(items).where(items.c.id == 1)).mappings().one()
|
|
196
|
+
print(row["embedding"]) # Vector
|
|
197
|
+
engine.dispose()
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The registered dialect is `sqlodin://`. It uses the same native mTLS client and
|
|
201
|
+
bound values. SQLAlchemy `text()` queries support FTS, and `VectorType` works with
|
|
202
|
+
`func.vec_distance_l2`/`func.vec_distance_cosine` and typed `bindparam` values.
|
|
203
|
+
VectorType validates dimensions on bind/result conversion; add a database CHECK
|
|
204
|
+
constraint if other writers must also be constrained.
|
|
205
|
+
|
|
206
|
+
The default is **SERIALIZABLE transactions**. `Session.begin()` and `engine.begin()`
|
|
207
|
+
commit atomically; rollback, exceptions and connection close discard staged work.
|
|
208
|
+
ORM flush returns generated integer primary keys and supports reads of staged writes.
|
|
209
|
+
Nested savepoints support rollback and release. Provide mapped classes or declared
|
|
210
|
+
Table metadata: general reflection, explicit DML RETURNING and two-phase/XA commits
|
|
211
|
+
remain unsupported. Explicit `autocommit=True` opts into independent statement commits.
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from sqlalchemy.orm import Session
|
|
215
|
+
|
|
216
|
+
with Session(engine) as session, session.begin():
|
|
217
|
+
parent = Parent(name="Ada")
|
|
218
|
+
session.add(parent)
|
|
219
|
+
session.flush() # parent.id is available; other sessions cannot see it yet
|
|
220
|
+
session.add(Child(parent_id=parent.id, label="first"))
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The server privately evaluates staged statements, rolls back the preview, and checks
|
|
224
|
+
its read revision again when the full transaction commits through Paxos. Any
|
|
225
|
+
intervening application write currently causes a serialization failure, including
|
|
226
|
+
writes to unrelated tables. Catch `sqlodin.dbapi.SerializationError` through
|
|
227
|
+
SQLAlchemy's `OperationalError.orig`, roll back, and retry the **whole transaction**.
|
|
228
|
+
Queries and flushes replay staged writes, so short transactions are preferable.
|
|
229
|
+
Transaction limits remain eight writes / 4096 SQL bytes / sixteen parameters / 384
|
|
230
|
+
vector components. Successful commit is the durable boundary, not flush.
|
|
231
|
+
|
|
232
|
+
Unknown commits raise `sqlodin.dbapi.OperationalError`; inspect `exc.orig.pending`.
|
|
233
|
+
Save that identity, resolve it with a native connection and the same authenticated
|
|
234
|
+
client SAN, and discard the affected pooled connection. Its saved read revision is
|
|
235
|
+
part of the retry identity. An unresolved commit blocks rollback/pool reset and new
|
|
236
|
+
writes. Do not start a fresh transaction as a substitute for resolving it.
|
|
237
|
+
|
|
238
|
+
See [the ORM transaction contract](../../docs/guides/orm-transactions.typ) for the execution
|
|
239
|
+
model, retry example, resource bounds and serializability argument.
|
|
240
|
+
|
|
241
|
+
## Compatibility and verification
|
|
242
|
+
|
|
243
|
+
Python 0.3.0 ORM transactions require the **format-4 / SQL-policy-6** service. The
|
|
244
|
+
revision is persisted with application outcomes and included in commit digests.
|
|
245
|
+
Older stores and peers fail closed; no automatic or rolling migration is provided.
|
|
246
|
+
Preserve existing data until a separately validated export/import migration is available.
|
|
247
|
+
|
|
248
|
+
`tools/check_orm_transactions.py` exercises ORM begin/flush, generated keys,
|
|
249
|
+
relationships, rollback/close, nested savepoints, constraint handling, concurrent
|
|
250
|
+
conflicts, pool cleanup, uncertain commits, quorum loss and all-voter recovery.
|
|
251
|
+
`tools/check_python_features.py` covers vectors, FTS and SQLAlchemy search paths.
|
|
252
|
+
Saved local/Linux correctness reports are under `benchmarks/results/`. They are
|
|
253
|
+
not search throughput benchmarks or production certification.
|
sqlodin-0.6.0/README.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# SQLodin for Python
|
|
2
|
+
|
|
3
|
+
A small synchronous client for SQLodin's native mTLS SQL service. Written by
|
|
4
|
+
Vikrant Rathore with assistance from Ronak Rathore. Python 3.11+, no runtime dependencies.
|
|
5
|
+
This is an initial client for the bounded SQLodin service, not a production-qualified release.
|
|
6
|
+
|
|
7
|
+
From this repository:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
uv add ./languages/python
|
|
11
|
+
# Development
|
|
12
|
+
cd languages/python
|
|
13
|
+
uv sync --extra test
|
|
14
|
+
uv run pytest
|
|
15
|
+
uv build
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Connect once and reuse the connection. Each endpoint's server name must match an
|
|
19
|
+
exact DNS SAN in its certificate; the address is the reachable host and port.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import sqlodin
|
|
23
|
+
|
|
24
|
+
nodes = [
|
|
25
|
+
sqlodin.Endpoint("10.175.52.19:7600", "node1.sqlodin.test"),
|
|
26
|
+
sqlodin.Endpoint("10.175.52.20:7600", "node2.sqlodin.test"),
|
|
27
|
+
sqlodin.Endpoint("10.175.52.21:7600", "node3.sqlodin.test"),
|
|
28
|
+
]
|
|
29
|
+
tls = sqlodin.TLS(ca="ca.pem", cert="app.pem", key="app.key")
|
|
30
|
+
|
|
31
|
+
with sqlodin.connect(nodes, cluster="orders", tls=tls) as db:
|
|
32
|
+
db.execute("INSERT INTO orders(id, customer) VALUES (?, ?)", (42, "Ada"))
|
|
33
|
+
order = db.query("SELECT id, customer FROM orders WHERE id = ?", (42,)).one()
|
|
34
|
+
print(order["customer"]) # Ada
|
|
35
|
+
print(order[0]) # 42
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`execute()` returns `WriteResult(changes, applied, node, sequence)`. Successful writes
|
|
39
|
+
have durable quorum acceptance and local application. `query()` returns immutable
|
|
40
|
+
`Rows` with `columns`, iteration/indexing, `first()`, `one()`, and `scalar()`.
|
|
41
|
+
Rows support column names, numeric positions, `dict(row)` and `row.as_tuple()`.
|
|
42
|
+
Duplicate column names resolve to the first occurrence by name; positions preserve
|
|
43
|
+
all columns. Prefer SQL aliases for unambiguous names. SQL NULL becomes `None`,
|
|
44
|
+
integers retain 64-bit precision, and BLOB query values become `bytes`.
|
|
45
|
+
|
|
46
|
+
Queries default to a fresh quorum barrier. `consistency="local"` explicitly permits
|
|
47
|
+
stale results without a quorum. `status()` describes the contacted node's local
|
|
48
|
+
state; it does not prove quorum availability. Queries may run while a write is
|
|
49
|
+
uncertain, but their results do not resolve that write's identity.
|
|
50
|
+
|
|
51
|
+
## Atomic write batches
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
with db.transaction() as tx:
|
|
55
|
+
tx.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (20, 1))
|
|
56
|
+
tx.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (20, 2))
|
|
57
|
+
print(tx.result.changes)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The context buffers SQL, then submits one atomic transaction body on successful
|
|
61
|
+
exit. A Python exception discards the unsent batch. A SQL constraint rolls back the
|
|
62
|
+
whole batch and raises `ConstraintError`. This is a write-only buffered batch:
|
|
63
|
+
there is no live transaction or query inside it. Do not include `BEGIN`, `COMMIT`,
|
|
64
|
+
or `ROLLBACK`. Statements use plain `?` placeholders. Values remain bound parameters,
|
|
65
|
+
including when the batch assigns distinct parameter positions to each statement.
|
|
66
|
+
`execute()` does not return rows; use `query()` for reads. SQL `RETURNING` rows are
|
|
67
|
+
currently discarded by the engine and are not exposed by this API.
|
|
68
|
+
|
|
69
|
+
## A timeout is not a rollback
|
|
70
|
+
|
|
71
|
+
The client retries connection failures across the supplied endpoints under one
|
|
72
|
+
operation deadline, preserving the same session, sequence, SQL, and parameters.
|
|
73
|
+
If it cannot learn the result, it raises `UnknownOutcome` and retains `db.pending`.
|
|
74
|
+
It refuses a new write until that request is resolved:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
try:
|
|
78
|
+
db.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (20, 1))
|
|
79
|
+
except sqlodin.UnknownOutcome as exc:
|
|
80
|
+
# Restore connectivity, then retry the exact identity.
|
|
81
|
+
result = db.resolve_pending()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For recovery after closing the connection, persist `exc.pending.to_json()` securely
|
|
85
|
+
and open a new connection with `pending=sqlodin.PendingWrite.from_json(saved)`.
|
|
86
|
+
Then call `resolve_pending()`. Saved requests contain application SQL and values.
|
|
87
|
+
Only one owner may advance a session. `Expired` and `Identity_Conflict` raise
|
|
88
|
+
`SessionError`; they do not silently create another session or rerun a payment.
|
|
89
|
+
Recovery of a request lost in a Python process crash *before its pending identity
|
|
90
|
+
was persisted* is not automatic. Use application-level unique operation IDs and
|
|
91
|
+
reconciliation for that case. This client makes no general exactly-once claim.
|
|
92
|
+
|
|
93
|
+
A connection serializes calls with a lock; use distinct reusable connections for
|
|
94
|
+
concurrency. Do not share connections across forked processes. New sessions consume
|
|
95
|
+
persistent server capacity, so reuse connections rather than opening one per write.
|
|
96
|
+
The native client has no async interface or connection pool. The optional SQLAlchemy
|
|
97
|
+
adapter provides optimistic serializable transactions by default, described below.
|
|
98
|
+
|
|
99
|
+
## Current bounds
|
|
100
|
+
|
|
101
|
+
The server supports fixed voter membership, at most 8 statements / 4096 SQL bytes /
|
|
102
|
+
16 parameters per transaction, and 256 UTF-8 bytes per text parameter. Parameters
|
|
103
|
+
support `str`, signed 64-bit `int`, finite `float`, `Vector`, and `None`; arbitrary BLOB parameters are
|
|
104
|
+
not yet supported. Queries are read-only, one statement, at most 4096 rows, with a
|
|
105
|
+
256 KiB internal result budget and an instruction budget. Large results fail as a
|
|
106
|
+
whole (`QueryError`), so use bounded application pagination. Replicated SQL follows
|
|
107
|
+
the engine's deterministic function policy. Host addresses are currently numeric
|
|
108
|
+
IPv4 endpoints. Live voter changes and production qualification remain open.
|
|
109
|
+
|
|
110
|
+
## Vector, full-text, and hybrid search
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
with sqlodin.connect(nodes, cluster="orders", tls=tls) as db:
|
|
114
|
+
docs = db.create_search_index("documents", dimensions=3) # once
|
|
115
|
+
docs.put(1, title="Consensus", body="Durable Paxos replication",
|
|
116
|
+
vector=[0.9, 0.1, 0.0])
|
|
117
|
+
print(docs.full_text("Paxos").one()["title"])
|
|
118
|
+
print(docs.nearest([1, 0, 0], metric="l2").first())
|
|
119
|
+
print(docs.hybrid("durable", [1, 0, 0], limit=5, candidates=30))
|
|
120
|
+
# On later connections, open a handle without creating tables:
|
|
121
|
+
docs = db.search_index("documents", dimensions=3)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`put()` and `delete()` keep ordinary content/vector storage and the FTS5 table
|
|
125
|
+
consistent in one durable transaction. Use these methods for all index mutations;
|
|
126
|
+
direct SQL can bypass that relationship. Opening a handle does not validate an
|
|
127
|
+
existing schema. Creation fails if either table already exists.
|
|
128
|
+
|
|
129
|
+
`full_text()` uses [FTS5 query syntax and BM25](https://www.sqlite.org/fts5.html);
|
|
130
|
+
lower scores rank first. `nearest()` performs an **exact distance scan**, using
|
|
131
|
+
sqlite-vec's `vec_distance_l2` or `vec_distance_cosine`; it is not an ANN index.
|
|
132
|
+
Use nonzero stored and query vectors for cosine distance. Hybrid retrieval performs
|
|
133
|
+
reciprocal-rank fusion, summing `1 / (rank_constant + rank)` over the two candidate
|
|
134
|
+
lists. Higher fused scores rank first; ties use document IDs. Both lists are
|
|
135
|
+
computed by one query against one fresh fenced snapshot. A candidate missing from
|
|
136
|
+
one list contributes only its other rank. Limits and candidate counts are 1–100.
|
|
137
|
+
A candidate limit bounds sorting/results, not the work needed to scan embeddings.
|
|
138
|
+
|
|
139
|
+
Vectors use immutable `sqlodin.Vector` values: 1–384 finite float32 components,
|
|
140
|
+
canonicalized before encoding. The whole request has a 384-component budget.
|
|
141
|
+
Ordinary SQL accepts these as typed parameters and returns vector BLOBs as `bytes`:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
v = sqlodin.Vector([0.9, 0.1, 0.0])
|
|
145
|
+
row = db.query("SELECT vec_distance_l2(embedding, ?) FROM documents WHERE id=?",
|
|
146
|
+
(v, 1)).one()
|
|
147
|
+
# Decode a selected embedding with sqlodin.Vector.from_bytes(blob).
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Titles, bodies and FTS expressions retain the 256-byte UTF-8 parameter limit.
|
|
151
|
+
This is currently a bounded document/chunk API, not unrestricted document ingestion.
|
|
152
|
+
Searches remain subject to the read instruction/result budgets. The durable service
|
|
153
|
+
admits built-in FTS5 and protects its shadow tables using SQLite defensive mode;
|
|
154
|
+
`vec0` virtual-table creation remains unsupported. General FTS maintenance commands
|
|
155
|
+
and custom tokenizers are outside the tested API.
|
|
156
|
+
|
|
157
|
+
## SQLAlchemy ORM and Core (optional)
|
|
158
|
+
|
|
159
|
+
Install the optional dependency from this checkout:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
uv pip install './languages/python[sqlalchemy]'
|
|
163
|
+
# For repository development:
|
|
164
|
+
uv sync --project languages/python --extra sqlalchemy --extra test
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table, select
|
|
169
|
+
from sqlodin.sqlalchemy import VectorType, create_engine
|
|
170
|
+
|
|
171
|
+
engine = create_engine(nodes, cluster="orders", tls=tls)
|
|
172
|
+
items = Table("items", MetaData(),
|
|
173
|
+
Column("id", Integer, primary_key=True, autoincrement=False),
|
|
174
|
+
Column("title", String),
|
|
175
|
+
Column("embedding", VectorType(3)),
|
|
176
|
+
)
|
|
177
|
+
items.create(engine, checkfirst=True)
|
|
178
|
+
with engine.begin() as conn:
|
|
179
|
+
conn.execute(items.insert(), {"id": 1, "title": "Paxos", "embedding": [1, 0, 0]})
|
|
180
|
+
row = conn.execute(select(items).where(items.c.id == 1)).mappings().one()
|
|
181
|
+
print(row["embedding"]) # Vector
|
|
182
|
+
engine.dispose()
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The registered dialect is `sqlodin://`. It uses the same native mTLS client and
|
|
186
|
+
bound values. SQLAlchemy `text()` queries support FTS, and `VectorType` works with
|
|
187
|
+
`func.vec_distance_l2`/`func.vec_distance_cosine` and typed `bindparam` values.
|
|
188
|
+
VectorType validates dimensions on bind/result conversion; add a database CHECK
|
|
189
|
+
constraint if other writers must also be constrained.
|
|
190
|
+
|
|
191
|
+
The default is **SERIALIZABLE transactions**. `Session.begin()` and `engine.begin()`
|
|
192
|
+
commit atomically; rollback, exceptions and connection close discard staged work.
|
|
193
|
+
ORM flush returns generated integer primary keys and supports reads of staged writes.
|
|
194
|
+
Nested savepoints support rollback and release. Provide mapped classes or declared
|
|
195
|
+
Table metadata: general reflection, explicit DML RETURNING and two-phase/XA commits
|
|
196
|
+
remain unsupported. Explicit `autocommit=True` opts into independent statement commits.
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from sqlalchemy.orm import Session
|
|
200
|
+
|
|
201
|
+
with Session(engine) as session, session.begin():
|
|
202
|
+
parent = Parent(name="Ada")
|
|
203
|
+
session.add(parent)
|
|
204
|
+
session.flush() # parent.id is available; other sessions cannot see it yet
|
|
205
|
+
session.add(Child(parent_id=parent.id, label="first"))
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The server privately evaluates staged statements, rolls back the preview, and checks
|
|
209
|
+
its read revision again when the full transaction commits through Paxos. Any
|
|
210
|
+
intervening application write currently causes a serialization failure, including
|
|
211
|
+
writes to unrelated tables. Catch `sqlodin.dbapi.SerializationError` through
|
|
212
|
+
SQLAlchemy's `OperationalError.orig`, roll back, and retry the **whole transaction**.
|
|
213
|
+
Queries and flushes replay staged writes, so short transactions are preferable.
|
|
214
|
+
Transaction limits remain eight writes / 4096 SQL bytes / sixteen parameters / 384
|
|
215
|
+
vector components. Successful commit is the durable boundary, not flush.
|
|
216
|
+
|
|
217
|
+
Unknown commits raise `sqlodin.dbapi.OperationalError`; inspect `exc.orig.pending`.
|
|
218
|
+
Save that identity, resolve it with a native connection and the same authenticated
|
|
219
|
+
client SAN, and discard the affected pooled connection. Its saved read revision is
|
|
220
|
+
part of the retry identity. An unresolved commit blocks rollback/pool reset and new
|
|
221
|
+
writes. Do not start a fresh transaction as a substitute for resolving it.
|
|
222
|
+
|
|
223
|
+
See [the ORM transaction contract](../../docs/guides/orm-transactions.typ) for the execution
|
|
224
|
+
model, retry example, resource bounds and serializability argument.
|
|
225
|
+
|
|
226
|
+
## Compatibility and verification
|
|
227
|
+
|
|
228
|
+
Python 0.3.0 ORM transactions require the **format-4 / SQL-policy-6** service. The
|
|
229
|
+
revision is persisted with application outcomes and included in commit digests.
|
|
230
|
+
Older stores and peers fail closed; no automatic or rolling migration is provided.
|
|
231
|
+
Preserve existing data until a separately validated export/import migration is available.
|
|
232
|
+
|
|
233
|
+
`tools/check_orm_transactions.py` exercises ORM begin/flush, generated keys,
|
|
234
|
+
relationships, rollback/close, nested savepoints, constraint handling, concurrent
|
|
235
|
+
conflicts, pool cleanup, uncertain commits, quorum loss and all-voter recovery.
|
|
236
|
+
`tools/check_python_features.py` covers vectors, FTS and SQLAlchemy search paths.
|
|
237
|
+
Saved local/Linux correctness reports are under `benchmarks/results/`. They are
|
|
238
|
+
not search throughput benchmarks or production certification.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sqlodin"
|
|
7
|
+
version = "0.6.0"
|
|
8
|
+
description = "A small, explicit Python client for SQLodin's durable multi-master SQL service"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{name = "Vikrant Rathore"}, {name = "Ronak Rathore"}]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = ["pytest>=8,<10", "SQLAlchemy>=2.0.43,<2.1"]
|
|
17
|
+
sqlalchemy = ["SQLAlchemy>=2.0.43,<2.1"]
|
|
18
|
+
|
|
19
|
+
[project.entry-points."sqlalchemy.dialects"]
|
|
20
|
+
sqlodin = "sqlodin.sqlalchemy:SQLodinDialect"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/sqlodin"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""SQLodin: durable SQL with a small, explicit Python API."""
|
|
2
|
+
from .client import Connection, PendingWrite, connect
|
|
3
|
+
from .errors import (ConnectionError, ConstraintError, Error, PendingWriteError,
|
|
4
|
+
QueryError, SerializationError, SessionError, UnknownOutcome)
|
|
5
|
+
from .results import Row, Rows, WriteResult
|
|
6
|
+
from .transport import Endpoint, TLS
|
|
7
|
+
from .vector import Vector
|
|
8
|
+
from .search import SearchIndex
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'Vector', 'SearchIndex', 'connect', 'Connection', 'Endpoint', 'TLS', 'PendingWrite', 'Row', 'Rows', 'WriteResult',
|
|
12
|
+
'Error', 'ConnectionError', 'ConstraintError', 'PendingWriteError', 'QueryError',
|
|
13
|
+
'SessionError', 'SerializationError', 'UnknownOutcome',
|
|
14
|
+
]
|
|
15
|
+
__version__ = '0.6.0'
|