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.
@@ -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