distributed-lock-lab

A distributed lock service for the payout queue of a fictional mid-size payment service provider, built to demonstrate one lesson: mutual exclusion does not protect a resource — the fencing token does.

The fencing demonstration

Worker A holds the lock and stalls past its lease. The lock service does exactly the right thing: it expires the lease and grants the lock to worker B, which completes the payout. Then A wakes up, still believing it holds the lock, and tries to finish its work.

# Illustrative transcript of the T-042 scenario, fencing on. Token values are examples.
[A] acquire payout:acct-7                            -> GRANTED  fencingToken=41
[A] SIGSTOP: paused past its lease; lock-server expires the lease on schedule
[B] acquire payout:acct-7                            -> GRANTED  fencingToken=42
[B] POST /v1/rail/submissions  X-Fencing-Token: 42   -> 200 ACKED   (rail_high_water: 42)
paydb=> UPDATE account SET balance_minor = balance_minor - 2500, fence = 42
paydb->  WHERE account_id = 'acct-7' AND currency = 'EUR' AND fence < 42 ...;
UPDATE 1
[A] SIGCONT: still believes it holds the lock, submits the same payout again
[A] POST /v1/rail/submissions  X-Fencing-Token: 41   -> 409 FENCED_OUT
    rejected: 41 is not greater than 42, and the rail is never called
paydb=> UPDATE account SET ... fence = 41 WHERE account_id = 'acct-7' AND fence < 41 ...;
UPDATE 0
    rejected: the row already carries fence 42
{"event":"fenced_out","resource":"account","resourceId":"acct-7",
 "presentedToken":41,"highestToken":42,"ownerId":"A"}

The lock service behaved correctly in both halves of that transcript. 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. Set payment.fencing.enabled and rail.proxy.fencing.enabled to false — the two kill switches of C5 §5.2, and nothing else changed: same lock service, same backend, same test — and the stale worker’s submission reaches a non-idempotent payment rail while the ledger stops balancing. Both default to on, are logged at WARN when off, and exist only so that the corruption can be demonstrated inside a named experiment.

→ The experiment is task T-042; the transcript above illustrates it and is replaced by captured output when that task lands. The mechanism is docs/03-architecture.md, flow C.

Why a lock at all

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 (ADR-004).

The two fence points

Because the two protected resources have different capabilities, fencing is enforced twice, and both enforcement points live in processes the lock service does not control.

Resource Enforcement mechanism Process Contract
PostgreSQL row in paydb conditional UPDATE … WHERE fence < :token payment-resource C1
External rail (cannot be modified) persisted highest-token-per-account high-water mark, checked before the rail is called rail-proxy 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 lock-server. That is the load-bearing design decision of the whole project: if the fence < :token check ran inside the lock service, 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 (ADR-007, architecture §3.3).

Architecture at a glance

                      acquire / renew / release           +-----------------+
   +------------------+   (lock-client SDK)               |   lock-server   |
   | payout-executor  |---------------------------------> | lease clock,    |
   | the worker; owns |   fencing token                   | token minting   |
   | the critical     | <---------------------------------|                 |
   | section          |                                   +--+-----------+--+
   +--+------------+--+                                      |           |
      |            |                                 +-------v--+   +----v-----+
      |            | (2) X-Fencing-Token             | lockdb   |   |   etcd   |
      |            v                                 | (pg)     |   | ModRev = |
      |     +------+-------+  POST /submit           +----------+   | token    |
      |     |  rail-proxy  |  (no token, no key)     +----------+   +----------+
      |     | fence point  |-----------------------> | rail-stub|
      |     | rail_high_   | <---- ACK / DECLINE --- | non-     |
      |     | water        |                         | idempotent|
      |     +--------------+                         +----------+
      | (1) claim, (3) post   X-Fencing-Token
      v
   +-------------------------------------------+       +-------------+
   | payment-resource: payout FSM, ledger,     |------>|    paydb    |
   | balance; fence point: WHERE fence < :token|       |    (pg)     |
   +-------------------------------------------+       +-------------+

