dflockd-client 1.0.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.
- dflockd_client-1.0.0/PKG-INFO +154 -0
- dflockd_client-1.0.0/README.md +135 -0
- dflockd_client-1.0.0/pyproject.toml +34 -0
- dflockd_client-1.0.0/src/dflockd_client/__init__.py +0 -0
- dflockd_client-1.0.0/src/dflockd_client/client.py +312 -0
- dflockd_client-1.0.0/src/dflockd_client/py.typed +0 -0
- dflockd_client-1.0.0/src/dflockd_client/sharding.py +17 -0
- dflockd_client-1.0.0/src/dflockd_client/sync_client.py +303 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dflockd-client
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: dflockd python client
|
|
5
|
+
Author: Matth Ingersoll
|
|
6
|
+
Author-email: Matth Ingersoll <matth@mtingers.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Dist: pytest-asyncio>=1.3.0 ; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest-cov>=7.0.0 ; extra == 'dev'
|
|
10
|
+
Requires-Dist: pyright>=1.1 ; extra == 'dev'
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Project-URL: Homepage, https://github.com/mtingers/dflockd-client-py
|
|
13
|
+
Project-URL: Repository, https://github.com/mtingers/dflockd-client-py
|
|
14
|
+
Project-URL: Documentation, https://mtingers.github.io/dflockd-client-py/
|
|
15
|
+
Project-URL: Bug Tracker, https://github.com/mtingers/dflockd-client-py/issues
|
|
16
|
+
Project-URL: Changelog, https://github.com/mtingers/dflockd-client-py/blob/main/CHANGELOG.md
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# dflockd-client
|
|
21
|
+
|
|
22
|
+
<!--toc:start-->
|
|
23
|
+
- [dflockd-client](#dflockd-client)
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [Quick start](#quick-start)
|
|
26
|
+
- [Async client](#async-client)
|
|
27
|
+
- [Sync client](#sync-client)
|
|
28
|
+
- [Manual acquire/release](#manual-acquirerelease)
|
|
29
|
+
- [Two-phase lock acquisition](#two-phase-lock-acquisition)
|
|
30
|
+
- [Parameters](#parameters)
|
|
31
|
+
- [Multi-server sharding](#multi-server-sharding)
|
|
32
|
+
<!--toc:end-->
|
|
33
|
+
|
|
34
|
+
A Python client library for [dflockd](https://github.com/mtingers/dflockd) — a lightweight distributed lock server with FIFO ordering, automatic lease expiry, and background renewal.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install dflockd-client
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or with uv:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv add dflockd-client
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
### Async client
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import asyncio
|
|
54
|
+
from dflockd_client.client import DistributedLock
|
|
55
|
+
|
|
56
|
+
async def main():
|
|
57
|
+
async with DistributedLock("my-key", acquire_timeout_s=10) as lock:
|
|
58
|
+
print(lock.token, lock.lease)
|
|
59
|
+
# critical section — lease auto-renews in background
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Sync client
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from dflockd_client.sync_client import DistributedLock
|
|
68
|
+
|
|
69
|
+
with DistributedLock("my-key", acquire_timeout_s=10) as lock:
|
|
70
|
+
print(lock.token, lock.lease)
|
|
71
|
+
# critical section — lease auto-renews in background thread
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Manual acquire/release
|
|
75
|
+
|
|
76
|
+
Both clients support explicit `acquire()` / `release()` outside of a context manager:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from dflockd_client.sync_client import DistributedLock
|
|
80
|
+
|
|
81
|
+
lock = DistributedLock("my-key")
|
|
82
|
+
if lock.acquire():
|
|
83
|
+
try:
|
|
84
|
+
pass # critical section
|
|
85
|
+
finally:
|
|
86
|
+
lock.release()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Two-phase lock acquisition
|
|
90
|
+
|
|
91
|
+
The `enqueue()` / `wait()` methods split lock acquisition into two steps, allowing you to notify an external system after joining the queue but before blocking:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from dflockd_client.sync_client import DistributedLock
|
|
95
|
+
|
|
96
|
+
lock = DistributedLock("my-key")
|
|
97
|
+
status = lock.enqueue() # join queue, returns "acquired" or "queued"
|
|
98
|
+
notify_external_system() # your application logic here
|
|
99
|
+
if lock.wait(timeout_s=10): # block until granted (no-op if already acquired)
|
|
100
|
+
try:
|
|
101
|
+
pass # critical section
|
|
102
|
+
finally:
|
|
103
|
+
lock.release()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Async equivalent:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
lock = DistributedLock("my-key")
|
|
110
|
+
status = await lock.enqueue()
|
|
111
|
+
await notify_external_system()
|
|
112
|
+
if await lock.wait(timeout_s=10):
|
|
113
|
+
try:
|
|
114
|
+
pass # critical section
|
|
115
|
+
finally:
|
|
116
|
+
await lock.release()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Parameters
|
|
120
|
+
|
|
121
|
+
| Parameter | Default | Description |
|
|
122
|
+
| ------------------- | ----------------------- | ----------------------------------------------------------------------- |
|
|
123
|
+
| `key` | _(required)_ | Lock name |
|
|
124
|
+
| `acquire_timeout_s` | `10` | Seconds to wait for lock acquisition |
|
|
125
|
+
| `lease_ttl_s` | `None` (server default) | Lease duration in seconds |
|
|
126
|
+
| `servers` | `[("127.0.0.1", 6388)]` | List of `(host, port)` tuples |
|
|
127
|
+
| `sharding_strategy` | `stable_hash_shard` | `Callable[[str, int], int]` — maps `(key, num_servers)` to server index |
|
|
128
|
+
| `renew_ratio` | `0.5` | Renew at `lease * ratio` seconds |
|
|
129
|
+
|
|
130
|
+
## Multi-server sharding
|
|
131
|
+
|
|
132
|
+
When running multiple dflockd instances, the client can distribute keys across servers using consistent hashing. Each key always routes to the same server.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from dflockd_client.sync_client import DistributedLock
|
|
136
|
+
|
|
137
|
+
servers = [("server1", 6388), ("server2", 6388), ("server3", 6388)]
|
|
138
|
+
|
|
139
|
+
with DistributedLock("my-key", servers=servers) as lock:
|
|
140
|
+
print(lock.token, lock.lease)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The default strategy uses `zlib.crc32` for stable, deterministic hashing. You can provide a custom strategy:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from dflockd_client.sync_client import DistributedLock
|
|
147
|
+
|
|
148
|
+
def my_strategy(key: str, num_servers: int) -> int:
|
|
149
|
+
"""Route all keys to the first server."""
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
with DistributedLock("my-key", servers=servers, sharding_strategy=my_strategy) as lock:
|
|
153
|
+
pass
|
|
154
|
+
```
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# dflockd-client
|
|
2
|
+
|
|
3
|
+
<!--toc:start-->
|
|
4
|
+
- [dflockd-client](#dflockd-client)
|
|
5
|
+
- [Installation](#installation)
|
|
6
|
+
- [Quick start](#quick-start)
|
|
7
|
+
- [Async client](#async-client)
|
|
8
|
+
- [Sync client](#sync-client)
|
|
9
|
+
- [Manual acquire/release](#manual-acquirerelease)
|
|
10
|
+
- [Two-phase lock acquisition](#two-phase-lock-acquisition)
|
|
11
|
+
- [Parameters](#parameters)
|
|
12
|
+
- [Multi-server sharding](#multi-server-sharding)
|
|
13
|
+
<!--toc:end-->
|
|
14
|
+
|
|
15
|
+
A Python client library for [dflockd](https://github.com/mtingers/dflockd) — a lightweight distributed lock server with FIFO ordering, automatic lease expiry, and background renewal.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install dflockd-client
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or with uv:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv add dflockd-client
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
### Async client
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import asyncio
|
|
35
|
+
from dflockd_client.client import DistributedLock
|
|
36
|
+
|
|
37
|
+
async def main():
|
|
38
|
+
async with DistributedLock("my-key", acquire_timeout_s=10) as lock:
|
|
39
|
+
print(lock.token, lock.lease)
|
|
40
|
+
# critical section — lease auto-renews in background
|
|
41
|
+
|
|
42
|
+
asyncio.run(main())
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Sync client
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from dflockd_client.sync_client import DistributedLock
|
|
49
|
+
|
|
50
|
+
with DistributedLock("my-key", acquire_timeout_s=10) as lock:
|
|
51
|
+
print(lock.token, lock.lease)
|
|
52
|
+
# critical section — lease auto-renews in background thread
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Manual acquire/release
|
|
56
|
+
|
|
57
|
+
Both clients support explicit `acquire()` / `release()` outside of a context manager:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from dflockd_client.sync_client import DistributedLock
|
|
61
|
+
|
|
62
|
+
lock = DistributedLock("my-key")
|
|
63
|
+
if lock.acquire():
|
|
64
|
+
try:
|
|
65
|
+
pass # critical section
|
|
66
|
+
finally:
|
|
67
|
+
lock.release()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Two-phase lock acquisition
|
|
71
|
+
|
|
72
|
+
The `enqueue()` / `wait()` methods split lock acquisition into two steps, allowing you to notify an external system after joining the queue but before blocking:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from dflockd_client.sync_client import DistributedLock
|
|
76
|
+
|
|
77
|
+
lock = DistributedLock("my-key")
|
|
78
|
+
status = lock.enqueue() # join queue, returns "acquired" or "queued"
|
|
79
|
+
notify_external_system() # your application logic here
|
|
80
|
+
if lock.wait(timeout_s=10): # block until granted (no-op if already acquired)
|
|
81
|
+
try:
|
|
82
|
+
pass # critical section
|
|
83
|
+
finally:
|
|
84
|
+
lock.release()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Async equivalent:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
lock = DistributedLock("my-key")
|
|
91
|
+
status = await lock.enqueue()
|
|
92
|
+
await notify_external_system()
|
|
93
|
+
if await lock.wait(timeout_s=10):
|
|
94
|
+
try:
|
|
95
|
+
pass # critical section
|
|
96
|
+
finally:
|
|
97
|
+
await lock.release()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Parameters
|
|
101
|
+
|
|
102
|
+
| Parameter | Default | Description |
|
|
103
|
+
| ------------------- | ----------------------- | ----------------------------------------------------------------------- |
|
|
104
|
+
| `key` | _(required)_ | Lock name |
|
|
105
|
+
| `acquire_timeout_s` | `10` | Seconds to wait for lock acquisition |
|
|
106
|
+
| `lease_ttl_s` | `None` (server default) | Lease duration in seconds |
|
|
107
|
+
| `servers` | `[("127.0.0.1", 6388)]` | List of `(host, port)` tuples |
|
|
108
|
+
| `sharding_strategy` | `stable_hash_shard` | `Callable[[str, int], int]` — maps `(key, num_servers)` to server index |
|
|
109
|
+
| `renew_ratio` | `0.5` | Renew at `lease * ratio` seconds |
|
|
110
|
+
|
|
111
|
+
## Multi-server sharding
|
|
112
|
+
|
|
113
|
+
When running multiple dflockd instances, the client can distribute keys across servers using consistent hashing. Each key always routes to the same server.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from dflockd_client.sync_client import DistributedLock
|
|
117
|
+
|
|
118
|
+
servers = [("server1", 6388), ("server2", 6388), ("server3", 6388)]
|
|
119
|
+
|
|
120
|
+
with DistributedLock("my-key", servers=servers) as lock:
|
|
121
|
+
print(lock.token, lock.lease)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The default strategy uses `zlib.crc32` for stable, deterministic hashing. You can provide a custom strategy:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from dflockd_client.sync_client import DistributedLock
|
|
128
|
+
|
|
129
|
+
def my_strategy(key: str, num_servers: int) -> int:
|
|
130
|
+
"""Route all keys to the first server."""
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
with DistributedLock("my-key", servers=servers, sharding_strategy=my_strategy) as lock:
|
|
134
|
+
pass
|
|
135
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dflockd-client"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "dflockd python client"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Matth Ingersoll", email = "matth@mtingers.com" }]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["uv_build>=0.9.28,<0.11.0"]
|
|
13
|
+
build-backend = "uv_build"
|
|
14
|
+
|
|
15
|
+
[tool.pytest.ini_options]
|
|
16
|
+
asyncio_mode = "auto"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest-asyncio>=1.3.0", "pytest-cov>=7.0.0", "pyright>=1.1"]
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest-asyncio>=1.3.0",
|
|
24
|
+
"pytest-cov>=7.0.0",
|
|
25
|
+
"pyright>=1.1",
|
|
26
|
+
"ruff>=0.15.0",
|
|
27
|
+
]
|
|
28
|
+
docs = ["mkdocs>=1.6", "mkdocs-material>=9.5"]
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/mtingers/dflockd-client-py"
|
|
31
|
+
Repository = "https://github.com/mtingers/dflockd-client-py"
|
|
32
|
+
Documentation = "https://mtingers.github.io/dflockd-client-py/"
|
|
33
|
+
"Bug Tracker" = "https://github.com/mtingers/dflockd-client-py/issues"
|
|
34
|
+
Changelog = "https://github.com/mtingers/dflockd-client-py/blob/main/CHANGELOG.md"
|
|
File without changes
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from .sharding import DEFAULT_SERVERS, ShardingStrategy, stable_hash_shard
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger("dflockd-client")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _encode_lines(*lines: str) -> bytes:
|
|
12
|
+
return ("".join(f"{ln}\n" for ln in lines)).encode("utf-8")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _readline(reader: asyncio.StreamReader) -> str:
|
|
16
|
+
raw = await reader.readline()
|
|
17
|
+
if raw == b"":
|
|
18
|
+
raise ConnectionError("server closed connection")
|
|
19
|
+
return raw.decode("utf-8").rstrip("\r\n")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def acquire(
|
|
23
|
+
reader: asyncio.StreamReader,
|
|
24
|
+
writer: asyncio.StreamWriter,
|
|
25
|
+
key: str,
|
|
26
|
+
acquire_timeout_s: int,
|
|
27
|
+
lease_ttl_s: int | None = None,
|
|
28
|
+
) -> tuple[str, int]:
|
|
29
|
+
# l\nkey\n"<timeout> [<lease>]"\n
|
|
30
|
+
arg = (
|
|
31
|
+
str(acquire_timeout_s)
|
|
32
|
+
if lease_ttl_s is None
|
|
33
|
+
else f"{acquire_timeout_s} {lease_ttl_s}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
writer.write(_encode_lines("l", key, arg))
|
|
37
|
+
await writer.drain()
|
|
38
|
+
|
|
39
|
+
resp = await _readline(reader)
|
|
40
|
+
if resp == "timeout":
|
|
41
|
+
raise TimeoutError(f"timeout acquiring {key!r}")
|
|
42
|
+
if not resp.startswith("ok "):
|
|
43
|
+
raise RuntimeError(f"acquire failed: {resp!r}")
|
|
44
|
+
|
|
45
|
+
# ok <token> <lease>
|
|
46
|
+
parts = resp.split()
|
|
47
|
+
if len(parts) < 2:
|
|
48
|
+
raise RuntimeError(f"bad ok response: {resp!r}")
|
|
49
|
+
token = parts[1]
|
|
50
|
+
lease = int(parts[2]) if len(parts) >= 3 else 30
|
|
51
|
+
return token, lease
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def renew(
|
|
55
|
+
reader: asyncio.StreamReader,
|
|
56
|
+
writer: asyncio.StreamWriter,
|
|
57
|
+
key: str,
|
|
58
|
+
token: str,
|
|
59
|
+
lease_ttl_s: int | None = None,
|
|
60
|
+
) -> int:
|
|
61
|
+
# n\nkey\n"<token> [<lease>]"\n
|
|
62
|
+
arg = token if lease_ttl_s is None else f"{token} {lease_ttl_s}"
|
|
63
|
+
writer.write(_encode_lines("n", key, arg))
|
|
64
|
+
await writer.drain()
|
|
65
|
+
|
|
66
|
+
resp = await _readline(reader)
|
|
67
|
+
if not resp.startswith("ok"):
|
|
68
|
+
raise RuntimeError(f"renew failed: {resp!r}")
|
|
69
|
+
|
|
70
|
+
# ok <seconds_remaining> (optional)
|
|
71
|
+
parts = resp.split()
|
|
72
|
+
if len(parts) >= 2 and parts[1].isdigit():
|
|
73
|
+
return int(parts[1])
|
|
74
|
+
return -1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def enqueue(
|
|
78
|
+
reader: asyncio.StreamReader,
|
|
79
|
+
writer: asyncio.StreamWriter,
|
|
80
|
+
key: str,
|
|
81
|
+
lease_ttl_s: int | None = None,
|
|
82
|
+
) -> tuple[str, str | None, int | None]:
|
|
83
|
+
"""
|
|
84
|
+
Two-phase enqueue: join FIFO queue, return immediately.
|
|
85
|
+
Returns (status, token, lease) where status is "acquired" or "queued".
|
|
86
|
+
"""
|
|
87
|
+
arg = "" if lease_ttl_s is None else str(lease_ttl_s)
|
|
88
|
+
writer.write(_encode_lines("e", key, arg))
|
|
89
|
+
await writer.drain()
|
|
90
|
+
|
|
91
|
+
resp = await _readline(reader)
|
|
92
|
+
if resp.startswith("acquired "):
|
|
93
|
+
parts = resp.split()
|
|
94
|
+
token = parts[1]
|
|
95
|
+
lease = int(parts[2]) if len(parts) >= 3 else 30
|
|
96
|
+
return ("acquired", token, lease)
|
|
97
|
+
if resp == "queued":
|
|
98
|
+
return ("queued", None, None)
|
|
99
|
+
raise RuntimeError(f"enqueue failed: {resp!r}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def wait(
|
|
103
|
+
reader: asyncio.StreamReader,
|
|
104
|
+
writer: asyncio.StreamWriter,
|
|
105
|
+
key: str,
|
|
106
|
+
wait_timeout_s: int,
|
|
107
|
+
) -> tuple[str, int]:
|
|
108
|
+
"""
|
|
109
|
+
Two-phase wait: block until lock is granted.
|
|
110
|
+
Returns (token, lease). Raises TimeoutError on timeout.
|
|
111
|
+
"""
|
|
112
|
+
writer.write(_encode_lines("w", key, str(wait_timeout_s)))
|
|
113
|
+
await writer.drain()
|
|
114
|
+
|
|
115
|
+
resp = await _readline(reader)
|
|
116
|
+
if resp == "timeout":
|
|
117
|
+
raise TimeoutError(f"timeout waiting for {key!r}")
|
|
118
|
+
if not resp.startswith("ok "):
|
|
119
|
+
raise RuntimeError(f"wait failed: {resp!r}")
|
|
120
|
+
|
|
121
|
+
parts = resp.split()
|
|
122
|
+
token = parts[1]
|
|
123
|
+
lease = int(parts[2]) if len(parts) >= 3 else 30
|
|
124
|
+
return token, lease
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def release(
|
|
128
|
+
reader: asyncio.StreamReader, writer: asyncio.StreamWriter, key: str, token: str
|
|
129
|
+
) -> None:
|
|
130
|
+
writer.write(_encode_lines("r", key, token))
|
|
131
|
+
await writer.drain()
|
|
132
|
+
|
|
133
|
+
resp = await _readline(reader)
|
|
134
|
+
if resp != "ok":
|
|
135
|
+
raise RuntimeError(f"release failed: {resp!r}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class DistributedLock:
|
|
140
|
+
key: str
|
|
141
|
+
acquire_timeout_s: int = 10
|
|
142
|
+
lease_ttl_s: int | None = None # if None, server default
|
|
143
|
+
servers: list[tuple[str, int]] = field(
|
|
144
|
+
default_factory=lambda: list(DEFAULT_SERVERS)
|
|
145
|
+
)
|
|
146
|
+
sharding_strategy: ShardingStrategy = stable_hash_shard
|
|
147
|
+
renew_ratio: float = 0.5 # renew at lease * ratio
|
|
148
|
+
|
|
149
|
+
_reader: asyncio.StreamReader | None = None
|
|
150
|
+
_writer: asyncio.StreamWriter | None = None
|
|
151
|
+
token: str | None = None
|
|
152
|
+
lease: int = 0
|
|
153
|
+
_renew_task: asyncio.Task | None = None
|
|
154
|
+
_closed: bool = False
|
|
155
|
+
|
|
156
|
+
def __post_init__(self):
|
|
157
|
+
if not self.servers:
|
|
158
|
+
raise ValueError("servers must be a non-empty list")
|
|
159
|
+
|
|
160
|
+
def _pick_server(self) -> tuple[str, int]:
|
|
161
|
+
idx = self.sharding_strategy(self.key, len(self.servers))
|
|
162
|
+
return self.servers[idx % len(self.servers)]
|
|
163
|
+
|
|
164
|
+
async def acquire(self) -> bool:
|
|
165
|
+
self._closed = False
|
|
166
|
+
host, port = self._pick_server()
|
|
167
|
+
self._reader, self._writer = await asyncio.open_connection(host, port)
|
|
168
|
+
try:
|
|
169
|
+
self.token, self.lease = await acquire(
|
|
170
|
+
self._reader,
|
|
171
|
+
self._writer,
|
|
172
|
+
self.key,
|
|
173
|
+
self.acquire_timeout_s,
|
|
174
|
+
self.lease_ttl_s,
|
|
175
|
+
)
|
|
176
|
+
except TimeoutError:
|
|
177
|
+
await self.aclose()
|
|
178
|
+
return False
|
|
179
|
+
except BaseException:
|
|
180
|
+
await self.aclose()
|
|
181
|
+
raise
|
|
182
|
+
# Start renew loop
|
|
183
|
+
self._renew_task = asyncio.create_task(self._renew_loop())
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
async def enqueue(self) -> str:
|
|
187
|
+
"""
|
|
188
|
+
Two-phase step 1: connect and enqueue. Returns "acquired" or "queued".
|
|
189
|
+
Starts renew loop on fast-path acquire.
|
|
190
|
+
"""
|
|
191
|
+
self._closed = False
|
|
192
|
+
host, port = self._pick_server()
|
|
193
|
+
self._reader, self._writer = await asyncio.open_connection(host, port)
|
|
194
|
+
try:
|
|
195
|
+
status, tok, lease = await enqueue(
|
|
196
|
+
self._reader, self._writer, self.key, self.lease_ttl_s
|
|
197
|
+
)
|
|
198
|
+
except BaseException:
|
|
199
|
+
await self.aclose()
|
|
200
|
+
raise
|
|
201
|
+
if status == "acquired":
|
|
202
|
+
self.token = tok
|
|
203
|
+
self.lease = lease or 0
|
|
204
|
+
self._renew_task = asyncio.create_task(self._renew_loop())
|
|
205
|
+
return status
|
|
206
|
+
|
|
207
|
+
async def wait(self, timeout_s: int | None = None) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
Two-phase step 2: wait for lock grant. Returns True if granted, False on timeout.
|
|
210
|
+
If already acquired (fast path from enqueue), returns immediately.
|
|
211
|
+
"""
|
|
212
|
+
if self.token is not None:
|
|
213
|
+
# Already acquired during enqueue
|
|
214
|
+
return True
|
|
215
|
+
if self._reader is None or self._writer is None:
|
|
216
|
+
raise RuntimeError("not connected; call enqueue() first")
|
|
217
|
+
timeout = timeout_s if timeout_s is not None else self.acquire_timeout_s
|
|
218
|
+
try:
|
|
219
|
+
self.token, self.lease = await wait(
|
|
220
|
+
self._reader, self._writer, self.key, timeout
|
|
221
|
+
)
|
|
222
|
+
except TimeoutError:
|
|
223
|
+
await self.aclose()
|
|
224
|
+
return False
|
|
225
|
+
except BaseException:
|
|
226
|
+
await self.aclose()
|
|
227
|
+
raise
|
|
228
|
+
self._renew_task = asyncio.create_task(self._renew_loop())
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
async def release(self) -> bool:
|
|
232
|
+
try:
|
|
233
|
+
if self._renew_task:
|
|
234
|
+
self._renew_task.cancel()
|
|
235
|
+
with contextlib.suppress(BaseException):
|
|
236
|
+
await self._renew_task
|
|
237
|
+
|
|
238
|
+
if self._reader and self._writer and self.token:
|
|
239
|
+
await release(self._reader, self._writer, self.key, self.token)
|
|
240
|
+
finally:
|
|
241
|
+
await self.aclose()
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
async def __aenter__(self):
|
|
245
|
+
self._closed = False
|
|
246
|
+
host, port = self._pick_server()
|
|
247
|
+
self._reader, self._writer = await asyncio.open_connection(host, port)
|
|
248
|
+
try:
|
|
249
|
+
self.token, self.lease = await acquire(
|
|
250
|
+
self._reader,
|
|
251
|
+
self._writer,
|
|
252
|
+
self.key,
|
|
253
|
+
self.acquire_timeout_s,
|
|
254
|
+
self.lease_ttl_s,
|
|
255
|
+
)
|
|
256
|
+
except BaseException:
|
|
257
|
+
await self.aclose()
|
|
258
|
+
raise
|
|
259
|
+
# Start renew loop
|
|
260
|
+
self._renew_task = asyncio.create_task(self._renew_loop())
|
|
261
|
+
return self
|
|
262
|
+
|
|
263
|
+
async def _renew_loop(self):
|
|
264
|
+
assert self._reader and self._writer and self.token
|
|
265
|
+
interval = max(1.0, self.lease * self.renew_ratio)
|
|
266
|
+
try:
|
|
267
|
+
while True:
|
|
268
|
+
await asyncio.sleep(interval)
|
|
269
|
+
try:
|
|
270
|
+
await renew(
|
|
271
|
+
self._reader,
|
|
272
|
+
self._writer,
|
|
273
|
+
self.key,
|
|
274
|
+
self.token,
|
|
275
|
+
self.lease_ttl_s,
|
|
276
|
+
)
|
|
277
|
+
except asyncio.CancelledError:
|
|
278
|
+
raise
|
|
279
|
+
except Exception:
|
|
280
|
+
log.error(
|
|
281
|
+
"lock lost (renew failed): key=%s token=%s",
|
|
282
|
+
self.key,
|
|
283
|
+
self.token,
|
|
284
|
+
)
|
|
285
|
+
self.token = None
|
|
286
|
+
return
|
|
287
|
+
except asyncio.CancelledError:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
291
|
+
try:
|
|
292
|
+
if self._renew_task:
|
|
293
|
+
self._renew_task.cancel()
|
|
294
|
+
with contextlib.suppress(BaseException):
|
|
295
|
+
await self._renew_task
|
|
296
|
+
|
|
297
|
+
if self._reader and self._writer and self.token:
|
|
298
|
+
await release(self._reader, self._writer, self.key, self.token)
|
|
299
|
+
finally:
|
|
300
|
+
await self.aclose()
|
|
301
|
+
|
|
302
|
+
async def aclose(self):
|
|
303
|
+
if self._closed:
|
|
304
|
+
return
|
|
305
|
+
self._closed = True
|
|
306
|
+
if self._writer:
|
|
307
|
+
self._writer.close()
|
|
308
|
+
with contextlib.suppress(Exception):
|
|
309
|
+
await self._writer.wait_closed()
|
|
310
|
+
self._reader = None
|
|
311
|
+
self._writer = None
|
|
312
|
+
self.token = None
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Sharding helpers for routing keys to servers."""
|
|
2
|
+
|
|
3
|
+
import zlib
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
ShardingStrategy = Callable[[str, int], int]
|
|
7
|
+
|
|
8
|
+
DEFAULT_SERVERS: list[tuple[str, int]] = [("127.0.0.1", 6388)]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def stable_hash_shard(key: str, num_servers: int) -> int:
|
|
12
|
+
"""Return a server index for *key* using CRC-32.
|
|
13
|
+
|
|
14
|
+
Unlike the built-in ``hash()``, ``zlib.crc32`` is deterministic across
|
|
15
|
+
processes regardless of ``PYTHONHASHSEED``.
|
|
16
|
+
"""
|
|
17
|
+
return zlib.crc32(key.encode("utf-8")) % num_servers
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import logging
|
|
3
|
+
import socket
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .sharding import DEFAULT_SERVERS, ShardingStrategy, stable_hash_shard
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("dflockd-client")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _encode_lines(*lines: str) -> bytes:
|
|
13
|
+
return ("".join(f"{ln}\n" for ln in lines)).encode("utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _readline(rfile: io.TextIOWrapper) -> str:
|
|
17
|
+
raw = rfile.readline()
|
|
18
|
+
if raw == "":
|
|
19
|
+
raise ConnectionError("server closed connection")
|
|
20
|
+
return raw.rstrip("\r\n")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def acquire(
|
|
24
|
+
sock: socket.socket,
|
|
25
|
+
rfile: io.TextIOWrapper,
|
|
26
|
+
key: str,
|
|
27
|
+
acquire_timeout_s: int,
|
|
28
|
+
lease_ttl_s: int | None = None,
|
|
29
|
+
) -> tuple[str, int]:
|
|
30
|
+
arg = (
|
|
31
|
+
str(acquire_timeout_s)
|
|
32
|
+
if lease_ttl_s is None
|
|
33
|
+
else f"{acquire_timeout_s} {lease_ttl_s}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
sock.sendall(_encode_lines("l", key, arg))
|
|
37
|
+
|
|
38
|
+
resp = _readline(rfile)
|
|
39
|
+
if resp == "timeout":
|
|
40
|
+
raise TimeoutError(f"timeout acquiring {key!r}")
|
|
41
|
+
if not resp.startswith("ok "):
|
|
42
|
+
raise RuntimeError(f"acquire failed: {resp!r}")
|
|
43
|
+
|
|
44
|
+
parts = resp.split()
|
|
45
|
+
if len(parts) < 2:
|
|
46
|
+
raise RuntimeError(f"bad ok response: {resp!r}")
|
|
47
|
+
token = parts[1]
|
|
48
|
+
lease = int(parts[2]) if len(parts) >= 3 else 30
|
|
49
|
+
return token, lease
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def renew(
|
|
53
|
+
sock: socket.socket,
|
|
54
|
+
rfile: io.TextIOWrapper,
|
|
55
|
+
key: str,
|
|
56
|
+
token: str,
|
|
57
|
+
lease_ttl_s: int | None = None,
|
|
58
|
+
) -> int:
|
|
59
|
+
arg = token if lease_ttl_s is None else f"{token} {lease_ttl_s}"
|
|
60
|
+
sock.sendall(_encode_lines("n", key, arg))
|
|
61
|
+
|
|
62
|
+
resp = _readline(rfile)
|
|
63
|
+
if not resp.startswith("ok"):
|
|
64
|
+
raise RuntimeError(f"renew failed: {resp!r}")
|
|
65
|
+
|
|
66
|
+
parts = resp.split()
|
|
67
|
+
if len(parts) >= 2 and parts[1].isdigit():
|
|
68
|
+
return int(parts[1])
|
|
69
|
+
return -1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def enqueue(
|
|
73
|
+
sock: socket.socket,
|
|
74
|
+
rfile: io.TextIOWrapper,
|
|
75
|
+
key: str,
|
|
76
|
+
lease_ttl_s: int | None = None,
|
|
77
|
+
) -> tuple[str, str | None, int | None]:
|
|
78
|
+
"""
|
|
79
|
+
Two-phase enqueue: join FIFO queue, return immediately.
|
|
80
|
+
Returns (status, token, lease) where status is "acquired" or "queued".
|
|
81
|
+
"""
|
|
82
|
+
arg = "" if lease_ttl_s is None else str(lease_ttl_s)
|
|
83
|
+
sock.sendall(_encode_lines("e", key, arg))
|
|
84
|
+
|
|
85
|
+
resp = _readline(rfile)
|
|
86
|
+
if resp.startswith("acquired "):
|
|
87
|
+
parts = resp.split()
|
|
88
|
+
token = parts[1]
|
|
89
|
+
lease = int(parts[2]) if len(parts) >= 3 else 30
|
|
90
|
+
return ("acquired", token, lease)
|
|
91
|
+
if resp == "queued":
|
|
92
|
+
return ("queued", None, None)
|
|
93
|
+
raise RuntimeError(f"enqueue failed: {resp!r}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def wait(
|
|
97
|
+
sock: socket.socket,
|
|
98
|
+
rfile: io.TextIOWrapper,
|
|
99
|
+
key: str,
|
|
100
|
+
wait_timeout_s: int,
|
|
101
|
+
) -> tuple[str, int]:
|
|
102
|
+
"""
|
|
103
|
+
Two-phase wait: block until lock is granted.
|
|
104
|
+
Returns (token, lease). Raises TimeoutError on timeout.
|
|
105
|
+
"""
|
|
106
|
+
sock.sendall(_encode_lines("w", key, str(wait_timeout_s)))
|
|
107
|
+
|
|
108
|
+
resp = _readline(rfile)
|
|
109
|
+
if resp == "timeout":
|
|
110
|
+
raise TimeoutError(f"timeout waiting for {key!r}")
|
|
111
|
+
if not resp.startswith("ok "):
|
|
112
|
+
raise RuntimeError(f"wait failed: {resp!r}")
|
|
113
|
+
|
|
114
|
+
parts = resp.split()
|
|
115
|
+
token = parts[1]
|
|
116
|
+
lease = int(parts[2]) if len(parts) >= 3 else 30
|
|
117
|
+
return token, lease
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def release(sock: socket.socket, rfile: io.TextIOWrapper, key: str, token: str) -> None:
|
|
121
|
+
sock.sendall(_encode_lines("r", key, token))
|
|
122
|
+
|
|
123
|
+
resp = _readline(rfile)
|
|
124
|
+
if resp != "ok":
|
|
125
|
+
raise RuntimeError(f"release failed: {resp!r}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class DistributedLock:
|
|
130
|
+
key: str
|
|
131
|
+
acquire_timeout_s: int = 10
|
|
132
|
+
lease_ttl_s: int | None = None
|
|
133
|
+
servers: list[tuple[str, int]] = field(
|
|
134
|
+
default_factory=lambda: list(DEFAULT_SERVERS)
|
|
135
|
+
)
|
|
136
|
+
sharding_strategy: ShardingStrategy = stable_hash_shard
|
|
137
|
+
renew_ratio: float = 0.5
|
|
138
|
+
|
|
139
|
+
_sock: socket.socket | None = field(default=None, repr=False)
|
|
140
|
+
_rfile: io.TextIOWrapper | None = field(default=None, repr=False)
|
|
141
|
+
token: str | None = None
|
|
142
|
+
lease: int = 0
|
|
143
|
+
_renew_thread: threading.Thread | None = field(default=None, repr=False)
|
|
144
|
+
_stop_event: threading.Event = field(default_factory=threading.Event, repr=False)
|
|
145
|
+
_closed: bool = False
|
|
146
|
+
|
|
147
|
+
def __post_init__(self):
|
|
148
|
+
if not self.servers:
|
|
149
|
+
raise ValueError("servers must be a non-empty list")
|
|
150
|
+
|
|
151
|
+
def _pick_server(self) -> tuple[str, int]:
|
|
152
|
+
idx = self.sharding_strategy(self.key, len(self.servers))
|
|
153
|
+
return self.servers[idx % len(self.servers)]
|
|
154
|
+
|
|
155
|
+
def _connect(self):
|
|
156
|
+
self._closed = False
|
|
157
|
+
self._stop_event.clear()
|
|
158
|
+
host, port = self._pick_server()
|
|
159
|
+
self._sock = socket.create_connection((host, port))
|
|
160
|
+
self._rfile = self._sock.makefile("r", encoding="utf-8")
|
|
161
|
+
|
|
162
|
+
def _start_renew(self):
|
|
163
|
+
self._renew_thread = threading.Thread(target=self._renew_loop, daemon=True)
|
|
164
|
+
self._renew_thread.start()
|
|
165
|
+
|
|
166
|
+
def _stop_renew(self):
|
|
167
|
+
if self._renew_thread is not None:
|
|
168
|
+
self._stop_event.set()
|
|
169
|
+
self._renew_thread.join(timeout=5)
|
|
170
|
+
self._renew_thread = None
|
|
171
|
+
|
|
172
|
+
def acquire(self) -> bool:
|
|
173
|
+
self._connect()
|
|
174
|
+
sock, rfile = self._sock, self._rfile
|
|
175
|
+
assert sock is not None and rfile is not None
|
|
176
|
+
try:
|
|
177
|
+
self.token, self.lease = acquire(
|
|
178
|
+
sock,
|
|
179
|
+
rfile,
|
|
180
|
+
self.key,
|
|
181
|
+
self.acquire_timeout_s,
|
|
182
|
+
self.lease_ttl_s,
|
|
183
|
+
)
|
|
184
|
+
except TimeoutError:
|
|
185
|
+
self.close()
|
|
186
|
+
return False
|
|
187
|
+
except BaseException:
|
|
188
|
+
self.close()
|
|
189
|
+
raise
|
|
190
|
+
self._start_renew()
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
def enqueue(self) -> str:
|
|
194
|
+
"""
|
|
195
|
+
Two-phase step 1: connect and enqueue. Returns "acquired" or "queued".
|
|
196
|
+
Starts renew loop on fast-path acquire.
|
|
197
|
+
"""
|
|
198
|
+
self._connect()
|
|
199
|
+
sock, rfile = self._sock, self._rfile
|
|
200
|
+
assert sock is not None and rfile is not None
|
|
201
|
+
try:
|
|
202
|
+
status, tok, lease = enqueue(sock, rfile, self.key, self.lease_ttl_s)
|
|
203
|
+
except BaseException:
|
|
204
|
+
self.close()
|
|
205
|
+
raise
|
|
206
|
+
if status == "acquired":
|
|
207
|
+
self.token = tok
|
|
208
|
+
self.lease = lease or 0
|
|
209
|
+
self._start_renew()
|
|
210
|
+
return status
|
|
211
|
+
|
|
212
|
+
def wait(self, timeout_s: int | None = None) -> bool:
|
|
213
|
+
"""
|
|
214
|
+
Two-phase step 2: wait for lock grant. Returns True if granted, False on timeout.
|
|
215
|
+
If already acquired (fast path from enqueue), returns immediately.
|
|
216
|
+
"""
|
|
217
|
+
if self.token is not None:
|
|
218
|
+
return True
|
|
219
|
+
sock, rfile = self._sock, self._rfile
|
|
220
|
+
if sock is None or rfile is None:
|
|
221
|
+
raise RuntimeError("not connected; call enqueue() first")
|
|
222
|
+
timeout = timeout_s if timeout_s is not None else self.acquire_timeout_s
|
|
223
|
+
try:
|
|
224
|
+
self.token, self.lease = wait(sock, rfile, self.key, timeout)
|
|
225
|
+
except TimeoutError:
|
|
226
|
+
self.close()
|
|
227
|
+
return False
|
|
228
|
+
except BaseException:
|
|
229
|
+
self.close()
|
|
230
|
+
raise
|
|
231
|
+
self._start_renew()
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
def release(self) -> bool:
|
|
235
|
+
try:
|
|
236
|
+
self._stop_renew()
|
|
237
|
+
sock, rfile = self._sock, self._rfile
|
|
238
|
+
if sock is not None and rfile is not None and self.token:
|
|
239
|
+
release(sock, rfile, self.key, self.token)
|
|
240
|
+
finally:
|
|
241
|
+
self.close()
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
def __enter__(self):
|
|
245
|
+
self._connect()
|
|
246
|
+
sock, rfile = self._sock, self._rfile
|
|
247
|
+
assert sock is not None and rfile is not None
|
|
248
|
+
try:
|
|
249
|
+
self.token, self.lease = acquire(
|
|
250
|
+
sock,
|
|
251
|
+
rfile,
|
|
252
|
+
self.key,
|
|
253
|
+
self.acquire_timeout_s,
|
|
254
|
+
self.lease_ttl_s,
|
|
255
|
+
)
|
|
256
|
+
except BaseException:
|
|
257
|
+
self.close()
|
|
258
|
+
raise
|
|
259
|
+
self._start_renew()
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
def _renew_loop(self):
|
|
263
|
+
sock, rfile, token = self._sock, self._rfile, self.token
|
|
264
|
+
assert sock is not None and rfile is not None and token is not None
|
|
265
|
+
interval = max(1.0, self.lease * self.renew_ratio)
|
|
266
|
+
while not self._stop_event.wait(interval):
|
|
267
|
+
try:
|
|
268
|
+
renew(sock, rfile, self.key, token, self.lease_ttl_s)
|
|
269
|
+
except Exception:
|
|
270
|
+
log.error(
|
|
271
|
+
"lock lost (renew failed): key=%s token=%s",
|
|
272
|
+
self.key,
|
|
273
|
+
self.token,
|
|
274
|
+
)
|
|
275
|
+
self.token = None
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
def __exit__(self, exc_type, exc, tb):
|
|
279
|
+
try:
|
|
280
|
+
self._stop_renew()
|
|
281
|
+
sock, rfile = self._sock, self._rfile
|
|
282
|
+
if sock is not None and rfile is not None and self.token:
|
|
283
|
+
release(sock, rfile, self.key, self.token)
|
|
284
|
+
finally:
|
|
285
|
+
self.close()
|
|
286
|
+
|
|
287
|
+
def close(self):
|
|
288
|
+
if self._closed:
|
|
289
|
+
return
|
|
290
|
+
self._closed = True
|
|
291
|
+
if self._rfile:
|
|
292
|
+
try:
|
|
293
|
+
self._rfile.close()
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
if self._sock:
|
|
297
|
+
try:
|
|
298
|
+
self._sock.close()
|
|
299
|
+
except Exception:
|
|
300
|
+
pass
|
|
301
|
+
self._rfile = None
|
|
302
|
+
self._sock = None
|
|
303
|
+
self.token = None
|