Architecture overview¶
dflockd is one process with three concerns:
LockManager— the in-memory lock state. Sharded by key for parallel-key throughput.- TCP server — the line-based wire protocol. One goroutine per client connection.
- HTTP server (optional) — REST handlers that call
LockManagerdirectly. Sessions provide the connID a two-phase request needs.
┌─────────────────┐ ┌─────────────────┐
│ TCP transport │ │ HTTP transport │
│ (line proto) │ │ (REST/JSON) │
└────────┬────────┘ └────────┬────────┘
│ │
│ per-conn / per-session connID
│ │
└────────────┬─────────────┘
▼
┌───────────────────┐
│ LockManager │
│ (sharded, 64×) │
└───────────────────┘
Both transports talk to the same LockManager, so a TCP client and
an HTTP session contending on the same key are ordered together in a
single FIFO queue.
LockManager¶
State is sharded into 64 stripes by FNV-1a(key) % 64. Each shard
owns its own mutex; cross-shard operations (only
CleanupConnection) iterate one shard at a time without ever
holding two mutexes.
Per shard:
resources— map of key →ResourceState(limit, holders, waiter queue, last-activity).connOwned— map of connID → key → set of held tokens. Used byCleanupConnectionto release everything a disconnected connection held without scanning every key.connEnqueued— map of (connID, key) → enqueued state. Backs the two-phaseWaitlookup.
Tokens are 16 random bytes, hex-encoded. A small per-manager
tokenBuf reads 4 KiB from crypto/rand at a time and dispenses
tokens 16 bytes at a time, amortising the syscall.
Two background goroutines run for the manager's lifetime:
- Lease expiry sweep — every
--lease-sweep-interval, evicts any holder past its lease deadline and grants the freed slot to the head waiter. - Idle GC — every
--gc-interval, prunes resources that have been idle (no holders, no waiters, no recent activity) for longer than--gc-max-idle. The cap on unique keys (--max-locks) is steady-state, not high-water.
TCP server¶
The accept loop:
- Bounded exponential backoff on transient accept errors (so FD exhaustion doesn't busy-spin).
- Global cap (
--max-connections) and per-IP cap (--max-connections-per-ip) gates before allocating a connID. - Each accepted conn runs
ServeConnin its own goroutine.
Per connection, the lifecycle:
- Auth handshake if
--auth-tokenis set. First command must beauth; anything else getserror_authand a 100 ms cooldown before close. - Request loop. Read three lines (cmd / key / arg), dispatch to the LockManager, write one response line.
- Peer-close watcher. While a blocking command (long-poll
acquire/wait) runs, a watcher goroutine peeks the read buffer; if it observes EOF or a full-buffer pipeline (abuse), it cancels the connection so the in-flight waiter can exit promptly. - Disconnect cleanup. The handler's
defercallsLockManager.CleanupConnection(connID). With--auto-release-on-disconnect=true(the default), every held token is released and granted to the next waiter. Pending waiters and two-phase enqueued state are always cleaned up regardless of that flag.
HTTP server¶
The HTTP layer doesn't speak protocol bytes. Each session is
metadata ({ID, ConnID, OwnerIP, lastSeen, inFlight, closed});
handlers parse JSON, claim the session, then call LockManager
methods directly using the session's connID.
Two coordination primitives keep the session safe:
BeginRequest/done. Every lock-modifying handler claims the session's mutex before callingLockManager, and bumps aninFlightcounter. The idle sweeper skips any session withinFlight > 0, so a long-poll/waitisn't reaped mid-flight. Session DELETE / sweeper / shutdown all set aclosedflag and drain the mutex before callingCleanupConnection, so a handler already mid-Acquirefinishes before its connID's state is wiped.- Body parse before claim. Handlers parse and validate the
JSON body before
BeginRequest. A slow / malicious body never bumpsinFlight, so the sweeper can still reap a stalled session.
A background sweeper reaps sessions whose lastSeen falls behind
2× --http-session-idle-timeout. lastSeen is refreshed on each
Lookup and on each BeginRequest exit.
Connection IDs¶
Both transports allocate connIDs from a single shared counter
(Server.NextConnID). That gives LockManager one flat namespace
of connections regardless of transport — CleanupConnection(id)
works the same way whether the id came from a TCP accept or an
HTTP session create.
connID 0 is reserved as a "skip per-connection bookkeeping" sentinel. The accept loop allocates from 1.
Two-phase operations¶
A two-phase acquire (enqueue then wait) splits the FIFO grant
into two requests so the caller can observe its queue position
before blocking. The pair share state via connEnqueued[(connID,
key)]:
Enqueueeither grants immediately (status"acquired", token in the response) or registers a waiter (status"queued", no token yet).Waitlooks up the per-(connID, key) state. If the waiter has already been promoted to holder (the same-tick fast path), it returns the token. Otherwise it blocks on the waiter's channel.- If
Enqueuereturned"queued"and the caller never callsWait, the connection's eventual disconnect (or session cleanup) closes the waiter channel.
The connID must be the same for the matching enqueue and wait.
On TCP that's automatic (same connection). On HTTP it's enforced
by carrying the same X-Dflockd-Session: <id> header.
Pre-refactor source¶
Older releases shipped a pub/sub layer (signal / listen /
unlisten commands, /v1/signals SSE) and an HTTP "bridge" that
ran the line protocol over net.Pipe per session. Both were
removed in v2; the source is preserved at old/ for reference but
excluded from the build via //go:build ignore. The current docs
describe only the v2 surface.