Both backends are first class

One SPI — LockStore (C2 §2.5) — and two implementations that mint the fencing token differently:

Backend Token Monotonic because
PostgreSQL nextval('fencing_token_seq'), one global sequence procedure — a restore must advance the sequence, or an operator recreates a token
etcd the lock key’s ModRevision, captured at grant time from the winning CAS transaction construction — the cluster revision never moves backwards

The same payout-executor, unchanged, runs against both, selected by one configuration key (ADR-002, SC-02).

Run it locally

JDK 25 and Docker; no cloud account is needed for anything up to and including the fencing experiment.

./gradlew build                                   # all modules, tests and the style check
./gradlew spotlessApply                           # format Java and Gradle files
docker compose --project-directory deploy/compose up -d --wait   # PostgreSQL x2 + etcd

The local stack, its ports and its profiles are documented in deploy/compose/README.md.

Repository map

The layout is pinned by C5 §5.5.

distributed-lock-lab/
  settings.gradle.kts          # module registry + version catalog wiring; the only place modules are declared
  build.gradle.kts             # applies convention plugins only - no versions, no per-module logic
  gradle/libs.versions.toml    # the single source of every version
  build-logic/                 # convention plugins: toolchain, format, test, Lombok policy
  lock-api/                    # dev.lock.api - dependency-free contract
  lock-server/                 # dev.lock.server.{core,store.pg,store.etcd,web} + lockdb migrations
  lock-client/                 # dev.lock.client - SDK with the conservative deadline
  payment-resource/            # dev.lock.payments.resource + paydb migrations
  payout-executor/             # dev.lock.payments.executor - the critical section
  rail-proxy/                  # dev.lock.rail.proxy - high-water fencing, attempt-before-forward
  rail-stub/                   # dev.lock.rail.stub - the non-idempotent hazard
  harness/                     # dev.lock.harness - scenarios, chaos, invariant checks
  deploy/
    terraform/                 # root module + env dirs; state in gs://dlock-tfstate
    k8s/                       # manifests per workload, one dir per k8s SA
    compose/                   # local no-cloud path
    images/                    # the shared service Dockerfile
  docs/                        # this document set; docs/contracts/ = C1..C5, docs/adr/ = decisions
  tasks/                       # the 63 task specifications and the ledger
  config/                      # shared static analysis and style configuration
  scripts/                     # check-docs.sh and the repository bootstrap scripts
  .github/workflows/           # build, infra, container, codeql, docs, pages, labels, dco

Migrations sit inside the owning module (lock-server owns lockdb, payment-resource owns paydb), because a shared migrations directory is how two databases silently acquire each other’s tables.

Toolchain

Every dependency version lives in gradle/libs.versions.toml and nowhere else (C5 §5.3).

Component Version Where it is pinned
Spring Boot (BOM and plugin) 4.1.0 catalog spring-boot-bom
PostgreSQL JDBC driver 42.7.5 catalog postgresql
Flyway 11.8.0 catalog flyway-core
jetcd 0.8.5 catalog jetcd-core
OpenTelemetry BOM 1.49.0 catalog otel-bom
Lombok 1.18.46 catalog lombok
JUnit BOM 6.0.3 catalog junit-bom
AssertJ 3.27.7 catalog assertj
Awaitility 4.3.0 catalog awaitility
Testcontainers BOM 1.21.0 catalog testcontainers-bom
Spotless plugin 7.0.3 catalog spotless-plugin
google-java-format 1.28.0 catalog google-java-format
PostgreSQL image 16.15 catalog postgres-image
etcd image 3.6.14 catalog etcd-image
Eclipse Temurin image 25.0.4_7 catalog temurin-image
Java 25 Gradle toolchain in build-logic/src/main/kotlin/dlock.java-base.gradle.kts — the catalog has no toolchain row
Gradle 9.5.0 gradle/wrapper/gradle-wrapper.properties
Terraform 1.15.9 catalog terraform — the line is 1.15 (C5 §5.4, ADR-008)
tflint 0.64.0 catalog tflint

