couch.

Locks in couch-confd

Read this before you add a lock, a cache, a background thread or a callback to daemon/couch-confd. It is for people changing the daemon, not for people writing an integration.

The daemon has four HTTP workers per port, a thread per panel request on plugin.sock (at most eight), a one-second tick, the legacy converter, the ten-second package sweep and one worker per running package child. They share the locks below. A pair of them taken in opposite orders by two threads stops the daemon for good, and because almost every request reads the configuration, "stops" means the whole API. That has already happened once in review (couch #256: the sweep held the package registry and asked for the configuration; deleting a connection held the configuration and asked for the registry).

The order

A thread may wait for a lock only while every lock it already holds is higher up this list. Two locks on the same line are never waited for together.

# Lock Where What it protects Held for
1 connection gate (read/write) gate_for, api/connections.rs that a connection's folder exists for as long as a request is working in it a whole /api/connections/<id>/… request (read); a deletion (write)
2 pairing session PairingSlot::session, plugins.rs one pairing conversation and its package child one step, up to 12 s inside the package
3 connection settings lock lock_for, api/connections.rs one connection's settings, key and running child: one request at a time per connection a whole request to the package or device, up to 12 s
4 configuration store Api::store, api.rs config.json and its revision one read, or one edit and its write to disk
5 catalog generations Runtime::catalog_generations which manifests the tick has already copied into the configuration the tick's pass
6 package registry Runtime::endpoints which package child serves which connection a map operation
7 children listing cache Runtime::children the last listing of each connection a map operation
8 pairing map Runtime::pairings which session each connection is pairing through a map operation
9 pairing times Runtime::paired_at when each connection last finished pairing a map operation
10 unreadable key notes Runtime::unreadable_keys what has already been said in the log a set operation

The names and numbers in code are lock_order::level (ConnectionGate = 10 … UnreadableKeys = 100, with gaps). A new lock gets a line here and a level there, between the two locks it sits between.

try_lock, try_read and try_write are outside the rule, because a thread that does not wait cannot be half of a deadlock. The daemon depends on that in three places, and they must stay tries:

Not in the list, and why

These are not checked by lock_order. The rule for all of them is the same: take nothing else while holding one.

Never call out while holding 4 to 10

Locks 4 to 10 are needed in passing by almost every request. While a thread holds one it must not:

lock_order::calling_out("what") marks the start of something slow. Put it in front of any new one - a media fetch, a watch that waits for a package - and a debug build panics if a thread gets there holding any of 4 to 10.

Three places do not live up to this today, all bounded and all known:

  1. Deleting a connection stops its package child, and tells a pairing in flight that it is over (up to 2 s), while it holds the configuration store.
  2. Dropping a Running entry kills its child and joins its worker. retain, clear and remove on the registry do that under lock 6. It never waits for a request (whoever is mid-request holds its own reference, so the last one is dropped by them, outside the registry), only for a killed process to be reaped. retire_endpoint shows the better shape.
  3. Lock 5 is held for the tick's whole pass, package-store reads included. It is the tick thread's alone; nothing else may ever take it.

The patterns

Snapshot, then release. Read what you need out of the configuration into a value, let go, then do the work. Api::with closures return data, never act. The sweep is the example: connections_in_use() builds the set first, and reap is handed something that only looks things up in it.

Take things out, then drop them. Remove from a map under its lock, let the lock go, then drop or end what you took out: retire_endpoint, end_pairing, pair_start replacing a session.

Try, and say busy. Anything held across a package's answer is tried, never waited for, and the caller gets busy (HTTP 503 or 409, Error::Busy on the panel's socket). When the thing in hand cannot be asked for again - a key a device has just issued - try for a bounded time with patiently and park the result if that runs out (write_done).

Mark, do not wait. To end something another thread is in the middle of, take it out of its map and set a flag it checks when it comes back (PairingSlot::cancelled). Never wait for the thread.

A long wait holds nothing and no worker. There are four workers. A request that waits for something to happen (a watch, a long-poll) must not hold the gate, a settings lock or any of 4 to 10 while it waits, and must not be able to occupy all four workers: with the gate held, a connection being watched could never be deleted, and four open watches would be the whole API.

The check

daemon/couch-confd/src/lock_order.rs. Locks 1 to 10 are RankedMutex<Level, T> or RankedRwLock<Level, T>, with the same methods and results as the std types. In a debug build each thread keeps a list of what it holds, and:

cargo test is a debug build, so every daemon test runs under it, and so does a host daemon started from target/debug. A release build compiles it away: lock is the inner Mutex::lock and returns the std guard.

What it cannot see: the file locks and leaves above, anything in another process, a Drop that does slow work, and any path no test or debug run goes down. A new lock that is left as a plain Mutex is invisible to it, which is why a new lock gets a level.