shelfdb 3.0.1__tar.gz → 3.0.2__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 (25) hide show
  1. {shelfdb-3.0.1 → shelfdb-3.0.2}/PKG-INFO +32 -3
  2. {shelfdb-3.0.1 → shelfdb-3.0.2}/README.md +31 -2
  3. {shelfdb-3.0.1 → shelfdb-3.0.2}/pyproject.toml +3 -6
  4. {shelfdb-3.0.1 → shelfdb-3.0.2}/pyproject.toml.orig +3 -6
  5. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/protocol/server.py +50 -7
  6. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/protocol/session.py +20 -9
  7. shelfdb-3.0.2/src/shelfdb/protocol/write_admission.py +49 -0
  8. {shelfdb-3.0.1 → shelfdb-3.0.2}/LICENSE +0 -0
  9. {shelfdb-3.0.1 → shelfdb-3.0.2}/ai-skill/shelfdb-usage/SKILL.md +0 -0
  10. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/__init__.py +0 -0
  11. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/__main__.py +0 -0
  12. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/cli.py +0 -0
  13. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/client/__init__.py +0 -0
  14. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/client/client.py +0 -0
  15. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/protocol/__init__.py +0 -0
  16. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/protocol/demo_poc.py +0 -0
  17. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/protocol/protocol.py +0 -0
  18. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/protocol/query_result.py +0 -0
  19. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/shelf/__init__.py +0 -0
  20. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/shelf/db.py +0 -0
  21. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/shelf/shelf/__init__.py +0 -0
  22. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/shelf/shelf/query.py +0 -0
  23. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/shelf/shelf/schema.py +0 -0
  24. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/shelf/shelf/shelf.py +0 -0
  25. {shelfdb-3.0.1 → shelfdb-3.0.2}/src/shelfdb/target.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shelfdb
3
- Version: 3.0.1
3
+ Version: 3.0.2
4
4
  Summary: A tiny database for Python
5
5
  Author: Nitipit Nontasuwan
6
6
  Author-email: Nitipit Nontasuwan <nitipit@gmail.com>
@@ -49,14 +49,28 @@ uv run python -m dev release-check
49
49
  The gate audits locked dependencies, checks formatting, linting, types, supported
50
50
  Python versions, strict documentation, release artifacts, metadata, and a clean
51
51
  wheel installation. GitHub Actions runs the same gate on pull requests, `main`,
52
- and version tags.
52
+ and version tags. Before tagging, authenticated release maintainers also check
53
+ GitHub's repository alerts:
53
54
 
54
- Serve the docs locally:
55
+ ```bash
56
+ uv run python -m dev release-check --github
57
+ ```
58
+
59
+ Serve the docs locally (Zensical dev server; live reload is built in):
55
60
 
56
61
  ```bash
57
62
  uv run python -m dev docs serve --port 9001 --livereload
58
63
  ```
59
64
 
65
+ `--livereload` is a legacy compatibility flag and is intentionally ignored by Zensical
66
+ (as Zensical has built-in live reload for docs serving).
67
+
68
+ Build docs for verification (Zensical build):
69
+
70
+ ```bash
71
+ uv run python -m dev docs build
72
+ ```
73
+
60
74
  Publish the docs with mike to the `docs` branch:
61
75
 
62
76
  ```bash
@@ -101,6 +115,21 @@ from shelfdb.client import Client
101
115
  client = await Client.connect("unix:///tmp/shelfdb.sock")
102
116
  ```
103
117
 