Non-goals

Written down because not writing them down is how a teaching project becomes an unfinished platform. This table reproduces charter §0.5; that file is the authority.

Out of scope Why
Custom Raft implementation The backends already provide consensus; writing Raft is a different project with a different lesson. etcd’s ModRevision is a better token than one we would implement (ADR-001)
Multi-region / global locking Cross-region quorum latency is ruinous and it obscures the single lesson this project exists to teach. Single region, documented as a constraint
Shared / exclusive (read-write) modes Extra state in the state machine, no new insight about fencing
Strict FIFO fairness Barging locks with jittered backoff demonstrate the mechanics; a fairness queue adds a starvation-versus-throughput analysis that is a separate topic
Multi-tenancy, namespaces, quotas An operations concern, not a correctness one, and there is exactly one customer (charter §0.3)
Admin UI An operator CLI plus psql/etcdctl covers break-glass. A UI is the first thing to cut
AuthN/AuthZ on the lock API One in-cluster caller; adding it would double the surface without touching the lesson. This alone disqualifies the project from production
Key-space sharding One shard is arithmetically sufficient for the assumed load; sharding is designed on paper only (architecture §3.8)
Real payment rails, real money, real PII Fictional domain; the rail is a stub

Never cut, at any schedule pressure: fencing tokens, conservative client-side expiry in the SDK, the correctness harness, the runbook. Without the first the project has no premise; without the rest it is a claim rather than a system.

Several non-goals have a documented trigger condition that would make them correct. Proposing one is legitimate; expect to be pointed at the trigger first.

Status

build docs pages License Contributions

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

   
Milestone complete M0 — Foundations: the Gradle build, nine modules, the version catalog, lock-api, the local compose stack, CI and this front matter (T-001…T-008)
In progress M1 — PostgreSQL lock backend (roadmap, delivery plan)
Not yet written Every service’s behaviour: M1 onward. The fencing experiment of the first section arrives with T-042
Measured numbers None. No latency, throughput or success-rate figure appears anywhere in this repository until T-070 measures one — [unmeasured] until then
Tasks ready to claim status:ready · good first issue · the board · what blocks what

This software must never be deployed in front of real money. There is no authentication and no authorisation on the lock API: any caller that can reach lock-server can acquire, renew, release or force-revoke any lock. That single absence disqualifies it from production on its own, and it is a deliberate non-goal rather than an oversight. The rest of the security posture is in SECURITY.md and architecture §3.9.

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.

Documentation

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. If a task specification and a contract disagree, the contract wins — open a contract change issue rather than implementing either version. Reading order for a fresh session is C5 → C1 → C2 → C3 → C4.

Contract Pins
C1 Database schemas and the exact SQL, including the fenced UPDATE
C2 Java API, the LockStore SPI, the SDK, the zero-dependency rule
C3 HTTP surfaces, error envelope, timeouts
C4 Metrics, logs, traces, cardinality rules
C5 Configuration, build, and every naming convention

The doc set, indexed with reading paths in docs/README.md:

   
00 charter Problem, customer, success criteria, non-goals
01 requirements Functional and non-functional requirements, invariants
02 domain model Payouts, ledger, accounts, the state machine
03 architecture Components, boundaries, the four flows, failure modes
04 contracts Index to the five contracts, precedence, change log
05 infrastructure GCP topology and what it costs
06 observability and SLO SLIs, SLOs, error-budget policy
07 correctness and testing Invariants, simulation, linearizability
08 operations Runbooks, break-glass, game day
09 risks Risk register
10 delivery plan Eight milestones and the critical path
11 glossary Terms, used consistently across the set
12 parallelization map Which tasks can run at the same time
decision records Fourteen ADRs plus the template, each with context and consequences
If you want to Start at
Contribute code CONTRIBUTING.md, then claim a task from the board
Understand the argument This page, 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

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.