Skip to main content

fencekit

CI PyPI Python tests License: MIT

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)

Source distribution for fencekit 0.6.1
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)

Table of built distributions (wheels) for fencekit 0.6.1
File Interpreter ABI Platform Actions
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 log

Release 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 log

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

Select from 2 files

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 log

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

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page