fencekit 0.1.0__py3-none-any.whl
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/__init__.py +38 -0
- fencekit/_version.py +1 -0
- fencekit/canonicalize.py +85 -0
- fencekit/client.py +92 -0
- fencekit/errors.py +42 -0
- fencekit/fence.py +114 -0
- fencekit/idempotency.py +81 -0
- fencekit/keys.py +41 -0
- fencekit/lock.py +167 -0
- fencekit/py.typed +0 -0
- fencekit/scripts.py +96 -0
- fencekit/types.py +42 -0
- fencekit-0.1.0.dist-info/METADATA +148 -0
- fencekit-0.1.0.dist-info/RECORD +16 -0
- fencekit-0.1.0.dist-info/WHEEL +4 -0
- fencekit-0.1.0.dist-info/licenses/LICENSE +21 -0
fencekit/__init__.py
ADDED
|
@@ -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
|
+
]
|
fencekit/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
fencekit/canonicalize.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Deterministic payload canonicalization for idempotency keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import TypeAlias
|
|
10
|
+
|
|
11
|
+
from fencekit.errors import CanonicalizeError
|
|
12
|
+
|
|
13
|
+
JsonScalar: TypeAlias = str | int | bool | None
|
|
14
|
+
JsonValue: TypeAlias = (
|
|
15
|
+
JsonScalar | float | Mapping[str, "JsonValue"] | Sequence["JsonValue"]
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize(value: object) -> JsonValue:
|
|
20
|
+
if value is None or isinstance(value, (str, int, bool)):
|
|
21
|
+
return value
|
|
22
|
+
if isinstance(value, float):
|
|
23
|
+
if not math.isfinite(value):
|
|
24
|
+
raise CanonicalizeError(
|
|
25
|
+
"non-finite floats (NaN/Inf) are not allowed in idempotency payloads"
|
|
26
|
+
)
|
|
27
|
+
return value
|
|
28
|
+
if isinstance(value, Mapping):
|
|
29
|
+
out: dict[str, JsonValue] = {}
|
|
30
|
+
for key, item in value.items():
|
|
31
|
+
if not isinstance(key, str):
|
|
32
|
+
raise CanonicalizeError(
|
|
33
|
+
f"mapping keys must be str, got {type(key).__name__}"
|
|
34
|
+
)
|
|
35
|
+
out[key] = _normalize(item)
|
|
36
|
+
return out
|
|
37
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
38
|
+
return [_normalize(item) for item in value]
|
|
39
|
+
raise CanonicalizeError(
|
|
40
|
+
f"unsupported type for canonicalization: {type(value).__name__}; "
|
|
41
|
+
"use JSON-serializable values (datetime must be ISO strings)"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def canonicalize(payload: Mapping[str, object]) -> str:
|
|
46
|
+
"""Return a deterministic JSON string for *payload*.
|
|
47
|
+
|
|
48
|
+
Rules:
|
|
49
|
+
|
|
50
|
+
- Only JSON-serializable mappings, sequences, and scalars.
|
|
51
|
+
- Keys are sorted; separators are compact ``(",", ":")``; ``ensure_ascii=True``.
|
|
52
|
+
- Non-finite floats are rejected.
|
|
53
|
+
- ``datetime`` and other objects are rejected. Pass ISO strings yourself.
|
|
54
|
+
|
|
55
|
+
Key stability is the caller's contract: the same business identity must
|
|
56
|
+
serialize the same way across producers.
|
|
57
|
+
"""
|
|
58
|
+
if not isinstance(payload, Mapping):
|
|
59
|
+
raise CanonicalizeError("payload must be a mapping")
|
|
60
|
+
normalized = _normalize(dict(payload))
|
|
61
|
+
assert isinstance(normalized, dict)
|
|
62
|
+
try:
|
|
63
|
+
return json.dumps(
|
|
64
|
+
normalized,
|
|
65
|
+
sort_keys=True,
|
|
66
|
+
separators=(",", ":"),
|
|
67
|
+
ensure_ascii=True,
|
|
68
|
+
allow_nan=False,
|
|
69
|
+
)
|
|
70
|
+
except (TypeError, ValueError) as exc:
|
|
71
|
+
raise CanonicalizeError(str(exc)) from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def idempotency_key(payload: Mapping[str, object], *, namespace: str) -> str:
|
|
75
|
+
"""Build a deterministic idempotency key from *payload* and *namespace*.
|
|
76
|
+
|
|
77
|
+
Returns ``{namespace}:{sha256_hex}``. The idempotency guard and key helpers
|
|
78
|
+
add the Redis prefix.
|
|
79
|
+
"""
|
|
80
|
+
if not namespace or not isinstance(namespace, str):
|
|
81
|
+
raise CanonicalizeError("namespace must be a non-empty str")
|
|
82
|
+
if ":" in namespace or any(character.isspace() for character in namespace):
|
|
83
|
+
raise CanonicalizeError("namespace must not contain ':' or whitespace")
|
|
84
|
+
digest = hashlib.sha256(canonicalize(payload).encode("utf-8")).hexdigest()
|
|
85
|
+
return f"{namespace}:{digest}"
|
fencekit/client.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Sync Redis client protocol and thin redis-py adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def integer_response(value: object) -> int:
|
|
9
|
+
"""Normalize an integer Redis reply without weakening types to ``Any``."""
|
|
10
|
+
if isinstance(value, int):
|
|
11
|
+
return value
|
|
12
|
+
raise TypeError(f"expected integer Redis response, got {type(value).__name__}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class SyncRedis(Protocol):
|
|
17
|
+
"""Minimal sync Redis surface used by fencekit.
|
|
18
|
+
|
|
19
|
+
Designed so an asyncio backend can be added later behind a different
|
|
20
|
+
protocol without changing lock/fence algorithms.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def set(
|
|
24
|
+
self,
|
|
25
|
+
name: str,
|
|
26
|
+
value: str,
|
|
27
|
+
*,
|
|
28
|
+
nx: bool = False,
|
|
29
|
+
xx: bool = False,
|
|
30
|
+
ex: int | None = None,
|
|
31
|
+
px: int | None = None,
|
|
32
|
+
) -> object: ...
|
|
33
|
+
|
|
34
|
+
def get(self, name: str) -> object: ...
|
|
35
|
+
|
|
36
|
+
def delete(self, *names: str) -> object: ...
|
|
37
|
+
|
|
38
|
+
def eval(
|
|
39
|
+
self,
|
|
40
|
+
script: str,
|
|
41
|
+
numkeys: int,
|
|
42
|
+
*keys_and_args: str | int,
|
|
43
|
+
) -> object: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RedisClient:
|
|
47
|
+
"""Adapter around ``redis.Redis`` with ``decode_responses=True`` semantics.
|
|
48
|
+
|
|
49
|
+
Prefer constructing with an existing client::
|
|
50
|
+
|
|
51
|
+
RedisClient(redis.Redis.from_url(url, decode_responses=True))
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, redis: SyncRedis) -> None:
|
|
55
|
+
self._redis = redis
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def raw(self) -> SyncRedis:
|
|
59
|
+
return self._redis
|
|
60
|
+
|
|
61
|
+
def set(
|
|
62
|
+
self,
|
|
63
|
+
name: str,
|
|
64
|
+
value: str,
|
|
65
|
+
*,
|
|
66
|
+
nx: bool = False,
|
|
67
|
+
xx: bool = False,
|
|
68
|
+
ex: int | None = None,
|
|
69
|
+
px: int | None = None,
|
|
70
|
+
) -> bool:
|
|
71
|
+
result = self._redis.set(name, value, nx=nx, xx=xx, ex=ex, px=px)
|
|
72
|
+
# redis-py returns True/None depending on version and NX
|
|
73
|
+
return bool(result)
|
|
74
|
+
|
|
75
|
+
def get(self, name: str) -> str | None:
|
|
76
|
+
value = self._redis.get(name)
|
|
77
|
+
if value is None:
|
|
78
|
+
return None
|
|
79
|
+
if isinstance(value, bytes):
|
|
80
|
+
return value.decode("utf-8")
|
|
81
|
+
return str(value)
|
|
82
|
+
|
|
83
|
+
def delete(self, *names: str) -> int:
|
|
84
|
+
return integer_response(self._redis.delete(*names))
|
|
85
|
+
|
|
86
|
+
def eval(
|
|
87
|
+
self,
|
|
88
|
+
script: str,
|
|
89
|
+
numkeys: int,
|
|
90
|
+
*keys_and_args: str | int,
|
|
91
|
+
) -> object:
|
|
92
|
+
return self._redis.eval(script, numkeys, *keys_and_args)
|
fencekit/errors.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Public exception hierarchy for fencekit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FenceKitError(Exception):
|
|
7
|
+
"""Base error for all fencekit failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LockNotAcquired(FenceKitError):
|
|
11
|
+
"""Raised when a lock could not be acquired within the requested wait."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LockNotOwned(FenceKitError):
|
|
15
|
+
"""Raised when release/extend is attempted without owning the lock.
|
|
16
|
+
|
|
17
|
+
Typical causes: lock TTL expired, another owner holds the key, or the
|
|
18
|
+
handle's owner_id does not match Redis.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IdempotencyNotOwned(FenceKitError):
|
|
23
|
+
"""Raised when a worker cannot complete an idempotency key it does not own."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FencedOutError(FenceKitError):
|
|
27
|
+
"""Raised when a write presents a fencing token older than the gate max.
|
|
28
|
+
|
|
29
|
+
A stale holder receives this error after a newer owner advances the fence.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TokenExpiredError(FenceKitError):
|
|
34
|
+
"""Raised when a lock handle is known to be expired before a write.
|
|
35
|
+
|
|
36
|
+
Optional fast-path for callers that track local expiry; Redis remains the
|
|
37
|
+
source of truth for ownership.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CanonicalizeError(FenceKitError):
|
|
42
|
+
"""Raised when a payload cannot be deterministically canonicalized."""
|
fencekit/fence.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Redis-backed fencing gate (storage-layer token enforcement)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fencekit.client import RedisClient, SyncRedis, integer_response
|
|
6
|
+
from fencekit.errors import FencedOutError
|
|
7
|
+
from fencekit.keys import DEFAULT_PREFIX, KeySpace
|
|
8
|
+
from fencekit.scripts import (
|
|
9
|
+
FENCE_ADVANCE_SCRIPT,
|
|
10
|
+
FENCE_CHECK_SCRIPT,
|
|
11
|
+
FENCED_SET_SCRIPT,
|
|
12
|
+
)
|
|
13
|
+
from fencekit.types import FenceToken
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FenceGate:
|
|
17
|
+
"""Enforce monotonic fencing tokens on a Redis-backed resource gate.
|
|
18
|
+
|
|
19
|
+
Use :meth:`set_if_fresh` to combine a Redis ``SET`` and fence comparison in
|
|
20
|
+
one atomic script. :meth:`check` is diagnostic only: a separate write after
|
|
21
|
+
a check has a time-of-check/time-of-use race.
|
|
22
|
+
|
|
23
|
+
For PostgreSQL (or other stores), reject stale tokens atomically in the
|
|
24
|
+
same statement as the mutation, for example::
|
|
25
|
+
|
|
26
|
+
UPDATE analysis_job
|
|
27
|
+
SET progress = %s, fence_token = %s
|
|
28
|
+
WHERE id = %s AND fence_token <= %s
|
|
29
|
+
|
|
30
|
+
Redis alone does not protect writes that bypass this gate.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
redis: SyncRedis | RedisClient,
|
|
36
|
+
*,
|
|
37
|
+
prefix: str = DEFAULT_PREFIX,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._client = redis if isinstance(redis, RedisClient) else RedisClient(redis)
|
|
40
|
+
self._keys = KeySpace(prefix)
|
|
41
|
+
|
|
42
|
+
def current_max(self, resource: str) -> int:
|
|
43
|
+
"""Return the highest token accepted for *resource* (0 if none)."""
|
|
44
|
+
raw = self._client.get(self._keys.fence_max(resource))
|
|
45
|
+
if raw is None:
|
|
46
|
+
return 0
|
|
47
|
+
return int(raw)
|
|
48
|
+
|
|
49
|
+
def check(self, token: FenceToken) -> None:
|
|
50
|
+
"""Raise :class:`FencedOutError` if *token* is strictly less than max.
|
|
51
|
+
|
|
52
|
+
Equal tokens are allowed (same acquire, multiple writes). This method
|
|
53
|
+
does not advance the gate and must not be used as authorization for a
|
|
54
|
+
later, separate write.
|
|
55
|
+
"""
|
|
56
|
+
max_key = self._keys.fence_max(token.resource)
|
|
57
|
+
result = self._client.eval(
|
|
58
|
+
FENCE_CHECK_SCRIPT,
|
|
59
|
+
1,
|
|
60
|
+
max_key,
|
|
61
|
+
token.value,
|
|
62
|
+
)
|
|
63
|
+
if integer_response(result) != 1:
|
|
64
|
+
raise FencedOutError(
|
|
65
|
+
f"token {token.value} for {token.resource!r} is fenced out "
|
|
66
|
+
f"(max={self.current_max(token.resource)})"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def check_and_advance(self, token: FenceToken) -> None:
|
|
70
|
+
"""Accept *token* if ``token >= max``, then set max to *token*.
|
|
71
|
+
|
|
72
|
+
Raises :class:`FencedOutError` if a *newer* token was already recorded
|
|
73
|
+
(stale-holder rejection). The same token may write repeatedly.
|
|
74
|
+
"""
|
|
75
|
+
max_key = self._keys.fence_max(token.resource)
|
|
76
|
+
result = self._client.eval(
|
|
77
|
+
FENCE_ADVANCE_SCRIPT,
|
|
78
|
+
1,
|
|
79
|
+
max_key,
|
|
80
|
+
token.value,
|
|
81
|
+
)
|
|
82
|
+
if integer_response(result) != 1:
|
|
83
|
+
raise FencedOutError(
|
|
84
|
+
f"token {token.value} for {token.resource!r} is fenced out "
|
|
85
|
+
f"(max={self.current_max(token.resource)})"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def set_if_fresh(self, token: FenceToken, key: str, value: str) -> None:
|
|
89
|
+
"""Atomically set Redis *key* when *token* is not stale.
|
|
90
|
+
|
|
91
|
+
The target write and max-token update execute in one Lua script. Raises
|
|
92
|
+
:class:`FencedOutError` without changing *key* when ``token < max``.
|
|
93
|
+
Equal tokens are accepted so one lock acquisition can update progress
|
|
94
|
+
multiple times. The caller owns *key* and must not point it at a
|
|
95
|
+
fencekit coordination key.
|
|
96
|
+
"""
|
|
97
|
+
if not key or not isinstance(key, str):
|
|
98
|
+
raise ValueError("key must be a non-empty str")
|
|
99
|
+
max_key = self._keys.fence_max(token.resource)
|
|
100
|
+
if key == max_key:
|
|
101
|
+
raise ValueError("target key must not be the fence max key")
|
|
102
|
+
result = self._client.eval(
|
|
103
|
+
FENCED_SET_SCRIPT,
|
|
104
|
+
2,
|
|
105
|
+
max_key,
|
|
106
|
+
key,
|
|
107
|
+
token.value,
|
|
108
|
+
value,
|
|
109
|
+
)
|
|
110
|
+
if integer_response(result) != 1:
|
|
111
|
+
raise FencedOutError(
|
|
112
|
+
f"token {token.value} for {token.resource!r} is fenced out "
|
|
113
|
+
f"(max={self.current_max(token.resource)})"
|
|
114
|
+
)
|
fencekit/idempotency.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Idempotency guard via Redis SET NX EX."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
from fencekit.client import RedisClient, SyncRedis, integer_response
|
|
9
|
+
from fencekit.errors import IdempotencyNotOwned
|
|
10
|
+
from fencekit.keys import DEFAULT_PREFIX, KeySpace
|
|
11
|
+
from fencekit.scripts import MARK_DONE_SCRIPT
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ttl_seconds(ttl: timedelta) -> int:
|
|
15
|
+
seconds = int(ttl.total_seconds())
|
|
16
|
+
if seconds < 1:
|
|
17
|
+
raise ValueError("ttl must be at least 1 second for idempotency keys")
|
|
18
|
+
return seconds
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IdempotencyGuard:
|
|
22
|
+
"""At-most-once *start* of a named unit of work under Redis availability.
|
|
23
|
+
|
|
24
|
+
Uses ``SET key pending:{owner_id} NX EX ttl``. Concurrent workers: only one
|
|
25
|
+
begins, and completion is owner-checked. Pair with
|
|
26
|
+
:class:`~fencekit.lock.DistributedLock` and
|
|
27
|
+
:class:`~fencekit.fence.FenceGate` for long jobs with crash/redelivery.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
redis: SyncRedis | RedisClient,
|
|
33
|
+
*,
|
|
34
|
+
prefix: str = DEFAULT_PREFIX,
|
|
35
|
+
owner_id: str | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._client = redis if isinstance(redis, RedisClient) else RedisClient(redis)
|
|
38
|
+
self._keys = KeySpace(prefix)
|
|
39
|
+
self._owner_id = owner_id or str(uuid.uuid4())
|
|
40
|
+
|
|
41
|
+
def try_begin(self, key: str, *, ttl: timedelta) -> bool:
|
|
42
|
+
"""Return True if this caller won the right to begin work for *key*."""
|
|
43
|
+
redis_key = self._keys.idempotency(key)
|
|
44
|
+
return self._client.set(
|
|
45
|
+
redis_key,
|
|
46
|
+
f"pending:{self._owner_id}",
|
|
47
|
+
nx=True,
|
|
48
|
+
ex=_ttl_seconds(ttl),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def mark_done(self, key: str, *, ttl: timedelta | None = None) -> None:
|
|
52
|
+
"""Mark *key* as done (refresh TTL if provided).
|
|
53
|
+
|
|
54
|
+
Raises :class:`IdempotencyNotOwned` if the key expired, was reclaimed by
|
|
55
|
+
another guard, or was not begun by this guard. This prevents a stale
|
|
56
|
+
worker from completing a newer worker's attempt.
|
|
57
|
+
"""
|
|
58
|
+
redis_key = self._keys.idempotency(key)
|
|
59
|
+
ttl_arg = _ttl_seconds(ttl) if ttl is not None else 0
|
|
60
|
+
result = self._client.eval(
|
|
61
|
+
MARK_DONE_SCRIPT,
|
|
62
|
+
1,
|
|
63
|
+
redis_key,
|
|
64
|
+
self._owner_id,
|
|
65
|
+
ttl_arg,
|
|
66
|
+
)
|
|
67
|
+
if integer_response(result) != 1:
|
|
68
|
+
raise IdempotencyNotOwned(
|
|
69
|
+
f"cannot complete idempotency key {key!r}: not owned or expired"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def clear(self, key: str) -> None:
|
|
73
|
+
"""Delete the idempotency key (intentional replay / admin)."""
|
|
74
|
+
self._client.delete(self._keys.idempotency(key))
|
|
75
|
+
|
|
76
|
+
def status(self, key: str) -> str | None:
|
|
77
|
+
"""Return ``pending``, ``done``, or ``None`` if absent."""
|
|
78
|
+
status = self._client.get(self._keys.idempotency(key))
|
|
79
|
+
if status is not None and status.startswith("pending:"):
|
|
80
|
+
return "pending"
|
|
81
|
+
return status
|
fencekit/keys.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Redis key naming helpers.
|
|
2
|
+
|
|
3
|
+
Default prefix is ``fencekit``. Apps sharing a Redis DB should use distinct
|
|
4
|
+
prefixes to avoid collisions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
DEFAULT_PREFIX = "fencekit"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _validate_segment(name: str, label: str) -> str:
|
|
13
|
+
if not name or not isinstance(name, str):
|
|
14
|
+
raise ValueError(f"{label} must be a non-empty str")
|
|
15
|
+
if any(character.isspace() for character in name):
|
|
16
|
+
raise ValueError(f"{label} must not contain whitespace")
|
|
17
|
+
return name
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KeySpace:
|
|
21
|
+
"""Build namespaced Redis keys for locks, fences, and idempotency."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, prefix: str = DEFAULT_PREFIX) -> None:
|
|
24
|
+
self.prefix = _validate_segment(prefix, "prefix")
|
|
25
|
+
|
|
26
|
+
def idempotency(self, key: str) -> str:
|
|
27
|
+
"""Build ``{prefix}:idem:{namespace}:{sha256}`` from a namespaced key."""
|
|
28
|
+
key = _validate_segment(key, "idempotency key")
|
|
29
|
+
return f"{self.prefix}:idem:{key}"
|
|
30
|
+
|
|
31
|
+
def lock(self, resource: str) -> str:
|
|
32
|
+
resource = _validate_segment(resource, "resource")
|
|
33
|
+
return f"{self.prefix}:lock:{resource}"
|
|
34
|
+
|
|
35
|
+
def fence_seq(self, resource: str) -> str:
|
|
36
|
+
resource = _validate_segment(resource, "resource")
|
|
37
|
+
return f"{self.prefix}:fence:seq:{resource}"
|
|
38
|
+
|
|
39
|
+
def fence_max(self, resource: str) -> str:
|
|
40
|
+
resource = _validate_segment(resource, "resource")
|
|
41
|
+
return f"{self.prefix}:fence:max:{resource}"
|
fencekit/lock.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Distributed lock with atomic fencing-token issuance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager, suppress
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
|
|
11
|
+
from fencekit.client import RedisClient, SyncRedis, integer_response
|
|
12
|
+
from fencekit.errors import LockNotAcquired, LockNotOwned
|
|
13
|
+
from fencekit.keys import DEFAULT_PREFIX, KeySpace
|
|
14
|
+
from fencekit.scripts import ACQUIRE_SCRIPT, EXTEND_SCRIPT, RELEASE_SCRIPT
|
|
15
|
+
from fencekit.types import FenceToken, LockHandle
|
|
16
|
+
|
|
17
|
+
_DEFAULT_RETRY_INTERVAL = timedelta(milliseconds=50)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ttl_ms(ttl: timedelta) -> int:
|
|
21
|
+
ms = int(ttl.total_seconds() * 1000)
|
|
22
|
+
if ms < 1:
|
|
23
|
+
raise ValueError("ttl must be at least 1ms")
|
|
24
|
+
return ms
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DistributedLock:
|
|
28
|
+
"""Single-instance Redis lock with fencing tokens.
|
|
29
|
+
|
|
30
|
+
Acquire uses a Lua script that ``SET``s the lock ``NX PX`` and ``INCR``s the
|
|
31
|
+
fence sequence in one atomic step. Release/extend only succeed if the
|
|
32
|
+
caller still owns the lock key.
|
|
33
|
+
|
|
34
|
+
This is **not** Redlock. See DESIGN.md for guarantees and caveats.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
redis: SyncRedis | RedisClient,
|
|
40
|
+
*,
|
|
41
|
+
prefix: str = DEFAULT_PREFIX,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._client = redis if isinstance(redis, RedisClient) else RedisClient(redis)
|
|
44
|
+
self._keys = KeySpace(prefix)
|
|
45
|
+
|
|
46
|
+
def acquire(
|
|
47
|
+
self,
|
|
48
|
+
resource: str,
|
|
49
|
+
*,
|
|
50
|
+
ttl: timedelta,
|
|
51
|
+
owner_id: str | None = None,
|
|
52
|
+
blocking: bool = False,
|
|
53
|
+
wait: timedelta | None = None,
|
|
54
|
+
retry_interval: timedelta = _DEFAULT_RETRY_INTERVAL,
|
|
55
|
+
) -> LockHandle:
|
|
56
|
+
"""Acquire *resource* and return a :class:`LockHandle` with a fence token.
|
|
57
|
+
|
|
58
|
+
If *blocking* is False (default), raises :class:`LockNotAcquired` on
|
|
59
|
+
contention. If True, retries until *wait* elapses (required when
|
|
60
|
+
blocking).
|
|
61
|
+
"""
|
|
62
|
+
if retry_interval.total_seconds() <= 0:
|
|
63
|
+
raise ValueError("retry_interval must be positive")
|
|
64
|
+
owner = owner_id or str(uuid.uuid4())
|
|
65
|
+
deadline: float | None = None
|
|
66
|
+
if blocking:
|
|
67
|
+
if wait is None:
|
|
68
|
+
raise ValueError("wait is required when blocking=True")
|
|
69
|
+
if wait.total_seconds() < 0:
|
|
70
|
+
raise ValueError("wait must not be negative")
|
|
71
|
+
deadline = time.monotonic() + wait.total_seconds()
|
|
72
|
+
|
|
73
|
+
while True:
|
|
74
|
+
handle = self._try_acquire(resource, ttl=ttl, owner_id=owner)
|
|
75
|
+
if handle is not None:
|
|
76
|
+
return handle
|
|
77
|
+
if not blocking or deadline is None:
|
|
78
|
+
raise LockNotAcquired(f"could not acquire lock for {resource!r}")
|
|
79
|
+
if time.monotonic() >= deadline:
|
|
80
|
+
raise LockNotAcquired(
|
|
81
|
+
f"timed out waiting for lock {resource!r} after {wait}"
|
|
82
|
+
)
|
|
83
|
+
time.sleep(retry_interval.total_seconds())
|
|
84
|
+
|
|
85
|
+
def _try_acquire(
|
|
86
|
+
self,
|
|
87
|
+
resource: str,
|
|
88
|
+
*,
|
|
89
|
+
ttl: timedelta,
|
|
90
|
+
owner_id: str,
|
|
91
|
+
) -> LockHandle | None:
|
|
92
|
+
lock_key = self._keys.lock(resource)
|
|
93
|
+
seq_key = self._keys.fence_seq(resource)
|
|
94
|
+
result = self._client.eval(
|
|
95
|
+
ACQUIRE_SCRIPT,
|
|
96
|
+
2,
|
|
97
|
+
lock_key,
|
|
98
|
+
seq_key,
|
|
99
|
+
owner_id,
|
|
100
|
+
_ttl_ms(ttl),
|
|
101
|
+
)
|
|
102
|
+
if result is None or result is False:
|
|
103
|
+
return None
|
|
104
|
+
token_value = integer_response(result)
|
|
105
|
+
return LockHandle(
|
|
106
|
+
resource=resource,
|
|
107
|
+
owner_id=owner_id,
|
|
108
|
+
token=FenceToken(value=token_value, resource=resource),
|
|
109
|
+
ttl=ttl,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def release(self, handle: LockHandle) -> None:
|
|
113
|
+
"""Release *handle* if still owned; raise :class:`LockNotOwned` otherwise."""
|
|
114
|
+
lock_key = self._keys.lock(handle.resource)
|
|
115
|
+
result = self._client.eval(
|
|
116
|
+
RELEASE_SCRIPT,
|
|
117
|
+
1,
|
|
118
|
+
lock_key,
|
|
119
|
+
handle.owner_id,
|
|
120
|
+
)
|
|
121
|
+
if integer_response(result) != 1:
|
|
122
|
+
raise LockNotOwned(
|
|
123
|
+
f"cannot release lock {handle.resource!r}: not owned or expired"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def extend(self, handle: LockHandle, *, ttl: timedelta) -> None:
|
|
127
|
+
"""Extend lock TTL if still owned (heartbeat)."""
|
|
128
|
+
lock_key = self._keys.lock(handle.resource)
|
|
129
|
+
result = self._client.eval(
|
|
130
|
+
EXTEND_SCRIPT,
|
|
131
|
+
1,
|
|
132
|
+
lock_key,
|
|
133
|
+
handle.owner_id,
|
|
134
|
+
_ttl_ms(ttl),
|
|
135
|
+
)
|
|
136
|
+
if integer_response(result) != 1:
|
|
137
|
+
raise LockNotOwned(
|
|
138
|
+
f"cannot extend lock {handle.resource!r}: not owned or expired"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@contextmanager
|
|
142
|
+
def hold(
|
|
143
|
+
self,
|
|
144
|
+
resource: str,
|
|
145
|
+
*,
|
|
146
|
+
ttl: timedelta,
|
|
147
|
+
owner_id: str | None = None,
|
|
148
|
+
blocking: bool = False,
|
|
149
|
+
wait: timedelta | None = None,
|
|
150
|
+
) -> Iterator[LockHandle]:
|
|
151
|
+
"""Context-manager helper: ``with lock.hold("res", ttl=...) as handle``."""
|
|
152
|
+
handle = self.acquire(
|
|
153
|
+
resource,
|
|
154
|
+
ttl=ttl,
|
|
155
|
+
owner_id=owner_id,
|
|
156
|
+
blocking=blocking,
|
|
157
|
+
wait=wait,
|
|
158
|
+
)
|
|
159
|
+
try:
|
|
160
|
+
yield handle
|
|
161
|
+
except BaseException:
|
|
162
|
+
# Preserve the critical-section exception if the lease also expired.
|
|
163
|
+
with suppress(LockNotOwned):
|
|
164
|
+
self.release(handle)
|
|
165
|
+
raise
|
|
166
|
+
else:
|
|
167
|
+
self.release(handle)
|
fencekit/py.typed
ADDED
|
File without changes
|
fencekit/scripts.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Lua scripts for atomic lock and fence operations.
|
|
2
|
+
|
|
3
|
+
Scripts are evaluated with EVAL so they work without prior SCRIPT LOAD.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
# KEYS[1]=lock_key, KEYS[2]=seq_key; ARGV[1]=owner_id, ARGV[2]=ttl_ms.
|
|
9
|
+
# Returns a fencing token (integer) or false. INCR happens before SET because
|
|
10
|
+
# Redis scripts are atomic but do not roll back writes after a runtime error:
|
|
11
|
+
# a corrupt/overflowing counter must not leave a lock without a token.
|
|
12
|
+
ACQUIRE_SCRIPT = """
|
|
13
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then
|
|
14
|
+
return false
|
|
15
|
+
end
|
|
16
|
+
local token = redis.call('INCR', KEYS[2])
|
|
17
|
+
if redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2], 'NX') then
|
|
18
|
+
return token
|
|
19
|
+
end
|
|
20
|
+
return false
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# KEYS[1]=lock_key; ARGV[1]=owner_id
|
|
24
|
+
# Returns 1 if deleted, 0 otherwise.
|
|
25
|
+
RELEASE_SCRIPT = """
|
|
26
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
27
|
+
return redis.call('DEL', KEYS[1])
|
|
28
|
+
end
|
|
29
|
+
return 0
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# KEYS[1]=lock_key; ARGV[1]=owner_id, ARGV[2]=ttl_ms
|
|
33
|
+
# Returns 1 if extended, 0 otherwise.
|
|
34
|
+
EXTEND_SCRIPT = """
|
|
35
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
36
|
+
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
37
|
+
end
|
|
38
|
+
return 0
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# KEYS[1]=max_key; ARGV[1]=token
|
|
42
|
+
# Returns 1 if accepted (token >= max), 0 if fenced out (token < max).
|
|
43
|
+
# Equal tokens are allowed so one acquire can write multiple times.
|
|
44
|
+
FENCE_ADVANCE_SCRIPT = """
|
|
45
|
+
local max = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
46
|
+
local token = tonumber(ARGV[1])
|
|
47
|
+
if token < max then
|
|
48
|
+
return 0
|
|
49
|
+
end
|
|
50
|
+
redis.call('SET', KEYS[1], ARGV[1])
|
|
51
|
+
return 1
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
# KEYS[1]=max_key; ARGV[1]=token
|
|
55
|
+
# Returns 1 if token >= max (allowed), 0 if fenced out. Does not advance.
|
|
56
|
+
FENCE_CHECK_SCRIPT = """
|
|
57
|
+
local max = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
58
|
+
local token = tonumber(ARGV[1])
|
|
59
|
+
if token < max then
|
|
60
|
+
return 0
|
|
61
|
+
end
|
|
62
|
+
return 1
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# KEYS[1]=max_key, KEYS[2]=target_key; ARGV[1]=token, ARGV[2]=value.
|
|
66
|
+
# The fence comparison and target mutation are one atomic Redis operation.
|
|
67
|
+
FENCED_SET_SCRIPT = """
|
|
68
|
+
local max = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
69
|
+
local token = tonumber(ARGV[1])
|
|
70
|
+
if token < max then
|
|
71
|
+
return 0
|
|
72
|
+
end
|
|
73
|
+
redis.call('SET', KEYS[1], ARGV[1])
|
|
74
|
+
redis.call('SET', KEYS[2], ARGV[2])
|
|
75
|
+
return 1
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
# KEYS[1]=idem_key; ARGV[1]=owner_id, ARGV[2]=ttl_seconds.
|
|
79
|
+
# Mark done only for the worker that began. Returns 1 if completed.
|
|
80
|
+
MARK_DONE_SCRIPT = """
|
|
81
|
+
local expected = 'pending:' .. ARGV[1]
|
|
82
|
+
if redis.call('GET', KEYS[1]) ~= expected then
|
|
83
|
+
return 0
|
|
84
|
+
end
|
|
85
|
+
local ttl = tonumber(ARGV[2])
|
|
86
|
+
if ttl ~= nil and ttl > 0 then
|
|
87
|
+
redis.call('SET', KEYS[1], 'done', 'EX', ttl)
|
|
88
|
+
else
|
|
89
|
+
local pttl = redis.call('PTTL', KEYS[1])
|
|
90
|
+
redis.call('SET', KEYS[1], 'done')
|
|
91
|
+
if pttl > 0 then
|
|
92
|
+
redis.call('PEXPIRE', KEYS[1], pttl)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
return 1
|
|
96
|
+
"""
|
fencekit/types.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared value types for locks and fencing tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class FenceToken:
|
|
11
|
+
"""Monotonic token issued on each successful lock acquire.
|
|
12
|
+
|
|
13
|
+
Pass this token to :class:`~fencekit.fence.FenceGate` (and to your own
|
|
14
|
+
storage layer) on every write under the lock.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
value: int
|
|
18
|
+
resource: str
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
if self.value < 1:
|
|
22
|
+
raise ValueError("fence token value must be positive")
|
|
23
|
+
if not self.resource:
|
|
24
|
+
raise ValueError("fence token resource must be non-empty")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class LockHandle:
|
|
29
|
+
"""Proof of lock ownership returned by :meth:`DistributedLock.acquire`."""
|
|
30
|
+
|
|
31
|
+
resource: str
|
|
32
|
+
owner_id: str
|
|
33
|
+
token: FenceToken
|
|
34
|
+
ttl: timedelta
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
if not self.owner_id:
|
|
38
|
+
raise ValueError("lock owner_id must be non-empty")
|
|
39
|
+
if self.token.resource != self.resource:
|
|
40
|
+
raise ValueError("lock token resource must match handle resource")
|
|
41
|
+
if self.ttl.total_seconds() <= 0:
|
|
42
|
+
raise ValueError("lock ttl must be positive")
|
|
@@ -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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
fencekit/__init__.py,sha256=6GL2fPRMmixEg-9ksteFsTOYuh0k7LW1XORBEkK72wc,956
|
|
2
|
+
fencekit/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
fencekit/canonicalize.py,sha256=MyF5QQT91pOR8Vxrbdw1q0_9HKGqgfr4JGKRt6VSIio,3103
|
|
4
|
+
fencekit/client.py,sha256=iLUSyvodgVpA_UCo-y7uhRoiqSTb20CnOxmL1T60SMU,2363
|
|
5
|
+
fencekit/errors.py,sha256=ZZD_I-QTP9HPN73KDHl9P8wSEj0Xw_UlkGcrcitk1OQ,1203
|
|
6
|
+
fencekit/fence.py,sha256=cGfU83ULag1qd_6UNBs3VdO8G5xigALjpCQm5uxY7Nk,4181
|
|
7
|
+
fencekit/idempotency.py,sha256=FJcomc5yB3s3TQypaMOKPy6DSeGdF74zl-jvQVGq0HY,2891
|
|
8
|
+
fencekit/keys.py,sha256=f6wN1Y-bQa4DMoabHDanXjwpk4y4urW1VO1SMFlJMMw,1412
|
|
9
|
+
fencekit/lock.py,sha256=X1sMTCZKtxyeDcPdpMvFV8QpNxNDKqzEw2JmNMdQSw4,5450
|
|
10
|
+
fencekit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
fencekit/scripts.py,sha256=S9hwlczjXty0s5nPOfdcbDRkBjJGfdt1NDSC4DFh8VE,2657
|
|
12
|
+
fencekit/types.py,sha256=xDz17F3YGRHCnE3sEmopOvGwx2OUjoIHJYodf9holsQ,1250
|
|
13
|
+
fencekit-0.1.0.dist-info/METADATA,sha256=3CsN_STn830fvmsyjG4UcZix8EYaxsml7bODdT911ww,4823
|
|
14
|
+
fencekit-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
15
|
+
fencekit-0.1.0.dist-info/licenses/LICENSE,sha256=dIUelP4H--LLD133BC9h2F8JORlyOr9dm6TCijqQXIo,1070
|
|
16
|
+
fencekit-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|