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 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"
@@ -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
+ )
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.