← 1.0.0 backlogs ISS-9

A read-only composed operation takes the write lock

Type
Debt — correct but pessimistic
Found while
SAPE-20
Triage
Defer
Status
Open
Severity
Low — no wrong answers, but it cancels SAPE-18 for the feature SAPE-20 sells

Description

SAPE-18 made reads share a database. A composed operation gets none of it, whatever it does, because composition is spelled action: batch and batch is classified as a write.

// internal/store/invoke.go:259
func writes(action string) bool {
	switch action {
	case ActionInsert, ActionPut, ActionUpdate, ActionDelete, ActionBatch:
		return true
	}
	return false
}

SharedRead asks writes(operation.Action) first and returns shared = false at once (invoke.go:470-472). invoke then drops the read lock, takes db.mutex.Lock() and serializes against every other call on that database (internal/server/server.go:754-760). A composed operation of three scans — the headline use — is a pure read that behaves like a write.

Evidence

Triage

Does it lose data, break a promise on a public surface, or stop somebody installing and running? No. Taking a stronger lock than needed is always safe. The wire does not change, no answer changes, and nobody is blocked.

Defer. The fix is a real design decision, not a one-liner: deciding "this batch only reads" means either recomputing it per call by walking the steps and their callees — the same recursive walk ceiling() does — or deriving it once at declaration time and storing it on the Operation. The second is better and it puts a new field on a stored declaration, which is a schema decision that deserves more than release-week attention.

If deferred, what it costs to wait