Ownership and concurrency¶
The daemon is the single process that opens the vault: it holds the vault lock exclusively for its entire lifetime, and every other consumer — CLI, agents — reaches the vault only through its HTTP API (Daemon). Two layers below that still do the real coordination work: SQLite serializes metadata writes, and an in-process gate serializes the daemon's own maintenance operations against its regular ones.
What SQLite handles¶
Every tree mutation is one transaction; the store opens the database in
WAL mode with BEGIN IMMEDIATE write transactions and a busy timeout.
Concurrent API requests interleave safely — invariants are enforced by
the schema, and losers of a name race get a typed name already exists
error, not corruption.
What SQLite can't handle¶
Garbage collection reads the database ("which blobs are unreachable?"),
then deletes files from blobs/. Between those two steps, a concurrent
import could ingest the same content, observe the blob file still
present, deduplicate against it — and then GC deletes the file out from
under a freshly committed reference. No database transaction can close
that window, because half of it lives on the filesystem.
The same shape recurs at startup: clearing stale blobs/tmp/ files must
not delete a temp file another process is actively writing.
The vault lock: one exclusive holder per vault tree¶
~/.docbank/vault.lock is an advisory byte-range file lock that docbank
daemon run takes exclusively (TryLockExclusive) at startup and releases
only on shutdown. Unix uses flock(2) and Windows uses LockFileEx. Because
it's a single long-lived process rather than one lock
acquisition per command, no per-command shared/exclusive split remains:
with all access funneled through one process, the daemon is the
serialization point, and a second daemon on the same vault is impossible
by construction.
Restore uses the same lock for its separate target tree. Kit first opens the target without following a final symlink and keeps that directory descriptor through publication. Docbank validates and locks that exact held directory, then Kit performs every cleanup and write relative to it. Renaming or replacing the target pathname cannot redirect a restore into the live vault, repository, or another tree.
A vault.lock alone cannot coordinate overlapping roots: /restore and
/restore/nested contain different lock files. Docbank therefore also keeps a
canonical per-user target-lock registry at
~/.local/state/docbank/target-locks, resolved from the operating-system user
record rather than HOME or XDG environment variables. Each daemon or restore
takes shared locks for the filesystem identities of all ancestors and an
exclusive lock for its root identity. Unix keys these identities by device and
inode; Windows uses volume serial and file ID. Parent and descendant trees
consequently conflict in either acquisition order, while disjoint sibling vault
daemons remain independent.
The persistent registry files contain no vault data and must not be removed;
their stable names are coordination state keyed by the platform filesystem
identity above. These
locks coordinate Docbank daemons and restores that retain the paths they were
given. They do not attempt to make arbitrary same-user filesystem reparenting a
safe operation: a process able to move a restore root into another live vault
already has the authority to modify that vault directly. The held os.Root
still prevents such a rename from redirecting restore writes through the old
pathname.
Restore acquires this hierarchy before writing even when the target is fresh, while the serving daemon continues to hold the live vault's hierarchy. This prevents a second restore, a daemon pointed at the target, or a restore nested inside an active vault from racing publication. Daemon startup creates only the root needed for locking before it attempts the lock; it does not initialize the database, blob tree, logs, or configuration first.
TryLockExclusive is non-blocking: a second docbank daemon run or restore
against an overlapping vault tree fails immediately rather than hanging.
This matches the daemon's role — waiting to acquire a lock another daemon holds
for its entire lifetime would mean waiting indefinitely. Restore reports its
conflict as backup_restore_target_active.
The vault.lock pathname is stable coordination state, not a success marker.
It remains after both successful and failed restores. Removing a held lock file
would allow a contender to create and lock a different inode at the same path,
breaking mutual exclusion; restore retries therefore ignore the retained file
when applying the empty-target rule.
Startup blob-tmp cleanup (blob.CleanTmp, the same stale-temp-file
problem described above) needs no locking scheme of its own anymore: the
daemon holding the vault lock exclusively at that point in startup
proves it's the sole process that could have left those files
mid-write, so cleanup is unconditional rather than a best-effort
non-blocking attempt.
The lock implementation is platform-specific without changing the contract.
Unix retries interrupted flock calls; Windows uses non-blocking shared or
exclusive LockFileEx ranges. Windows directory identities come from opened
handles, and final reparse points are rejected in the same places Unix rejects
symlinks. The target-lock registry and vault root are private to the current
user: POSIX modes enforce this on Unix and restricted DACLs enforce it on
Windows.
The maintenance gate: serializing inside the daemon¶
With one process holding the vault lock for its whole run, gc --run,
trash empty, and verify cannot take the vault lock exclusively per
command — the daemon already holds it.
Instead, an in-process sync.RWMutex-shaped gate serializes maintenance
against regular mutations: ordinary mutating API handlers take the read
side (concurrent with each other), and gc --run/trash empty/verify
take the write side, giving them the same "observe a quiescent vault"
guarantee as an exclusive per-command lock. Requests queue rather
than fail. See HTTP API: maintenance gate
for the request-handling detail.
First-open bootstrap¶
Creating a fresh vault has its own race: SQLite's WAL-mode conversion
and autocommit DDL both acquire locks in ways that can fail immediately
without consulting the busy handler when two processes race first
contact. The store applies the schema and creates the root inside one
BEGIN IMMEDIATE transaction and retries SQLITE_BUSY with a bounded
backoff; every statement is idempotent, so whichever process wins, both
converge on the same initialized vault.
docbank daemon run takes the vault lock before opening the store, so two
daemons racing to bootstrap the same fresh vault can no longer both
reach store.Open at once — the loser fails at the vault lock instead. The
retry logic stays in internal/store regardless: it's exercised
directly by the store package's own tests, and it's the correct
behavior for any caller that opens the store without first taking the
vault lock.