Skip to main content

lfp-swarm-lease

lfp-swarm-lease elects one leader for one Docker Swarm service. It stores the election in Swarm's Raft-backed service metadata, so it needs no database and no fixed manager.

The implementation is pure Python. The controller, CLI, FastAPI server, and Python observer ship as one Python package built with uv and Hatchling. The TypeScript observer is generated from the controller's OpenAPI contract and contains no native binaries.

pip install lfp-swarm-lease
npm install @regbo/lfp-swarm-lease

The model is intentionally narrow:

  • One deployed controller service equals one election.
  • The namespace defaults to the current Swarm service name.
  • The holder ID defaults to the current Swarm node ID.
  • Acquisition is local-only and requires Docker manager access.
  • Remote clients are observation-only and use HTTP/SSE.
  • A different independent election should be a different controller service.

Run a leader-only process

Use the CLI as every manager replica's entrypoint:

lfp-swarm-lease run -- ./consul-controller

The process keeps contending in the background. Each time this controller becomes leader it starts the command with SWARM_LEASE_FENCE, SWARM_LEASE_HOLDER_ID, SWARM_LEASE_NODE_ID, SWARM_LEASE_TASK_ID, and SWARM_LEASE_NAMESPACE. If renewal fails or the fence changes, it sends SIGTERM to the child process group, waits 10 seconds, then kills remaining children. A later transition back to this node starts a fresh process. --once exits after the first leadership term.

Every running contender also serves:

Endpoint Purpose
GET /healthz Process liveness without a Docker call.
GET /readyz Docker/Swarm readiness.
GET /v1/info Bound namespace.
GET /v1/state Complete current election snapshot.
GET /v1/events SSE current holder, then holder/fence changes.
GET /docs Interactive OpenAPI documentation.

The first SSE event is current. Later events are change. Routine renewals retain the fence and are suppressed. Clients can connect to one Swarm service hostname: the VIP may round-robin reconnections because every replica observes the same anchor.

acquired has two contexts. In TryAcquireResult it means that acquisition attempt won. In snapshots and watcher events it means the election currently has some holder; compare holder_id, node_id, or task_id when caller-relative ownership is needed.

Discovery and configuration

Configuration precedence is:

  1. Typed CLI or ControllerOptions values.
  2. Environment variables.
  3. Current Swarm task labels.
  4. Defaults.

By default all variables begin with SWARM_LEASE_. --env-prefix POSTGRES switches the complete set to POSTGRES_*.

For automatic discovery, mount the Docker socket and leave the container hostname at Docker's default:

volumes:
  - /var/run/docker.sock:/var/run/docker.sock

The controller inspects the container named by HOSTNAME and reads:

  • com.docker.swarm.service.name as the namespace
  • com.docker.swarm.node.id as node metadata and the default holder ID
  • com.docker.swarm.task.id as task metadata

The hostname is only the lookup key. If it does not resolve to a container with a Swarm task ID and the namespace is still needed, startup fails immediately. Set SWARM_LEASE_NAMESPACE and SWARM_LEASE_HOLDER_ID explicitly when using a hard-coded hostname.

Important settings include:

  • SWARM_LEASE_TTL_MS — lease duration, default 30 seconds.
  • SWARM_LEASE_MAX_CAS_RETRIES — collisions retried within one mutation, default 8. It never limits how long run contends.
  • SWARM_LEASE_LISTEN — HTTP bind address, default 0.0.0.0:8080.
  • SWARM_LEASE_DEFAULT_POLL_MS — watcher poll cadence, default 1 second.
  • SWARM_LEASE_KEEP_ALIVE_MS — SSE keepalive, default 15 seconds.
  • SWARM_LEASE_SHUTDOWN_TIMEOUT_MS — child grace period, default 10 seconds.

init is optional because acquisition creates the zero-replica anchor automatically.

Python controller API

The Python controller is async and typed with Pydantic:

import asyncio

from lfp_swarm_lease import ControllerOptions, SwarmLeaseController

async def main() -> None:
    controller = await SwarmLeaseController.connect(
        ControllerOptions(namespace="consul-controllers", holder_id="manager-a")
    )
    result = await controller.try_acquire()
    if result.grant is None:
        print("held by", result.current_holder_id)
        return

    grant = result.grant
    await consul.reconcile_leader(fencing_token=grant.fence)
    grant = await controller.renew(grant)
    await controller.release(grant)

asyncio.run(main())

In Swarm, omit the two explicit values and let task discovery fill them. try_acquire() is nonblocking. run is usually safer than hand-writing the renewal and process-lifecycle loop.

Watch from Python

import asyncio

from lfp_swarm_lease import RemoteLeaseObserver

async def main() -> None:
    watcher = RemoteLeaseObserver.connect(
        "http://controllers:8080"
    ).watch()
    async for state in watcher:
        await status.publish(
            acquired=state.acquired,
            holder_id=state.holder_id,
            fence=state.fence,
        )

asyncio.run(main())

The observer waits up to 60 seconds for an initial controller response. After the first state it reconnects indefinitely with jittered exponential backoff. Replayed state after a controller restart is suppressed. wait_for_holder(timeout) skips inactive states.

Watch from TypeScript

import { RemoteLeaseObserver } from "@regbo/lfp-swarm-lease";

const watcher = RemoteLeaseObserver.connect(
  "http://controllers:8080",
).watch();

for await (const state of watcher) {
  await status.publish({
    acquired: state.acquired,
    holderId: state.holder_id,
    fence: state.fence,
  });
}

createLeaseClient() exposes the ordinary OpenAPI-typed endpoints. The SSE wrapper adds transparent retry, timeout, and transition de-duplication.

PostgreSQL or Consul controller stub

The election should run the coordinator, not the database server itself:

exec lfp-swarm-lease run -- ./postgres-controller

# postgres-controller
fence = int(os.environ["SWARM_LEASE_FENCE"])
postgres.promote_if_safe(fencing_token=fence)
postgres.serve_until_sigterm()
postgres.demote_and_fence_writes()

For Consul, the child can reconcile catalog, ACL, configuration, or an external resource while it is leader. Consul server membership and its own Raft leader election remain Consul's responsibility. This lease chooses one orchestration process; it does not replace Consul consensus.

The fencing token matters even when holder_id stays the same: a restarted controller on the same manager gets a newer fence. A protected resource that can reject stale tokens provides the strongest split-brain protection.

Other CLI commands

lfp-swarm-lease init
lfp-swarm-lease try-acquire
lfp-swarm-lease renew 7
lfp-swarm-lease release 7
lfp-swarm-lease inspect
lfp-swarm-lease destroy

try-acquire exits 75 when another contender holds the election. release exits 3 when the identity/fence pair does not match.

Development

uv sync --frozen
uv run ruff check .
uv run mypy src
uv run pytest
uv build --no-sources

bun install --frozen-lockfile
bun run node:test
bun run demo:test
bun run demo:typecheck
bun run demo:lint
bun run demo:build

The default uv build flow uses the Hatchling source-build hook to export the FastAPI contract and generate the TypeScript client before packaging Python. Run bun run clients:build directly while iterating only on the TypeScript client.

Release and demo deployment

Python, the generated Node client, and the private demo carry the same version. The local release command defaults to a patch bump and also accepts minor or major:

bun run release --dry-run
bun run release          # 0.1.0 -> 0.1.1
bun run release minor    # 0.1.1 -> 0.2.0
bun run release major    # 0.2.0 -> 1.0.0

It requires a clean branch that is not behind its upstream, updates both lockfiles, runs the release tests, commits the version files, creates an annotated vX.Y.Z tag, and atomically pushes the commit and tag. The tag starts the release workflow, which validates the shared version, publishes through PyPI trusted publishing and the configured npm automation token, then publishes the controller and demo images to Docker Hub as X.Y.Z and latest. Release CI never receives Portainer credentials and never changes a running stack.

Repository variables BUILD_ARM=false and PUBLISH_NPM=false disable those optional release steps. A manual workflow run exposes the same switches. All three artifact types always use the version validated from the tag or manual version input.

Demo lifecycle is an explicit local command. It supports local/private image pushes and Portainer deployment without reimplementing package publishing:

bun run demo --help
bun run demo deploy --dry-run
bun run demo deploy
bun run demo remove  # `rm` is an alias

Configuration precedence is CLI, process environment, process *_FILE, .env, .env *_FILE, then Pydantic defaults. Copy .env.example to the ignored .env for a local setup. Every setting uses the SWARM_LEASE_DEPLOY_ prefix, and every setting accepts a matching _FILE form so tokens never need to be placed directly in the environment or on a command line.

demo deploy pushes both images to Docker Hub or a private registry, resolves the demo image digest, renders and validates deploy/stack.yml, and only then creates or updates the exact Portainer-owned stack. --no-build-arm limits local image builds to linux/amd64. --no-push-images --image-tag VERSION deploys an image that the release workflow already published. demo remove is idempotent and verifies the configured stack name and endpoint before deleting it; it leaves volumes and unrelated stacks alone.

The release workflow consumes publishing values only from GitHub variables and secrets. Set repository variables BUILD_ARM and PUBLISH_NPM for tag-triggered defaults. Configure the release environment with:

  • Variable: CONTAINER_NAMESPACE.
  • Secrets: CONTAINER_REGISTRY_USERNAME, CONTAINER_REGISTRY_TOKEN, and NPM_TOKEN.

PyPI uses trusted publishing, so the GitHub release environment must be added as a trusted publisher for this repository on PyPI. NPM_TOKEN must be a granular automation token permitted to publish without an interactive 2FA prompt.

Safety

  • A partition without Swarm write quorum cannot acquire or renew.
  • The Docker socket is manager-equivalent access and belongs only on trusted controllers.
  • Observation clients never receive Docker access.
  • Expiration uses wall-clock time; keep manager clocks synchronized.
  • Authentication, when needed, belongs in HTTP middleware or a reverse proxy.

Metadata

Release files for lfp-swarm-lease 0.1.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 lfp-swarm-lease 0.1.1
File Size Uploaded
lfp_swarm_lease-0.1.1.tar.gz 80.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lfp-swarm-lease 0.1.1
File Interpreter ABI Platform
lfp_swarm_lease-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 106.0 kB

Release files / lfp_swarm_lease-0.1.1.tar.gz

Download URL lfp_swarm_lease-0.1.1.tar.gz
Size 80.4 kB
Tags Source
SHA-256 checksum
How to use checksums
e5538feb93958a546013f82c4e90e622e1a2f4e3df4b4ee9863509ade86152a3
BLAKE2b-256 checksum
How to use checksums
4d46bddb26da6aac0f4a1d59b3d78295c6295c147c56190befc82b980cee5e35
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 Oct 15, 2026.

Transparency log

Release files / lfp_swarm_lease-0.1.1-py3-none-any.whl

Download URL lfp_swarm_lease-0.1.1-py3-none-any.whl
Size 25.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
16e61563329e9db369efc54b9d09466923b8d73a501e83530efb7eeccdb758d8
BLAKE2b-256 checksum
How to use checksums
df9ae54299b6ce9d46b9cad779331857adaeab78d17f48541a8dcc5b3950f81b
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 Oct 15, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

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