database.mutex was a sync.Mutex: every call to one database queued behind
every other, read or write. It is now sync.RWMutex
(internal/server/server.go:95).
Which of the two a call is cannot be known before the operation is looked up, and the lookup is itself
a read of the tree. So invoke takes the read lock, asks
store.SharedRead, and a call that turns out to read runs right there on the operation
already in hand (server.go:736-750). One that writes drops the read lock, takes the write
lock, and looks the operation up again — a declaration could have changed in between, and the
one it runs must be the one it holds the lock for.
Store.SharedRead (internal/store/invoke.go:462) shares only when two things
hold. The action must not change anything, which is what writes() already decides. And
the collection must not be partitioned: for a partitioned collection even a get goes through
Collection.into, which opens the partition file if it is not open, creates it if it is
new, and then runs expiry, which drops files. A read there is a writer wearing a reader's name.
One reader at a time per database is a ceiling a stranger hits on the first load test, and nobody had measured it. It also had to land before composed operations: the read/write split is what decides which lock a composed call takes, and getting that wrong after the wire is public is not a change you can make quietly.
TestReadsRunTogether (internal/server/parallel_reads_test.go:20) is a
timing measurement, not an assertion about locks — a lock is not what a caller pays. Four
counts over 4000 documents, run solo and then four at once over a real socket. Serialized measured
3.9–4.0× on this machine; shared measured 1.6–2.1×. The bar sits at
3×, so it fails on queueing rather than on a loaded box. A 2× bar was tried and measured
flaky.TestReadsRunTogetherAtN32 (parallel_reads_test.go:148) repeats it at 32
readers.TestPartitionedReadsAreWrites (partition_reads_test.go:20) pins the
exception, so the partition carve-out is measured rather than commented.
390e976 closed a leak this work turned up. Server.Close closed
db.pages, which lets go of the leader file only; the per-partition files stayed locked
for the life of the process, and the next Server on the same directory was refused with
vfs.ErrLocked on a .part file until a finalizer happened to run. It now
closes the partitions first, then the leader (server.go:349-363).
action: batch,
writes() says true, and it takes the write lock —
ISS-9.Server.Close only. The same shape is still open
in internal/cli's open() — ISS-7.Explore; this covers the invoke
path.