fencekit
Redis-backed idempotency and fenced distributed locks for background jobs.
Celery with acks_late redelivers work after a worker crash. Redis gives you
SET NX and Lua. fencekit wraps those into tested pieces:
| Piece | Problem it solves |
|---|---|
IdempotencyGuard |
Double-start / redelivery of the same logical job |
DistributedLock + fencing token |
Two workers on the same resource at once |
FenceGate / fenced_update |
Stale lock holder overwriting newer state after TTL expiry |
Extracted from ChessMate (Celery + Redis + Postgres). See DESIGN.md for guarantees, non-guarantees, and crash semantics.
Install
pip install fencekit
pip install "fencekit[django]" # optional Django QuerySet helper
pip install "fencekit[celery]" # optional Celery task decorator
pip install "fencekit[otel]" # optional OpenTelemetry hook factory
Requires Redis 6+ (tested with Redis 7) and Python 3.10+.
Quick example
from datetime import timedelta
from redis import Redis
from fencekit import (
BeginOutcome,
DistributedLock,
FenceGate,
IdempotencyGuard,
IdempotencyResultMissing,
fenced_update,
idempotency_key,
)
r = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
guard = IdempotencyGuard(r)
lock = DistributedLock(r)
fence = FenceGate(r)
def analyze_batch(job) -> dict | None:
key = idempotency_key(
{"game_ids": ["abc", "def"], "engine": "sf16"},
namespace="analysis",
)
resource = f"analysis:{job.pk}"
outcome = guard.try_begin_or_reclaim(
key, lock=lock, lock_resource=resource, ttl=timedelta(hours=24)
)
if outcome == BeginOutcome.ALREADY_DONE:
try:
return guard.get_result(key)
except IdempotencyResultMissing:
return None
if outcome == BeginOutcome.IN_PROGRESS:
return None # active worker holds the lock
handle = lock.acquire(resource, ttl=timedelta(minutes=5), owner_id=guard.owner_id)
try:
fence.set_if_fresh(handle.token, f"analysis:{job.pk}:status", "running")
fenced_update(
type(job).objects.filter(pk=job.pk),
handle.token,
updates={"progress": 50, "status": "running"},
)
result = {"report_id": job.pk, "status": "done"}
guard.mark_done(key, result=result, ttl=timedelta(hours=24))
return result
finally:
lock.release(handle)
API overview
idempotency_key(payload, namespace=...): deterministic key from JSON-canonicalized payload.
IdempotencyGuard.try_begin / try_begin_or_reclaim / mark_done / get_result: at-most-once start; reclaim stale pending after crash; optional JSON memo on completion.
DistributedLock.acquire / release / extend: lease plus monotonic fencing token (Lua).
FenceGate.set_if_fresh: atomic fenced Redis string writes.
fenced_update(queryset, token, updates=...): fenced Django/Postgres UPDATE in one statement.
Typed public API (py.typed). Optional Celery helper: fencekit.celery.idempotent_task.
Observability (optional)
Pass :class:~fencekit.hooks.FenceKitHooks to IdempotencyGuard, DistributedLock, and FenceGate, or use the OTel factory:
from fencekit import DistributedLock, FenceKitHooks, IdempotencyGuard
from fencekit.otel import otel_hooks
hooks = otel_hooks() # pip install "fencekit[otel]"
guard = IdempotencyGuard(redis, hooks=hooks)
lock = DistributedLock(redis, hooks=hooks)
Hook callbacks must not raise; fencekit swallows errors so metrics cannot break jobs.
Celery (optional)
from datetime import timedelta
from fencekit.celery import idempotent_task
from fencekit import DistributedLock, IdempotencyGuard, idempotency_key
guard = IdempotencyGuard(redis)
lock = DistributedLock(redis)
@shared_task(bind=True)
@idempotent_task(
guard,
lock,
key=lambda batch_id: idempotency_key({"batch_id": batch_id}, namespace="analysis"),
lock_resource=lambda batch_id: f"analysis:{batch_id}",
idempotency_ttl=timedelta(hours=24),
lock_ttl=timedelta(minutes=5),
retry_on_in_progress=True,
)
def analyze_batch(self, batch_id: str) -> dict:
...
Comparison
Short view. Sources and nuance: docs/COMPARISON.md.
| Dedup start | Celery plugin | Stale-write fencing | Postgres helper | |
|---|---|---|---|---|
| fencekit | Yes | Manual | Yes | fenced_update |
| celery-once / celery-singleton | Yes | Yes | No | No |
redis-py Lock |
No | No | No | No |
| relier | Yes | Yes | Partial (framework) | App-owned |
fencekit complements celery-once. celery-once dedupes scheduling; fencekit rejects writes from a worker whose lock TTL already expired.
Local demo (no cloud)
Redis via Docker on your machine. No hosted services, no monthly bill.
docker compose -f examples/reference/docker-compose.yml up -d
pip install -e .
python examples/reference/demo_stale_fence.py
python examples/reference/demo_idempotency.py
See examples/reference/README.md.
Guarantees
| Claim | Status |
|---|---|
| At-most-once start within idempotency TTL | Yes (SET NX) |
Memoized result after mark_done(..., result=...) |
Yes (within TTL) |
| Mutual exclusion while lock TTL held (single Redis primary) | Best-effort lease |
Stale holder blocked via FenceGate / fenced_update |
Yes (when used) |
| Exactly-once delivery | No |
| Safety under Redis failover / split brain | No |
| Writes that skip the fencing token | No |
fencekit does not implement Redlock. Fencing only works when the storage layer checks the token in the same operation as the write.
Development
REM Windows (CMD)
python -m pip install -e ".[dev]"
python -m ruff check src tests
python -m mypy src
python -m pytest -m "not integration" -q
Full suite (Redis on localhost:6379):
set FENCEKIT_REDIS_URL=redis://localhost:6379/15
python -m pytest -q
# Linux/macOS with uv
uv sync --extra dev
uv run pytest
CI runs lint, type-check, and tests on Python 3.10–3.13 with Redis. Releases publish to PyPI via Trusted Publishing (OIDC, no long-lived token).
License
MIT
Metadata
Release files for fencekit 0.6.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| fencekit-0.6.1.tar.gz | 37.6 kB | With provenance | Details for fencekit-0.6.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| fencekit-0.6.1-py3-none-any.whl | Python 3 | none | any | Details for fencekit-0.6.1-py3-none-any.whl |
Total release size: 60.5 kB
Release files / fencekit-0.6.1.tar.gz
| Download URL | fencekit-0.6.1.tar.gz |
|---|---|
| Size | 37.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
10478c9c16cedcef83fb4918090b468b56fe01b7cc9a2cdd8300f367392acc12
|
|
BLAKE2b-256 checksum How to use checksums |
e09be06cee956edd741b24e5a4ea8ecd8af431c2fbbb34e2b3b8d68f73249780
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 18, 2026.
Transparency logRelease files / fencekit-0.6.1-py3-none-any.whl
| Download URL | fencekit-0.6.1-py3-none-any.whl |
|---|---|
| Size | 22.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8622da832e51852c7d05df557127569aaef54c52519d2eb9fa54618c92ad6a24
|
|
BLAKE2b-256 checksum How to use checksums |
9b1b4c4637a6ab18b57d83c3fb7c96e784f00eeb951c06b636da207e9f183bdb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 18, 2026.
Transparency logSecurity for fencekit 0.6.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for fencekit-0.6.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 18, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.