Skip to content

Commit 7a0896d

Browse files
authored
feat: Automate tokio runtime cleanup via reference counting (#17)
1 parent 9f6a0fe commit 7a0896d

13 files changed

Lines changed: 654 additions & 200 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ jobs:
3333
with:
3434
toolchain: stable
3535
override: true
36+
# Include Python version in cache key to prevent cross-contamination
37+
# between GIL-enabled (3.14) and free-threaded (3.14t) builds
38+
cache-key: py${{ matrix.python-version }}
3639
- name: Install protobuf compiler
3740
uses: arduino/setup-protoc@v3
3841
with:
@@ -43,6 +46,11 @@ jobs:
4346
with:
4447
enable-cache: true
4548
python-version: ${{ matrix.python-version }}
49+
- name: Check python build
50+
run: |
51+
uv run --no-project python -c 'import sys; print(sys.prefix); print(sys.version_info)'
52+
uv run --no-project python -c 'import sysconfig; flag=sysconfig.get_config_var("Py_GIL_DISABLED"); print(f"Py_GIL_DISABLED={flag}")'
53+
uv run --no-project python -c 'import sys; print(f"{sys.abiflags=}")'
4654
- name: Install dependencies and build the package
4755
run: |
4856
uv sync --locked --all-extras --no-install-project

README.md

Lines changed: 61 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pip install etcd_client
1515

1616
```python
1717
from etcd_client import EtcdClient
18-
etcd = EtcdClient(['http:://127.0.0.1:2379'])
18+
etcd = EtcdClient(['http://127.0.0.1:2379'])
1919
```
2020

2121
Actual connection establishment with Etcd's gRPC channel will be done when you call `EtcdClient.connect()`.
@@ -28,28 +28,9 @@ async def main():
2828
print(bytes(value).decode()) # testvalue
2929
```
3030

31-
### Cleanup on shutdown
31+
### Working with key prefixes
3232

33-
To prevent segfaults or GIL state violations during Python interpreter shutdown, you should call `cleanup_runtime()` at the end of your main async function before the event loop shuts down:
34-
35-
```python
36-
from etcd_client import EtcdClient, cleanup_runtime
37-
38-
async def main():
39-
etcd = EtcdClient(['http://127.0.0.1:2379'])
40-
async with etcd.connect() as communicator:
41-
await communicator.put('testkey'.encode(), 'testvalue'.encode())
42-
value = await communicator.get('testkey'.encode())
43-
print(bytes(value).decode())
44-
# Cleanup the tokio runtime before returning
45-
cleanup_runtime()
46-
47-
asyncio.run(main())
48-
```
49-
50-
This function signals the internal tokio runtime to shut down gracefully, waiting up to 5 seconds for pending tasks to complete.
51-
52-
`EtcdCommunicator.get_prefix(prefix)` will return a tuple of list containing all key-values with given key prefix.
33+
`EtcdCommunicator.get_prefix(prefix)` returns a list of key-value pairs matching the given prefix.
5334

5435
```python
5536
async def main():
@@ -69,9 +50,39 @@ async def main():
6950
print([bytes(v).decode() for v in resp])
7051
```
7152

53+
## Automatic runtime cleanup
54+
55+
The tokio runtime is automatically cleaned up when the last client context exits. In most cases, no explicit cleanup is needed:
56+
57+
```python
58+
import asyncio
59+
from etcd_client import EtcdClient
60+
61+
async def main():
62+
etcd = EtcdClient(['http://127.0.0.1:2379'])
63+
async with etcd.connect() as communicator:
64+
await communicator.put('testkey'.encode(), 'testvalue'.encode())
65+
value = await communicator.get('testkey'.encode())
66+
print(bytes(value).decode())
67+
# Runtime automatically cleaned up when context exits
68+
69+
asyncio.run(main())
70+
```
71+
72+
The library uses reference counting to track active client contexts. When the last context exits, the tokio runtime is gracefully shut down, waiting up to 5 seconds for pending tasks to complete. If you create new clients after this, the runtime is automatically re-initialized.
73+
74+
For advanced use cases requiring explicit control, `cleanup_runtime()` is available:
75+
76+
```python
77+
from etcd_client import cleanup_runtime
78+
79+
# Force cleanup at a specific point (usually not needed)
80+
cleanup_runtime()
81+
```
82+
7283
## Operating with Etcd lock
7384

74-
Just like `EtcdClient.connect()`, you can easilly use etcd lock by calling `EtcdClient.with_lock(lock_opts)`.
85+
Just like `EtcdClient.connect()`, you can easily use etcd lock by calling `EtcdClient.with_lock(lock_opts)`.
7586

7687
```python
7788
async def first():
@@ -98,18 +109,11 @@ async with etcd.connect() as communicator:
98109
await asyncio.gather(first(), second()) # first: testvalue | second: testvalue
99110
```
100111

101-
Adding `timeout` parameter to `EtcdClient.with_lock()` call will add a timeout to lock acquiring process.
112+
### Lock timeout
102113

103-
```python
104-
async def first():
105-
async with etcd.with_lock(
106-
EtcdLockOption(
107-
lock_name='foolock'.encode(),
108-
)
109-
) as communicator:
110-
value = await communicator.get('testkey'.encode())
111-
print('first:', bytes(value).decode(), end=' | ')
114+
Adding `timeout` parameter to `EtcdLockOption` will add a timeout to the lock acquiring process.
112115

116+
```python
113117
async def second():
114118
await asyncio.sleep(0.1)
115119
async with etcd.with_lock(
@@ -120,13 +124,11 @@ async def second():
120124
) as communicator:
121125
value = await communicator.get('testkey'.encode())
122126
print('second:', bytes(value).decode())
123-
124-
async with etcd.connect() as communicator:
125-
await communicator.put('testkey'.encode(), 'testvalue'.encode())
126-
await asyncio.gather(first(), second()) # first: testvalue | second: testvalue
127127
```
128128

129-
Adding `ttl` parameter to `EtcdClient.with_lock()` call will force lock to be released after given seconds.
129+
### Lock TTL
130+
131+
Adding `ttl` parameter to `EtcdLockOption` will force the lock to be released after the given seconds.
130132

131133
```python
132134
async def first():
@@ -154,7 +156,7 @@ for task in done:
154156

155157
## Watch
156158

157-
You can watch changes on key with `EtcdCommunicator.watch(key)`.
159+
You can watch changes on a key with `EtcdCommunicator.watch(key)`.
158160

159161
```python
160162
async def watch():
@@ -179,7 +181,9 @@ await asyncio.gather(watch(), update())
179181
# WatchEventType.PUT 5
180182
```
181183

182-
Watching changes on keys with specific prefix can be also done by `EtcdCommunicator.watch_prefix(key_prefix)`.
184+
### Watch with prefix
185+
186+
Watching changes on keys with a specific prefix can be done with `EtcdCommunicator.watch_prefix(key_prefix)`.
183187

184188
```python
185189
async def watch():
@@ -204,11 +208,11 @@ await asyncio.gather(watch(), update())
204208

205209
## Transaction
206210

207-
You can run etcd transaction by calling `EtcdCommunicator.txn(txn)`.
211+
You can run etcd transactions by calling `EtcdCommunicator.txn(txn)`.
208212

209213
### Constructing compares
210214

211-
Constructing compare operations can be done by comparing `Compare` instance.
215+
Constructing compare operations can be done using the `Compare` class.
212216

213217
```python
214218
from etcd_client import Compare, CompareOp
@@ -218,7 +222,7 @@ compares = [
218222
]
219223
```
220224

221-
### Executing transaction calls
225+
### Executing transactions
222226

223227
```python
224228
async with etcd.connect() as communicator:
@@ -232,29 +236,26 @@ async with etcd.connect() as communicator:
232236
]
233237

234238
res = await communicator.txn(Txn().when(compares).and_then([TxnOp.get('successkey'.encode())]))
235-
print(res) # TODO: Need to write response type bindings.
239+
print(res) # TODO: Need to write response type bindings.
236240
```
237241

238242
## How to build
239243

240-
### Prerequisite
244+
### Prerequisites
241245

242-
* The Rust development environment (the 2021 edition or later) using [`rustup`](https://rustup.rs/) or your package manager
246+
* The Rust development environment (2021 edition or later) using [`rustup`](https://rustup.rs/) or your package manager
243247
* The Python development environment (3.10 or later) using [`pyenv`](https://github.com/pyenv/pyenv#installation) or your package manager
244248

245-
### Build instruction
249+
### Build instructions
246250

247-
First, create a virtualenv (either using the standard venv package, pyenv, or
248-
whatever your favorite). Then, install the PEP-517 build toolchain and run it.
251+
First, create a virtualenv (using the standard venv package, pyenv, or your preferred tool). Then, install the PEP-517 build toolchain and run it.
249252

250253
```shell
251254
pip install -U pip build setuptools
252255
python -m build --sdist --wheel
253256
```
254257

255-
It will automatically install build dependencies like
256-
[`maturin`](https://github.com/PyO3/maturin) and build the wheel and source
257-
distributions under the `dist/` directory.
258+
This will automatically install build dependencies like [`maturin`](https://github.com/PyO3/maturin) and build the wheel and source distributions under the `dist/` directory.
258259

259260
## How to develop and test
260261

@@ -279,42 +280,18 @@ uv run maturin develop # Builds and installs the Rust extension
279280
This project uses ruff for linting/formatting and mypy for type checking:
280281

281282
```bash
282-
# Format Python code
283-
make fmt-py
284-
285-
# Lint Python code
286-
make lint-py
287-
288-
# Auto-fix Python issues (format + fixable lints)
289-
make fix-py
290-
291-
# Type check Python code
292-
make typecheck
293-
294-
# Auto-fix Rust issues (format + fixable clippy lints)
295-
make fix-rust
296-
297-
# Auto-fix all issues (Python + Rust)
298-
make fix
299-
300-
# Format all code (Python + Rust)
301-
make fmt
302-
303-
# Lint all code (Python + Rust)
304-
make lint
305-
306-
# Run all checks (Python + Rust)
307-
make check
283+
make fmt # Format all code (Python + Rust)
284+
make lint # Lint all code (Python + Rust)
285+
make fix # Auto-fix all issues (Python + Rust)
286+
make typecheck # Type check Python code
287+
make check # Run all checks
308288
```
309289

310290
### Running tests
311291

312292
```bash
313-
# Run tests using uv
314-
make test
315-
316-
# Or directly with uv
317-
uv run pytest
293+
make test # Run tests using uv
294+
uv run pytest # Or directly with uv
318295

319-
# The tests use testcontainers to automatically spin up etcd
296+
# Tests use testcontainers to automatically spin up etcd
320297
```

etcd_client.pyi

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -360,30 +360,62 @@ class GRPCStatusCode(Enum):
360360
"""The request does not have valid authentication credentials."""
361361

362362

363+
def active_context_count() -> int:
364+
"""
365+
Get the number of currently active client contexts.
366+
367+
Returns the count of client context managers currently in use (inside
368+
`async with` blocks). This is useful for debugging and testing the
369+
automatic cleanup behavior.
370+
371+
Returns:
372+
The number of active client contexts. Returns 0 when no clients
373+
are in an active context manager.
374+
375+
Example:
376+
```python
377+
from etcd_client import Client, active_context_count
378+
379+
client = Client(["localhost:2379"])
380+
print(active_context_count()) # 0
381+
382+
async with client.connect():
383+
print(active_context_count()) # 1
384+
385+
print(active_context_count()) # 0
386+
```
387+
"""
388+
...
389+
390+
363391
def cleanup_runtime() -> None:
364392
"""
365393
Explicitly cleanup the tokio runtime.
366394
395+
In most cases, the runtime is automatically cleaned up when the last
396+
client context exits. This function is provided for cases where explicit
397+
control is needed, such as when using the client without a context manager.
398+
367399
This function signals the runtime to shutdown and waits for all tracked tasks
368-
to complete. It should be called at the end of your main async function,
369-
before the event loop shuts down.
400+
to complete (up to 5 seconds). After shutdown, the runtime will be lazily
401+
re-initialized if new client operations are performed.
370402
371403
Example:
372404
```python
373405
from etcd_client import cleanup_runtime
374406
375407
async def main():
376408
# Your etcd operations here
377-
client = Client.connect(["localhost:2379"])
378-
await client.put("key", "value")
379-
# Cleanup before returning
380-
cleanup_runtime()
409+
async with client.connect():
410+
await client.put("key", "value")
411+
# Runtime is automatically cleaned up when context exits
412+
# Explicit call is usually not needed
381413
382414
asyncio.run(main())
383415
```
384416
385417
Note:
386-
This is useful for ensuring clean shutdown and preventing GIL state
387-
violations during Python interpreter finalization.
418+
This function is idempotent - calling it multiple times or when the
419+
runtime is already shut down is safe and has no effect.
388420
"""
389421
...

pyproject.toml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ classifiers = [
2222
"Programming Language :: Python :: 3.14",
2323
]
2424
dependencies = [
25-
"maturin>=1.10.2",
26-
"pytest>=8.4.1,<9",
27-
"pytest-asyncio>=1.1.0,<2",
25+
"maturin>=1.11.2",
26+
"pytest>=9.0.2,<10",
27+
"pytest-asyncio>=1.3.0,<2",
2828
"trafaret>=2.1,<3",
29-
"testcontainers>=4.12.0,<5",
29+
"testcontainers>=4.13.3,<5",
3030
]
3131

3232
[project.urls]
@@ -35,12 +35,12 @@ repository = "https://github.com/lablup/etcd-client-py"
3535

3636
[project.optional-dependencies]
3737
dev = [
38-
"ruff>=0.8.5",
39-
"mypy>=1.13.0",
38+
"ruff>=0.14.10",
39+
"mypy>=1.19.1",
4040
]
4141

4242
[build-system]
43-
requires = ["maturin>=1.7,<2.0"]
43+
requires = ["maturin>=1.11,<2.0"]
4444
build-backend = "maturin"
4545

4646
[tool.maturin]

python/etcd_client/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from .etcd_client import * # noqa: F403
2-
from .etcd_client import cleanup_runtime # noqa: F401
2+
from .etcd_client import active_context_count, cleanup_runtime # noqa: F401
33

44
__doc__ = etcd_client.__doc__ # noqa: F405
55
if hasattr(etcd_client, "__all__"): # noqa: F405

0 commit comments

Comments
 (0)