fencekit 0.1.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.
- fencekit-0.1.0/.gitignore +19 -0
- fencekit-0.1.0/CHANGELOG.md +18 -0
- fencekit-0.1.0/DESIGN.md +226 -0
- fencekit-0.1.0/LICENSE +21 -0
- fencekit-0.1.0/PKG-INFO +148 -0
- fencekit-0.1.0/README.md +116 -0
- fencekit-0.1.0/pyproject.toml +100 -0
- fencekit-0.1.0/src/fencekit/__init__.py +38 -0
- fencekit-0.1.0/src/fencekit/_version.py +1 -0
- fencekit-0.1.0/src/fencekit/canonicalize.py +85 -0
- fencekit-0.1.0/src/fencekit/client.py +92 -0
- fencekit-0.1.0/src/fencekit/errors.py +42 -0
- fencekit-0.1.0/src/fencekit/fence.py +114 -0
- fencekit-0.1.0/src/fencekit/idempotency.py +81 -0
- fencekit-0.1.0/src/fencekit/keys.py +41 -0
- fencekit-0.1.0/src/fencekit/lock.py +167 -0
- fencekit-0.1.0/src/fencekit/py.typed +0 -0
- fencekit-0.1.0/src/fencekit/scripts.py +96 -0
- fencekit-0.1.0/src/fencekit/types.py +42 -0
- fencekit-0.1.0/tests/__init__.py +0 -0
- fencekit-0.1.0/tests/conftest.py +43 -0
- fencekit-0.1.0/tests/integration/__init__.py +0 -0
- fencekit-0.1.0/tests/integration/test_redis_primitives.py +126 -0
- fencekit-0.1.0/tests/property/__init__.py +0 -0
- fencekit-0.1.0/tests/property/test_fencing_expiry.py +152 -0
- fencekit-0.1.0/tests/property/test_idempotency.py +60 -0
- fencekit-0.1.0/tests/unit/__init__.py +0 -0
- fencekit-0.1.0/tests/unit/test_canonicalize.py +79 -0
- fencekit-0.1.0/tests/unit/test_errors.py +50 -0
- fencekit-0.1.0/tests/unit/test_lock_unit.py +79 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-07-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `idempotency_key` with deterministic JSON canonicalization
|
|
13
|
+
- owner-bound `IdempotencyGuard` via Redis `SET NX EX`
|
|
14
|
+
- `DistributedLock` with atomic acquire + fencing token (Lua)
|
|
15
|
+
- `FenceGate` with atomic fenced Redis string writes
|
|
16
|
+
- Typed public API (`py.typed`), errors, and key helpers
|
|
17
|
+
- Unit, property (Hypothesis), and Redis integration tests
|
|
18
|
+
- `DESIGN.md` documenting guarantees, non-guarantees, and crash semantics
|
fencekit-0.1.0/DESIGN.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Design: fencekit
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
fencekit coordinates repeated delivery of background jobs and rejects stale
|
|
6
|
+
writes through Redis fencing tokens. Celery orchestration and Redlock are outside
|
|
7
|
+
its scope. The library makes no exactly-once delivery claim.
|
|
8
|
+
|
|
9
|
+
## Origin
|
|
10
|
+
|
|
11
|
+
ChessMate's Celery jobs analyze imported games with Stockfish and then write coaching
|
|
12
|
+
reports. Worker crashes caused some jobs to be delivered again. The same batch could
|
|
13
|
+
run twice. Duplicate runs wasted Stockfish CPU and left the saved progress ambiguous.
|
|
14
|
+
fencekit contains the Redis operations pulled from those tasks. Its tests reproduce
|
|
15
|
+
worker death and lock-expiry races.
|
|
16
|
+
|
|
17
|
+
## Threat model
|
|
18
|
+
|
|
19
|
+
Trusted: Redis is trusted infrastructure in v0.1 (no authz inside the library;
|
|
20
|
+
use Redis ACLs/network policy at the ops layer). Callers are trusted not to forge
|
|
21
|
+
`owner_id` for locks they do not hold (owner IDs are random UUIDs per acquire).
|
|
22
|
+
|
|
23
|
+
Untrusted / hostile: concurrent workers, process kill mid-task, GC pauses
|
|
24
|
+
longer than lock TTL, network delay delivering stale writes, Celery redelivery.
|
|
25
|
+
|
|
26
|
+
Redis administrators are outside the threat model. So are compromised workers that
|
|
27
|
+
bypass the API. The v0.1 model excludes split-brain and multi-primary failover.
|
|
28
|
+
|
|
29
|
+
## Algorithms
|
|
30
|
+
|
|
31
|
+
### Idempotency
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
SET {prefix}:idem:{namespace}:{sha256} "pending:{owner_id}" NX EX ttl
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Only one caller begins. `mark_done` uses Lua to compare the guard owner before
|
|
38
|
+
setting `done` (with an optional TTL refresh), so a stale worker cannot complete
|
|
39
|
+
a key reclaimed by another worker after expiry.
|
|
40
|
+
|
|
41
|
+
Canonicalization rules:
|
|
42
|
+
|
|
43
|
+
- top-level payload and nested objects are mappings with string keys;
|
|
44
|
+
- mapping keys are sorted recursively; sequence order is preserved;
|
|
45
|
+
- JSON uses compact separators, UTF-8 input, and `ensure_ascii=True`;
|
|
46
|
+
- integers and floats retain JSON's distinct spellings (`1` vs `1.0`);
|
|
47
|
+
- NaN/infinity, bytes, sets, datetimes, and custom objects are rejected;
|
|
48
|
+
- the key suffix is lowercase SHA-256 hex of the canonical JSON bytes; and
|
|
49
|
+
- namespaces are non-empty and contain neither whitespace nor `:`.
|
|
50
|
+
|
|
51
|
+
Callers must normalize business values (for example, datetime ISO format and
|
|
52
|
+
case normalization) before calling `idempotency_key`. Key stability is part of
|
|
53
|
+
the producer contract.
|
|
54
|
+
|
|
55
|
+
### Lock + fencing token (atomic)
|
|
56
|
+
|
|
57
|
+
Lua on acquire:
|
|
58
|
+
|
|
59
|
+
```lua
|
|
60
|
+
-- KEYS[1]=lock, KEYS[2]=sequence; ARGV[1]=owner, ARGV[2]=ttl_ms
|
|
61
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then
|
|
62
|
+
return false
|
|
63
|
+
end
|
|
64
|
+
local token = redis.call('INCR', KEYS[2])
|
|
65
|
+
if redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2], 'NX') then
|
|
66
|
+
return token
|
|
67
|
+
end
|
|
68
|
+
return false
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The script is atomic: no other client can acquire between `EXISTS` and `SET`.
|
|
72
|
+
`INCR` precedes `SET` because Redis scripts do not roll back earlier
|
|
73
|
+
writes after a runtime error. A corrupt or overflowing counter therefore cannot
|
|
74
|
+
leave a lock without a token. Failed attempts can consume a token only if the
|
|
75
|
+
final `SET` unexpectedly fails. Gaps are safe; the counter only needs to increase.
|
|
76
|
+
|
|
77
|
+
Release and extend compare ownership in the same script as the mutation:
|
|
78
|
+
|
|
79
|
+
```lua
|
|
80
|
+
-- release: KEYS[1]=lock, ARGV[1]=owner
|
|
81
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
82
|
+
return redis.call('DEL', KEYS[1])
|
|
83
|
+
end
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
-- extend: KEYS[1]=lock, ARGV[1]=owner, ARGV[2]=ttl_ms
|
|
87
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
88
|
+
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
89
|
+
end
|
|
90
|
+
return 0
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This prevents a delayed owner from deleting or extending a replacement lease.
|
|
94
|
+
|
|
95
|
+
### Fence gate
|
|
96
|
+
|
|
97
|
+
```lua
|
|
98
|
+
local max = tonumber(redis.call('GET', max_key) or '0')
|
|
99
|
+
if tonumber(token) < max then return 0 end -- stale
|
|
100
|
+
redis.call('SET', max_key, token) -- equal token may rewrite
|
|
101
|
+
return 1
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`check` and `check_and_advance` operate only on the token registry. They are
|
|
105
|
+
useful for diagnostics and coordination, but a check followed by a separate
|
|
106
|
+
write has a time-of-check/time-of-use race. For Redis string state,
|
|
107
|
+
`FenceGate.set_if_fresh` checks the token and writes the target in one Lua script.
|
|
108
|
+
The script also records the accepted token. Other Redis data types require an
|
|
109
|
+
equivalent application Lua script. External stores must compare the token in the
|
|
110
|
+
same transaction or statement as their mutation.
|
|
111
|
+
|
|
112
|
+
The Redis string helper is:
|
|
113
|
+
|
|
114
|
+
```lua
|
|
115
|
+
-- KEYS[1]=max_key, KEYS[2]=target; ARGV[1]=token, ARGV[2]=value
|
|
116
|
+
local max = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
117
|
+
local token = tonumber(ARGV[1])
|
|
118
|
+
if token < max then return 0 end
|
|
119
|
+
redis.call('SET', KEYS[1], ARGV[1])
|
|
120
|
+
redis.call('SET', KEYS[2], ARGV[2])
|
|
121
|
+
return 1
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Martin Kleppmann describes the stale-holder problem in
|
|
125
|
+
[How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html).
|
|
126
|
+
TTL locks cannot stop a paused holder, so **the storage layer must reject stale
|
|
127
|
+
tokens**. Redis's
|
|
128
|
+
[distributed-lock documentation](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/)
|
|
129
|
+
also motivates random owner values and compare-before-delete release. fencekit
|
|
130
|
+
uses that ownership rule and does not implement Redlock.
|
|
131
|
+
|
|
132
|
+
## Guarantees
|
|
133
|
+
|
|
134
|
+
| Claim | Status |
|
|
135
|
+
|-------|--------|
|
|
136
|
+
| At-most-once *start* within the idempotency TTL (Redis up) | Yes |
|
|
137
|
+
| Mutual exclusion while lease held (single primary) | Best-effort |
|
|
138
|
+
| Stale holder cannot mutate via atomic `set_if_fresh` | Yes |
|
|
139
|
+
| Exactly-once delivery | No |
|
|
140
|
+
| HA / failover / Redlock safety | No |
|
|
141
|
+
| Writes that skip the token | No |
|
|
142
|
+
|
|
143
|
+
At-most-once side effects require the application storage layer to combine the
|
|
144
|
+
fencing check with a unique business-operation key in one transaction. A fence
|
|
145
|
+
prevents stale overwrites. It cannot make a non-idempotent external API, such as
|
|
146
|
+
a card charge, execute exactly once.
|
|
147
|
+
|
|
148
|
+
## Non-guarantees and risks
|
|
149
|
+
|
|
150
|
+
1. Redlock. fencekit does not implement multi-key majority locking. Its v0.1
|
|
151
|
+
safety model uses one Redis primary and storage-side fencing.
|
|
152
|
+
2. Single Redis primary. Asynchronous failover may allow overlapping leases
|
|
153
|
+
or roll back a fence counter, so do not claim HA mutual exclusion or global
|
|
154
|
+
token monotonicity across failover. Redis Cluster is unsupported in v0.1;
|
|
155
|
+
multi-key Lua scripts also require keys in one hash slot.
|
|
156
|
+
3. Clock skew. Redis server `PX` controls expiry. Worker-to-server skew does not
|
|
157
|
+
decide ownership. A Redis server clock jump can still make leases expire early
|
|
158
|
+
or late. Do not implement "lock valid until" on the worker wall clock; blocking
|
|
159
|
+
waits use the client monotonic clock only.
|
|
160
|
+
4. Cooperative storage. Postgres (or S3, etc.) must also compare tokens in
|
|
161
|
+
the mutation itself. A separate preflight check cannot protect another store.
|
|
162
|
+
5. GC pause > TTL. A newer token fences out the paused holder after its lease
|
|
163
|
+
expires.
|
|
164
|
+
6. Idempotency TTL. Too short means a duplicate begin after expiry. Too long can
|
|
165
|
+
leave a key stuck in `pending` if a worker dies without `mark_done`/`clear`.
|
|
166
|
+
Align with the job SLA; v0.2 may add explicit takeover rules.
|
|
167
|
+
7. Counter persistence and overflow. Fence counters intentionally do not
|
|
168
|
+
expire. Deleting/restoring/rolling them back can reuse old token values and
|
|
169
|
+
breaks monotonicity. Redis integer overflow makes acquisition fail safely
|
|
170
|
+
before the lock key is written.
|
|
171
|
+
8. Ambiguous network outcomes. A client timeout does not prove Redis did not
|
|
172
|
+
apply a command. A fresh acquire cannot create a second lease while the first
|
|
173
|
+
key remains, but the caller may not know it owns the first lease. Likewise,
|
|
174
|
+
an idempotency `SET` whose reply was lost may suppress work until its TTL.
|
|
175
|
+
Applications must treat connection errors as an unknown outcome and reconcile.
|
|
176
|
+
|
|
177
|
+
## Crash / restart semantics
|
|
178
|
+
|
|
179
|
+
1. Kill while lock valid: lock remains until TTL; redelivery fails acquire /
|
|
180
|
+
`try_begin` while key is pending/done.
|
|
181
|
+
2. Kill after TTL: new owner gets higher token; stale atomic fenced writes
|
|
182
|
+
fail (`FencedOutError`). See `tests/property/test_fencing_expiry.py`.
|
|
183
|
+
3. Celery redelivery: same idempotency key means `try_begin` is False while TTL
|
|
184
|
+
holds.
|
|
185
|
+
4. Partial side effects (temp files, half-written rows) remain an application
|
|
186
|
+
concern; fence every durable mutation.
|
|
187
|
+
|
|
188
|
+
## TTL guidance
|
|
189
|
+
|
|
190
|
+
Set the lock TTL longer than one chunk of work plus its heartbeat margin. Extend
|
|
191
|
+
the lease regularly. Short leases recover faster after worker death than
|
|
192
|
+
multi-hour leases.
|
|
193
|
+
|
|
194
|
+
The idempotency TTL should cover retries and the maximum job duration. Use a longer
|
|
195
|
+
value when a duplicate start after completion would be unsafe.
|
|
196
|
+
|
|
197
|
+
## Key naming
|
|
198
|
+
|
|
199
|
+
| Purpose | Pattern |
|
|
200
|
+
|---------|---------|
|
|
201
|
+
| Idempotency | `{prefix}:idem:{namespace}:{sha256}` |
|
|
202
|
+
| Lock | `{prefix}:lock:{resource}` |
|
|
203
|
+
| Fence sequence | `{prefix}:fence:seq:{resource}` |
|
|
204
|
+
| Fence max | `{prefix}:fence:max:{resource}` |
|
|
205
|
+
|
|
206
|
+
Default `prefix=fencekit`.
|
|
207
|
+
|
|
208
|
+
## Trust boundaries
|
|
209
|
+
|
|
210
|
+
Payloads are JSON-canonicalized; fencekit does not use pickle. The library does
|
|
211
|
+
not log Redis URLs or credentials. v0.1 treats Redis as trusted infrastructure,
|
|
212
|
+
so production deployments should restrict it with network controls and Redis
|
|
213
|
+
ACLs.
|
|
214
|
+
|
|
215
|
+
## Postgres pattern (app-owned)
|
|
216
|
+
|
|
217
|
+
```sql
|
|
218
|
+
UPDATE analysis_job
|
|
219
|
+
SET progress = %(progress)s, fence_token = %(token)s
|
|
220
|
+
WHERE id = %(id)s AND fence_token <= %(token)s;
|
|
221
|
+
-- rowcount 0 => fenced out
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Versioning
|
|
225
|
+
|
|
226
|
+
Semver from 0.1.0. Breaking API changes bump minor while 0.x.
|
fencekit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ahmed Mohamed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
fencekit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fencekit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Redis-backed idempotent task primitives: fencing tokens and distributed locks
|
|
5
|
+
Project-URL: Homepage, https://github.com/ahmed5145/fencekit
|
|
6
|
+
Project-URL: Documentation, https://github.com/ahmed5145/fencekit/blob/main/DESIGN.md
|
|
7
|
+
Project-URL: Changelog, https://github.com/ahmed5145/fencekit/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Repository, https://github.com/ahmed5145/fencekit
|
|
9
|
+
Author-email: Ahmed Mohamed <ahmed5145@users.noreply.github.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: celery,distributed-lock,fencing-token,idempotency,redis
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: redis>=5.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: hypothesis>=6.100; extra == 'dev'
|
|
27
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# fencekit
|
|
34
|
+
|
|
35
|
+
Redis-backed idempotency and fenced locks for background jobs. A destination that
|
|
36
|
+
atomically checks the fencing token can reject writes from a stale lock holder.
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
ChessMate ([chess-mate.online](https://chess-mate.online)) runs imported games
|
|
41
|
+
through Stockfish before writing a coaching report. With Redis as the Celery broker
|
|
42
|
+
and late acknowledgements enabled, a worker crash can cause the same job to be
|
|
43
|
+
delivered again. I saw batches progress twice. That wasted Stockfish CPU and left
|
|
44
|
+
the recorded progress ambiguous.
|
|
45
|
+
|
|
46
|
+
Celery requires late-ack tasks to be idempotent, but each task still needs code to
|
|
47
|
+
enforce that property. fencekit collects the Redis operations I used in ChessMate.
|
|
48
|
+
Its tests reproduce worker death and lock-expiry races.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install fencekit
|
|
54
|
+
# or
|
|
55
|
+
uv add fencekit
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Requires Redis 6+ (tested with Redis 7) and Python 3.10+.
|
|
59
|
+
|
|
60
|
+
## 30-second example
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from datetime import timedelta
|
|
64
|
+
from redis import Redis
|
|
65
|
+
|
|
66
|
+
from fencekit import (
|
|
67
|
+
DistributedLock,
|
|
68
|
+
FenceGate,
|
|
69
|
+
IdempotencyGuard,
|
|
70
|
+
idempotency_key,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
r = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
|
|
74
|
+
guard = IdempotencyGuard(r)
|
|
75
|
+
lock = DistributedLock(r)
|
|
76
|
+
fence = FenceGate(r)
|
|
77
|
+
|
|
78
|
+
def analyze_batch() -> None:
|
|
79
|
+
key = idempotency_key(
|
|
80
|
+
{"game_ids": ["abc", "def"], "engine": "sf16"},
|
|
81
|
+
namespace="analysis",
|
|
82
|
+
)
|
|
83
|
+
if not guard.try_begin(key, ttl=timedelta(hours=24)):
|
|
84
|
+
return # already started or completed
|
|
85
|
+
|
|
86
|
+
handle = lock.acquire("analysis:batch-42", ttl=timedelta(minutes=5))
|
|
87
|
+
try:
|
|
88
|
+
# Atomic fence check + Redis progress write:
|
|
89
|
+
fence.set_if_fresh(handle.token, "analysis:batch-42:status", "running")
|
|
90
|
+
# Extend the lock on heartbeats and fence every durable write.
|
|
91
|
+
guard.mark_done(key)
|
|
92
|
+
finally:
|
|
93
|
+
lock.release(handle)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Guarantees
|
|
97
|
+
|
|
98
|
+
| Claim | Status |
|
|
99
|
+
|-------|--------|
|
|
100
|
+
| At-most-once *start* within the idempotency TTL (Redis available) | Yes (`SET NX`) |
|
|
101
|
+
| Mutual exclusion while lock TTL held (single Redis primary) | Best-effort lease |
|
|
102
|
+
| Stale holder cannot overwrite via atomic `FenceGate.set_if_fresh` | Yes |
|
|
103
|
+
| Exactly-once delivery | No |
|
|
104
|
+
| Safety under Redis failover / split brain | No (v0.1) |
|
|
105
|
+
| Safety if the app writes without presenting the token | No |
|
|
106
|
+
|
|
107
|
+
See [DESIGN.md](DESIGN.md) for the threat model, Lua algorithms, TTL guidance, and
|
|
108
|
+
crash/restart semantics. fencekit does not implement Redlock.
|
|
109
|
+
|
|
110
|
+
Fencing covers stale overwrites. Exactly-once external effects also require the
|
|
111
|
+
destination to enforce the fence token and a unique business-operation key in
|
|
112
|
+
one atomic operation.
|
|
113
|
+
|
|
114
|
+
## Running tests
|
|
115
|
+
|
|
116
|
+
```bat
|
|
117
|
+
REM CMD (Windows), no uv required
|
|
118
|
+
cd fencekit
|
|
119
|
+
python -m pip install -e ".[dev]"
|
|
120
|
+
python -m ruff check --fix src tests
|
|
121
|
+
python -m ruff check src tests
|
|
122
|
+
python -m mypy src
|
|
123
|
+
python -m pytest -m "not integration" -q
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The full suite needs Redis on `localhost:6379`:
|
|
127
|
+
|
|
128
|
+
```bat
|
|
129
|
+
set FENCEKIT_REDIS_URL=redis://localhost:6379/15
|
|
130
|
+
python -m pytest -q
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Integration tests skip cleanly when Redis is unreachable or `FENCEKIT_REDIS_URL` is unset.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# Linux/macOS with uv + Docker
|
|
137
|
+
uv sync --extra dev
|
|
138
|
+
uv run pytest -m "not integration"
|
|
139
|
+
docker run -d -p 6379:6379 --name fencekit-redis redis:7
|
|
140
|
+
export FENCEKIT_REDIS_URL=redis://localhost:6379/15
|
|
141
|
+
uv run pytest
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
v0.1 has no Celery adapter. A later release may add one as an optional extra.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
fencekit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# fencekit
|
|
2
|
+
|
|
3
|
+
Redis-backed idempotency and fenced locks for background jobs. A destination that
|
|
4
|
+
atomically checks the fencing token can reject writes from a stale lock holder.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
ChessMate ([chess-mate.online](https://chess-mate.online)) runs imported games
|
|
9
|
+
through Stockfish before writing a coaching report. With Redis as the Celery broker
|
|
10
|
+
and late acknowledgements enabled, a worker crash can cause the same job to be
|
|
11
|
+
delivered again. I saw batches progress twice. That wasted Stockfish CPU and left
|
|
12
|
+
the recorded progress ambiguous.
|
|
13
|
+
|
|
14
|
+
Celery requires late-ack tasks to be idempotent, but each task still needs code to
|
|
15
|
+
enforce that property. fencekit collects the Redis operations I used in ChessMate.
|
|
16
|
+
Its tests reproduce worker death and lock-expiry races.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install fencekit
|
|
22
|
+
# or
|
|
23
|
+
uv add fencekit
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires Redis 6+ (tested with Redis 7) and Python 3.10+.
|
|
27
|
+
|
|
28
|
+
## 30-second example
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from datetime import timedelta
|
|
32
|
+
from redis import Redis
|
|
33
|
+
|
|
34
|
+
from fencekit import (
|
|
35
|
+
DistributedLock,
|
|
36
|
+
FenceGate,
|
|
37
|
+
IdempotencyGuard,
|
|
38
|
+
idempotency_key,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
r = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
|
|
42
|
+
guard = IdempotencyGuard(r)
|
|
43
|
+
lock = DistributedLock(r)
|
|
44
|
+
fence = FenceGate(r)
|
|
45
|
+
|
|
46
|
+
def analyze_batch() -> None:
|
|
47
|
+
key = idempotency_key(
|
|
48
|
+
{"game_ids": ["abc", "def"], "engine": "sf16"},
|
|
49
|
+
namespace="analysis",
|
|
50
|
+
)
|
|
51
|
+
if not guard.try_begin(key, ttl=timedelta(hours=24)):
|
|
52
|
+
return # already started or completed
|
|
53
|
+
|
|
54
|
+
handle = lock.acquire("analysis:batch-42", ttl=timedelta(minutes=5))
|
|
55
|
+
try:
|
|
56
|
+
# Atomic fence check + Redis progress write:
|
|
57
|
+
fence.set_if_fresh(handle.token, "analysis:batch-42:status", "running")
|
|
58
|
+
# Extend the lock on heartbeats and fence every durable write.
|
|
59
|
+
guard.mark_done(key)
|
|
60
|
+
finally:
|
|
61
|
+
lock.release(handle)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Guarantees
|
|
65
|
+
|
|
66
|
+
| Claim | Status |
|
|
67
|
+
|-------|--------|
|
|
68
|
+
| At-most-once *start* within the idempotency TTL (Redis available) | Yes (`SET NX`) |
|
|
69
|
+
| Mutual exclusion while lock TTL held (single Redis primary) | Best-effort lease |
|
|
70
|
+
| Stale holder cannot overwrite via atomic `FenceGate.set_if_fresh` | Yes |
|
|
71
|
+
| Exactly-once delivery | No |
|
|
72
|
+
| Safety under Redis failover / split brain | No (v0.1) |
|
|
73
|
+
| Safety if the app writes without presenting the token | No |
|
|
74
|
+
|
|
75
|
+
See [DESIGN.md](DESIGN.md) for the threat model, Lua algorithms, TTL guidance, and
|
|
76
|
+
crash/restart semantics. fencekit does not implement Redlock.
|
|
77
|
+
|
|
78
|
+
Fencing covers stale overwrites. Exactly-once external effects also require the
|
|
79
|
+
destination to enforce the fence token and a unique business-operation key in
|
|
80
|
+
one atomic operation.
|
|
81
|
+
|
|
82
|
+
## Running tests
|
|
83
|
+
|
|
84
|
+
```bat
|
|
85
|
+
REM CMD (Windows), no uv required
|
|
86
|
+
cd fencekit
|
|
87
|
+
python -m pip install -e ".[dev]"
|
|
88
|
+
python -m ruff check --fix src tests
|
|
89
|
+
python -m ruff check src tests
|
|
90
|
+
python -m mypy src
|
|
91
|
+
python -m pytest -m "not integration" -q
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The full suite needs Redis on `localhost:6379`:
|
|
95
|
+
|
|
96
|
+
```bat
|
|
97
|
+
set FENCEKIT_REDIS_URL=redis://localhost:6379/15
|
|
98
|
+
python -m pytest -q
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Integration tests skip cleanly when Redis is unreachable or `FENCEKIT_REDIS_URL` is unset.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Linux/macOS with uv + Docker
|
|
105
|
+
uv sync --extra dev
|
|
106
|
+
uv run pytest -m "not integration"
|
|
107
|
+
docker run -d -p 6379:6379 --name fencekit-redis redis:7
|
|
108
|
+
export FENCEKIT_REDIS_URL=redis://localhost:6379/15
|
|
109
|
+
uv run pytest
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
v0.1 has no Celery adapter. A later release may add one as an optional extra.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fencekit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Redis-backed idempotent task primitives: fencing tokens and distributed locks"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Ahmed Mohamed", email = "ahmed5145@users.noreply.github.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"redis",
|
|
17
|
+
"distributed-lock",
|
|
18
|
+
"fencing-token",
|
|
19
|
+
"idempotency",
|
|
20
|
+
"celery",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Topic :: System :: Distributed Computing",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"redis>=5.0.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/ahmed5145/fencekit"
|
|
40
|
+
Documentation = "https://github.com/ahmed5145/fencekit/blob/main/DESIGN.md"
|
|
41
|
+
Changelog = "https://github.com/ahmed5145/fencekit/blob/main/CHANGELOG.md"
|
|
42
|
+
Repository = "https://github.com/ahmed5145/fencekit"
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest>=8.0",
|
|
47
|
+
"pytest-timeout>=2.3",
|
|
48
|
+
"hypothesis>=6.100",
|
|
49
|
+
"ruff>=0.8",
|
|
50
|
+
"mypy>=1.13",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["src/fencekit"]
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.sdist]
|
|
57
|
+
include = [
|
|
58
|
+
"/src",
|
|
59
|
+
"/tests",
|
|
60
|
+
"/README.md",
|
|
61
|
+
"/DESIGN.md",
|
|
62
|
+
"/CHANGELOG.md",
|
|
63
|
+
"/LICENSE",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
minversion = "8.0"
|
|
68
|
+
testpaths = ["tests"]
|
|
69
|
+
markers = [
|
|
70
|
+
"integration: tests that require a real Redis instance (FENCEKIT_REDIS_URL)",
|
|
71
|
+
]
|
|
72
|
+
timeout = 60
|
|
73
|
+
addopts = "-ra"
|
|
74
|
+
|
|
75
|
+
[tool.ruff]
|
|
76
|
+
target-version = "py310"
|
|
77
|
+
line-length = 88
|
|
78
|
+
src = ["src", "tests"]
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint]
|
|
81
|
+
select = [
|
|
82
|
+
"E",
|
|
83
|
+
"F",
|
|
84
|
+
"I",
|
|
85
|
+
"UP",
|
|
86
|
+
"B",
|
|
87
|
+
"SIM",
|
|
88
|
+
"RUF",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
[tool.ruff.lint.isort]
|
|
92
|
+
known-first-party = ["fencekit"]
|
|
93
|
+
|
|
94
|
+
[tool.mypy]
|
|
95
|
+
python_version = "3.10"
|
|
96
|
+
strict = true
|
|
97
|
+
warn_return_any = true
|
|
98
|
+
warn_unused_configs = true
|
|
99
|
+
mypy_path = "src"
|
|
100
|
+
packages = ["fencekit"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Redis idempotency and fenced locks for background jobs."""
|
|
2
|
+
|
|
3
|
+
from fencekit._version import __version__
|
|
4
|
+
from fencekit.canonicalize import canonicalize, idempotency_key
|
|
5
|
+
from fencekit.client import RedisClient, SyncRedis
|
|
6
|
+
from fencekit.errors import (
|
|
7
|
+
CanonicalizeError,
|
|
8
|
+
FencedOutError,
|
|
9
|
+
FenceKitError,
|
|
10
|
+
IdempotencyNotOwned,
|
|
11
|
+
LockNotAcquired,
|
|
12
|
+
LockNotOwned,
|
|
13
|
+
TokenExpiredError,
|
|
14
|
+
)
|
|
15
|
+
from fencekit.fence import FenceGate
|
|
16
|
+
from fencekit.idempotency import IdempotencyGuard
|
|
17
|
+
from fencekit.lock import DistributedLock
|
|
18
|
+
from fencekit.types import FenceToken, LockHandle
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CanonicalizeError",
|
|
22
|
+
"DistributedLock",
|
|
23
|
+
"FenceGate",
|
|
24
|
+
"FenceKitError",
|
|
25
|
+
"FenceToken",
|
|
26
|
+
"FencedOutError",
|
|
27
|
+
"IdempotencyGuard",
|
|
28
|
+
"IdempotencyNotOwned",
|
|
29
|
+
"LockHandle",
|
|
30
|
+
"LockNotAcquired",
|
|
31
|
+
"LockNotOwned",
|
|
32
|
+
"RedisClient",
|
|
33
|
+
"SyncRedis",
|
|
34
|
+
"TokenExpiredError",
|
|
35
|
+
"__version__",
|
|
36
|
+
"canonicalize",
|
|
37
|
+
"idempotency_key",
|
|
38
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|