118
+ ## Transaction behavior
119
+
120
+ - Readers use independent LMDB snapshots and do not join the writer queue.
121
+ - Remote write transactions queue, one writer at a time, across connections
122
+ served by the same `DB` object on one event loop. Independent local writers
123
+ and other server processes are outside this queue.
124
+ - A normal exit from `async with client.transaction(write=True)` commits;
125
+ an exception escaping the context rolls back the whole transaction.
126
+ Catching a query error inside the context leaves commit/rollback up to you.
127
+ - Keep transactions short: perform external HTTP/LLM calls before opening them.
128
+ Long-lived readers can delay page reuse; long-lived writers hold up the queue.
129
+
130
+ See [remote transaction usage](docs-src/usage/remote.md#concurrent-transactions)
131
+ for coordination limits and details on partial updates and error handling.
132
+
104
133
  ## Example
105
134
 
106
135
  ```python
@@ -30,14 +30,28 @@ uv run python -m dev release-check
30
30
  The gate audits locked dependencies, checks formatting, linting, types, supported
31
31
  Python versions, strict documentation, release artifacts, metadata, and a clean
32
32
  wheel installation. GitHub Actions runs the same gate on pull requests, `main`,
33
- and version tags.
33
+ and version tags. Before tagging, authenticated release maintainers also check
34
+ GitHub's repository alerts:
34
35
 
35
- Serve the docs locally:
36
+ ```bash
37
+ uv run python -m dev release-check --github
38
+ ```
39
+
40
+ Serve the docs locally (Zensical dev server; live reload is built in):
36
41
 
37
42
  ```bash
38
43
  uv run python -m dev docs serve --port 9001 --livereload
39
44
  ```
40
45
 
46
+ `--livereload` is a legacy compatibility flag and is intentionally ignored by Zensical
47
+ (as Zensical has built-in live reload for docs serving).
48
+
49
+ Build docs for verification (Zensical build):
50
+
51
+ ```bash
52
+ uv run python -m dev docs build
53
+ ```
54
+
41
55
  Publish the docs with mike to the `docs` branch:
42
56
 
43
57
  ```bash
@@ -82,6 +96,21 @@ from shelfdb.client import Client
82
96
  client = await Client.connect("unix:///tmp/shelfdb.sock")
83
97
  ```
84
98
 
99
+ ## Transaction behavior
100
+
101
+ - Readers use independent LMDB snapshots and do not join the writer queue.
102
+ - Remote write transactions queue, one writer at a time, across connections
103
+ served by the same `DB` object on one event loop. Independent local writers
104
+ and other server processes are outside this queue.
105
+ - A normal exit from `async with client.transaction(write=True)` commits;
106
+ an exception escaping the context rolls back the whole transaction.
107
+ Catching a query error inside the context leaves commit/rollback up to you.
108
+ - Keep transactions short: perform external HTTP/LLM calls before opening them.
109
+ Long-lived readers can delay page reuse; long-lived writers hold up the queue.
110
+
111
+ See [remote transaction usage](docs-src/usage/remote.md#concurrent-transactions)
112
+ for coordination limits and details on partial updates and error handling.
113
+
85
114
  ## Example
86
115
 
87
116
  ```python
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "shelfdb"
7
- version = "3.0.1"
7
+ version = "3.0.2"
8
8
  description = "A tiny database for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -34,12 +34,9 @@ Repository = "https://github.com/keenlycode/shelfdb"
34
34
 
35
35
  [dependency-groups]
36
36
  dev = [
37
- "mike>=2.1.3,<3",
38
- "mkdocs>=1.6.1,<2",
39
- "mkdocs-shadcn>=0.10.4,<0.11",
40
- "mkdocstrings[python]>=0.29.0,<0.30",
37
+ "zensical==0.0.52",
38
+ "mike @ git+https://github.com/squidfunk/mike.git@2d4ad799442f4592db8ad53b179bfb33db8c69ac",
41
39
  "pytest-cov>=7.0.0,<8",
42
- "pymdown-extensions>=11.0.0,<12",
43
40
  "pytest>=9.0.0,<10",
44
41
  "ruff>=0.13.0,<0.14",
45
42
  "tinydb>=4.8.2,<5",
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "shelfdb"
7
- version = "3.0.1"
7
+ version = "3.0.2"
8
8
  description = "A tiny database for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -33,12 +33,9 @@ Repository = "https://github.com/keenlycode/shelfdb"
33
33
 
34
34
  [dependency-groups]
35
35
  dev = [
36
- "mike>=2.1.3,<3",
37
- "mkdocs>=1.6.1,<2",
38
- "mkdocs-shadcn>=0.10.4,<0.11",
39
- "mkdocstrings[python]>=0.29.0,<0.30",
36
+ "zensical==0.0.52",
37
+ "mike @ git+https://github.com/squidfunk/mike.git@2d4ad799442f4592db8ad53b179bfb33db8c69ac",
40
38
  "pytest-cov>=7.0.0,<8",
41
- "pymdown-extensions>=11.0.0,<12",
42
39
  "pytest>=9.0.0,<10",
43
40
  "ruff>=0.13.0,<0.14",
44
41
  "tinydb>=4.8.2,<5",
@@ -12,16 +12,39 @@ from shelfdb.shelf import DB
12
12
 
13
13
  from .protocol import read_request, write_response
14
14
  from .session import Session
15
+ from .write_admission import WriteLease
16
+
17
+
18
+ class _ConnectionReader(asyncio.StreamReader):
19
+ """Observe transport EOF/errors without a second consumer of request bytes."""
20
+
21
+ def __init__(self):
22
+ super().__init__()
23
+ self.disconnected = asyncio.Event()
24
+
25
+ def feed_eof(self) -> None:
26
+ self.disconnected.set()
27
+ super().feed_eof()
28
+
29
+ def set_exception(self, exc) -> None:
30
+ self.disconnected.set()
31
+ super().set_exception(exc)
32
+
33
+
34
+ def _protocol_factory(db: DB) -> asyncio.StreamReaderProtocol:
35
+ reader = _ConnectionReader()
36
+ return asyncio.StreamReaderProtocol(reader, partial(handle_client, db=db))
15
37
 
16
38
 
17
39
  async def handle_client(
18
- reader: asyncio.StreamReader,
40
+ reader: _ConnectionReader,
19
41
  writer: asyncio.StreamWriter,
20
42
  *,
21
43
  db: DB,
22
44
  ) -> None:
23
45
  """Serve one client connection with one session."""
24
46
  session = Session(db)
47
+ lease = WriteLease(db)
25
48
 
26
49
  try:
27
50
  while True:
@@ -34,19 +57,35 @@ async def handle_client(
34
57
  break
35
58
 
36
59
  try:
60
+ if (
61
+ isinstance(command, dict)
62
+ and command.get("cmd") == "begin"
63
+ and command.get("mode") == "write"
64
+ and not session.active
65
+ ):
66
+ if not await lease.acquire(reader.disconnected):
67
+ break
37
68
  response = session.handle(command)
38
69
  except Exception as exc:
39
70
  response = {"ok": False, "error": str(exc)}
71
+ finally:
72
+ # A failed begin and any terminal transaction path give up admission.
73
+ # Query errors intentionally leave an active transaction usable.
74
+ if not session.active:
75
+ lease.release()
40
76
 
41
77
  try:
42
78
  await write_response(writer, response)
43
79
  except Exception:
44
80
  break
45
81
  finally:
46
- session.close()
47
- writer.close()
48
- with suppress(Exception):
49
- await writer.wait_closed()
82
+ try:
83
+ session.close()
84
+ finally:
85
+ lease.release()
86
+ writer.close()
87
+ with suppress(Exception):
88
+ await writer.wait_closed()
50
89
 
51
90
 
52
91
  async def serve(
@@ -56,7 +95,9 @@ async def serve(
56
95
  port: int = 0,
57
96
  ) -> asyncio.Server:
58
97
  """Start the minimal ShelfDB protocol server."""
59
- return await asyncio.start_server(partial(handle_client, db=db), host, port)
98
+ return await asyncio.get_running_loop().create_server(
99
+ partial(_protocol_factory, db), host, port
100
+ )
60
101
 
61
102
 
62
103
  class _UnixServer:
@@ -101,7 +142,9 @@ async def serve_unix(
101
142
  socket_path = Path(path)
102
143
  with suppress(FileNotFoundError):
103
144
  socket_path.unlink()
104
- server = await asyncio.start_unix_server(partial(handle_client, db=db), socket_path)
145
+ server = await asyncio.get_running_loop().create_unix_server(
146
+ partial(_protocol_factory, db), socket_path
147
+ )
105
148
  return _UnixServer(server, socket_path)
106
149
 
107
150
 
@@ -94,8 +94,10 @@ class Session:
94
94
  def close(self) -> None:
95
95
  tx = self._tx
96
96
  if tx is not None:
97
- tx.tx.abort()
98
- self._clear_transaction()
97
+ try:
98
+ tx.tx.abort()
99
+ finally:
100
+ self._clear_transaction()
99
101
 
100
102
  def _begin(self, mode: str | None) -> dict[str, Any]:
101
103
  if mode not in {"read", "write"}:
@@ -115,16 +117,25 @@ class Session:
115
117
 
116
118
  def _commit(self) -> dict[str, Any]:
117
119
  tx = self._require_tx()
118
- if tx.is_write:
119
- tx.commit()
120
- else:
121
- tx.tx.abort()
122
- self._clear_transaction()
120
+ try:
121
+ if tx.is_write:
122
+ tx.commit()
123
+ else:
124
+ tx.tx.abort()
125
+ except Exception:
126
+ # LMDB may already have consumed the handle on commit failure.
127
+ # Best-effort abort also covers failures before commit reached LMDB.
128
+ try:
129
+ tx.tx.abort()
130
+ except Exception:
131
+ pass
132
+ raise
133
+ finally:
134
+ self._clear_transaction()
123
135
  return _ok({"committed": True})
124
136
 
125
137
  def _rollback(self) -> dict[str, Any]:
126
- self._require_tx().tx.abort()
127
- self._clear_transaction()
138
+ self.close()
128
139
  return _ok({"rolled_back": True})
129
140
 
130
141
  def _query(
@@ -0,0 +1,49 @@
1
+ """Event-loop-local writer admission, shared by listeners serving one DB.
2
+
3
+ This coordinates only server-side writers. It does not make synchronous LMDB
4
+ operations or independent local writers nonblocking.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from weakref import WeakKeyDictionary
11
+
12
+ from shelfdb.shelf import DB
13
+
14
+ _GATES: WeakKeyDictionary[DB, asyncio.Lock] = WeakKeyDictionary()
15
+
16
+
17
+ class WriteLease:
18
+ """One connection's ownership of a DB's writer gate (not the transaction)."""
19
+
20
+ def __init__(self, db: DB):
21
+ self._gate = _GATES.setdefault(db, asyncio.Lock())
22
+ self.held = False
23
+
24
+ async def acquire(self, disconnected: asyncio.Event) -> bool:
25
+ """Wait without reading the stream; EOF/cancellation cannot leak a grant."""
26
+ waiting = asyncio.create_task(self._gate.acquire())
27
+ closed = asyncio.create_task(disconnected.wait())
28
+ try:
29
+ await asyncio.wait((waiting, closed), return_when=asyncio.FIRST_COMPLETED)
30
+ if disconnected.is_set():
31
+ return False
32
+ waiting.result()
33
+ self.held = True
34
+ return True
35
+ finally:
36
+ # No await between checking a grant and returning an abandoned one.
37
+ # A pending Lock.acquire handles its own cancellation safely.
38
+ if not self.held and waiting.done() and not waiting.cancelled():
39
+ if waiting.exception() is None and waiting.result():
40
+ self._gate.release()
41
+ waiting.cancel()
42
+ closed.cancel()
43
+ await asyncio.gather(waiting, closed, return_exceptions=True)
44
+
45
+ def release(self) -> None:
46
+ """Release only this connection's grant, at most once."""
47
+ if self.held:
48
+ self.held = False
49
+ self._gate.release()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes