distributed-lock-lab

A distributed lock is not what makes your critical section safe. This repository proves it, by experiment, in about ninety seconds.

docs pages License Status Contributions

📖 Read the documentation site → — the full doc set with search and working cross-references.


The experiment

Two workers race to execute the same payout.

Worker A acquires the lock, then stalls — a stop-the-world GC pause, a throttled container, a SIGSTOP. It stalls long enough that its lease expires. The lock service does exactly the right thing: it expires the lease on schedule and grants the lock to worker B. Worker B submits the payout to an external payment rail and posts the ledger entries.

Then worker A wakes up. It still believes it holds the lock. It submits the same payout again.

Run the scenario with fencing disabled and you get a duplicate submission to a non-idempotent payment rail and a ledger that no longer balances. Run it with fencing enabled — changing nothing else, same lock service, same backend, same test — and the stale worker is rejected at two independent enforcement points before it can touch anything.

$ ./gradlew :harness:runScenario --args='sigstop --fencing=off'
  rail.submissions=2   ledger.balanced=false   ← money sent twice
$ ./gradlew :harness:runScenario --args='sigstop --fencing=on'
  rail.submissions=1   ledger.balanced=true    ← stale writer rejected twice

The lock service behaved correctly in both runs. It granted one lease at a time and expired the first one on schedule. That is precisely what makes this class of bug so easy to miss in code review, and it is the entire argument of this project.

→ The experiment is task T-042. The mechanism is docs/03-architecture.md, flow C.

Why this repository exists

Search for “distributed lock tutorial” and you will find the same example every time: a bank balance, protected by a lock.

UPDATE account SET balance_minor = balance_minor - :amount
 WHERE account_id = :id AND balance_minor >= :amount;

That statement is already atomic. It is already serialised by PostgreSQL on a single row. It needs no lock service at all, and wrapping one around it teaches a reflex that will cost somebody real money later.

A distributed lock earns its place only when the critical section spans a side effect no database transaction can roll back. Here that side effect is a submission to a deliberately non-idempotent external payment rail — the one shape for which a distributed lock is genuinely the right answer.

Because the two protected resources have different capabilities, fencing is enforced twice, in two processes that the lock service does not control:

Resource Enforcement mechanism Contract
PostgreSQL row conditional UPDATE … WHERE fence < :token C1
External rail (cannot be modified) proxy holding a persisted highest-token-per-account high-water mark C3

The high-water mark is persisted rather than in-memory, so that a proxy restart cannot forget it and re-admit a stale writer — see ADR-007.

Neither enforcement point lives inside the lock service. That is the load-bearing design decision of the whole project: if the fence < :token check ran inside lock-server, the service would be validating its own grants, and the bug being demonstrated — the server was right, the holder was wrong, and the resource believed the holder — could not occur at all.

Project status

This repository currently contains specifications, not code.

Approximately 100 documents: requirements, architecture, five authoritative contracts, fourteen decision records, SRE artifacts, and 63 implementation task specifications written to be picked up independently by contributors. Nothing here compiles yet, because nothing here is code.

That is the invitation. The design work is finished and reviewable; the implementation is open.

   
Phase Specification complete → implementation open
Milestone in progress M0 — Foundations (roadmap)
Tasks ready to claim see the task board and good first issue
Language / build Java 25, Gradle 9.5 Kotlin DSL, Spring Boot 4.1
Backends PostgreSQL 16 and etcd 3.6, both first-class
License Apache-2.0

What gets built

Nine modules, two interchangeable lock backends, two independent fencing enforcement points, and the operational apparatus to run the result on real infrastructure.

  +--- harness: scenarios | chaos (pause/kill/partition) | invariant checks | bench ---+
      | drives                                                              asserts |
      v                                                                             v
  +--------------+  acquire/renew/release        +--------------------------------+
  | payout-      |------------------------+      |          lock-server           |
  | executor     |  heartbeat             |      | web -> core (SessionRegistry,  |
  | (the worker) |<-- lock-client (SDK) --+----->| lease clock, token minting)    |
  |  deadline, checkStillHeld, onLockLost |      | store.pg    |    store.etcd    |
  +---+------+----------------------------+      +-----+--------------+-----------+
      |      | (2) submit  X-Fencing-Token             |              |
      |      v                                  +------v-----+  +-----v----------+
      |  +----------------+  POST /submit       | dlock-pg-  |  | dlock-etcd     |
      |  |   rail-proxy   |  (no token, no key) | lock       |  | 3-replica STS  |
      |  | fence point (c)|------------------->  (lockdb,    |  | ModRevision =  |
      |  | rail_high_water|<--ACK/DECLINE/t-out | REGIONAL)  |  | token          |
      |  +--------+-------+     +-----------+   +------------+  +----------------+
      |           |             | rail-stub |
      |           | intent      | non-      |   lock-api: zero-dependency types,
      | (1) claim | BEFORE      | idempotent|     shared by every box above
      |  (3) post | forward     +-----------+   deploy: Terraform + K8s + compose
      v           v                            build-logic: toolchain, Spotless
  +----------------------------------------------------+   +--------------+
  |  payment-resource | payout FSM | ledger | balance  |-->| dlock-pg-pay |
  |  fence point (a): UPDATE .. WHERE fence < :token   |   | (paydb,ZONAL)|
  +----------------------------------------------------+   +--------------+
Module Responsibility
lock-api The Java and wire contract. Zero third-party dependencies, enforced in CI
lock-server Lease authority; the only minter of fencing tokens; store.pg and store.etcd
lock-client SDK: conservative monotonic deadline, heartbeat loop, checkStillHeld, onLockLost
payment-resource Owns paydb; fence point (a) — the conditional UPDATE
payout-executor The worker that composes the critical section end to end
rail-proxy Fence point (c) — persisted high-water mark; records intent before forwarding
rail-stub A deliberately non-idempotent external rail with injectable latency and failures
harness Scenario runner, chaos injection, invariant checkers, benchmark driver
deploy Terraform, Kubernetes manifests, local docker compose path

Full inventory with dependency edges: C5 §5.4.

Getting started

Nothing to build yet — M0 is what creates the build. Until then:

If you want to Start at
Contribute code CONTRIBUTING.md, then claim a task from the board
Understand the argument This README, then docs/00-charter.md
Review the design docs/03-architecture.md and the decision records
Look up a name or a schema docs/04-contracts.md — the index to every pinned identifier
Read the SRE half docs/06-observability-and-slo.md, docs/08-operations.md
Contribute as an AI agent AGENTS.md — required reading before generating anything

Once M0 lands, the local path is a single command and requires no cloud account:

git clone https://github.com/rednavis/distributed-lock-lab.git
cd distributed-lock-lab
./gradlew build          # builds all modules, runs the full test suite
docker compose up        # local stack: both backends, the rail, the services

The contracts are authoritative

Five documents pin every identifier this project is allowed to use — tables, columns, SQL statements, Java signatures, HTTP paths, error codes, headers, metric names, log event names, configuration keys, module names, cloud resource names.

   
C1 Database schemas and the exact SQL
C2 Java API, the SPI, the SDK
C3 HTTP surfaces, error envelope, timeouts
C4 Metrics, logs, traces, cardinality rules
C5 Configuration, build, and every naming convention

If a task specification and a contract disagree, the contract wins — open a contract change issue rather than implementing either version. Nine modules and two lock backends compile against one vocabulary, and no single contributor sees more than a slice of it. A plausible synonym — fencing_token where the contract pins fence — compiles, passes its own module’s tests, and fails at every integration point three milestones later.

What this is not

This is a reference implementation and a teaching artifact. It is not production-ready and must never be deployed in front of real money.

Concretely missing, and deliberately so: no authentication or authorisation on the lock API, no multi-tenancy, no quotas, single region, no key-space sharding, no shared/exclusive lock modes, no strict FIFO fairness, no data-retention or PII story. It is not a Raft implementation — consensus comes from etcd, and ADR-001 explains why writing our own would teach a different lesson than the one this project is for.

The full non-goals table, each with its reason, is docs/00-charter.md.

The domain — a mid-size payment service provider, its payout queue, its external rail — is fictional. Every quantity in this repository is an explicitly labelled assumption, not measured production data. No benchmark number appears without the command, environment and date that produced it.

Start here

Five tasks are unblocked right now — nothing needs to be merged first, and three of them need no Java at all:

Issue Task What you need
#11 T-001 Monorepo skeleton and Gradle settings JDK 25, Gradle. Blocks every other Java task — highest priority in the repo
#48 T-050 Terraform root and dev environment Terraform CLI only. No cloud account, no billing
#49 T-051 Terraform module: network Terraform CLI only
#50 T-052 Terraform module: two Cloud SQL instances Terraform CLI only
#66 T-068 The runbook, one entry per alert A text editor. Pure prose

Once T-001 lands, T-023rail-stub, the deliberately non-idempotent rail — is the best first Java contribution: self-contained, zero dependencies, and it is the hazard the whole experiment depends on.

Browse everything: ready to start · good first issues · all 63 tasks · the board · what blocks what

Contributing

Contributions are welcome and the project is structured to make them tractable: 63 independently specified tasks, a dependency graph that says which can run in parallel, and contracts precise enough that two people working in different modules will produce code that fits together.

Both humans and AI agents contribute here, under the same rules and the same review bar. AGENTS.md states the additional obligations that apply to generated contributions.

License

Apache License 2.0 — see LICENSE and NOTICE.

Contributions are accepted under the same license via the Developer Certificate of Origin; sign your commits with git commit -s. There is no separate CLA.


Back to top

Apache-2.0. The domain modelled here is fictional; every number is a labelled assumption. Not production-ready — see SECURITY.md.