ADR-110: An update Block Says mut and Nothing Else, May Run More Than Once, and the Compiler Picks the Lock

Status: Superseded by ADR-281 — its D1-D5 are D12-D16 there, and D6 is D18 Date: September 16, 2026 Target Version: Nikaia 0.0.8 Related: ADR-059 (access reads, update writes), ADR-065 D2 (update_all), ADR-039 D1 (poisoning at yes, and §3’s door to a retry), ADR-067 D1 (a door’s block is sync and touches no lock), ADR-094 D3 (mut on a parameter is in-place change, and the caller’s value is what changes), ADR-037 D7 (plain against atomic, decided per value), lock-free.md (the measurement), Part I 6.3, Part II 12.2

1. Context

Locked data has four doors. Three of them were never in doubt: get copies the value out, set stores one computed outside, access reads in place and may not change what it is handed. The fourth, update, was handed the old value and returned the new one — a pure function, chosen so that the moment the lock opens and the moment it closes are the block’s edges and nothing else, which is what lets an implementation choose which lock, and whether to retry instead of locking at all.

Two things were wrong with the form and not with the idea. Part I 6.3 said the opposite of Part II about access — “not copied to append one” against “may not change it” — so a reader who trusted Part I wrote a change that the language below refused in its own words. And the pure form made a large value pay for being pure: handing a list to the block by value means moving it out of the lock, writing an empty marker where it lay, and moving it back, so that push on a ten-thousand-entry list was three lines and a vacated slot a panic could leave behind.

The way out is one form for the block, and one observation: changing a copy is the same as computing a new value. Whether v is a copy or the address of the value in the lock is the compiler’s to decide, by type, and that decision is exactly the one that decides whether a retry is possible.

2. Decisions

D1 — update has one form: the block takes mut v, changes it, and returns nothing.

kasse.update fn(mut v) { v += 100 }
protokoll.update fn(mut log) { log.push(entry) }

mut on the parameter is ADR-094 D3’s word with its meaning unchanged: the value is changed in place, and the caller’s value is what changes — here the caller is the lock. A block that hands a value back is refused as NK1141, and the message says an update changes v; there is nothing to return. A block that changes v without the word is NK1138, as any parameter is; a block that changes nothing is a no-op and is not refused.

access fn(v) { … } stays the read door, hands the value where it lies, and may not change it (ADR-059 D1). Part I 6.3’s sentence about appending is withdrawn: a ten-thousand-entry list is not copied to be asked its length.

D2 — What v is, is the compiler’s, by type: a copy where the value is a word, the address otherwise.

the value v is the door does
fits a machine word — a number, a bool, a small enum a copy on the stack load the word, run the block on the copy, compare-and-swap it in; on a collision load again and run again
anything else — a list, a map, a text, a struct the address in the lock take the lock, run the block once, release

Nothing is moved out of the lock in either row and no slot is ever empty. Which row a value falls in is read off its type, as ADR-037 D7 reads plain against atomic off a value, and explain reports it. The programmer writes one form and learns nothing about user_parallelism or word sizes from it. There is no spelling a program needs for speed.

D3 — An update block may run more than once, and a program cannot tell.

This is the permission the first row of D2 uses, and it is sound because the three things that would let a program notice are already refused at every door: the block is sync, so it does not wait; it touches no lock, so it does no I/O and takes nothing else (ADR-067 D1 — a println fails this, as it takes standard output’s own lock); and it captures its surroundings as views (Part I 5.3), so it changes nothing outside v. A block that runs again on a fresh copy leaves no trace of the run that lost.

ADR-039 §3 left this door open for want of “an additional assurance which does not exist yet”; the assurance is the touches column, which exists now. The retry is used only where the copy is free, which is the same boundary as the first row of D2 — a block on an address runs once, because a change to the address cannot be taken back.

D4 — A panic in the block: the word is untouched, the address is poisoned.

On a copy, a panic leaves the shared word as it was — nothing was swapped in. On the address, the value stays in the state the block reached, and what happens next is what ADR-039 D1 already says: an abort at no, where nobody is left to observe it, and poisoning at yes, where the next door on that lock refuses. The empty-slot state that update used to leave does not exist.

D5 — get is the one door that copies, and the report says where it copies something large.

get copies the whole value out, by definition. --sharing names every get on a type that does not copy cheaply, beside the count it already prints, so a list copied out of a lock is a line somebody reads and not a surprise in a profile. access is the door for a question about a large value.

D6 — update_all is D1 widened: one mut per lock, nothing returned.

update_all(konto_a, konto_b) fn(mut von, mut nach) {
    von -= 30
    nach += 30
}

Two words cannot be swapped together by one compare-and-swap on every target, so the pair takes the lock, in ADR-065 D3’s address order. The rule is D2’s, applied to the tuple of types.

3. Consequences

4. What this does not decide

5. What is built

D1, D4 and D6, and the address row of D2. fn(mut v) parses, update and update_all are handed &mut T, the Option is gone and with it the empty slot — and so is the emptied() panic that named it, because the state it reported cannot occur. update_all takes one mut per lock and returns nothing.

The block runs exactly once, which is D2’s address row for every value rather than only for the ones that are not a machine word. D3 licenses that outright — may run more than once is a permission and not a requirement — so what is left of D2 is speed: a copy and a compare-and-swap where the value fits a word.

What the corpus said, and it narrowed a rule. NK1138 at a door is D1’s, and the first build asked it of every lambda parameter, on the reasoning that one changed without mut is already broken Rust. It is not: par_fold(…, fn(acc, m) { acc.record(m) }) in examples/1brc.nika changes acc, has no mut, and compiles — the emitter writes the word itself where it recognises a fold’s accumulator, and and_modify fn(tally) { tally.bump() } in k-nucleotide.nika is the same shape one library over. So the question is asked at a door and nowhere else, and widening it belongs with whoever takes that mut out of the emitter.

And Part II 12.2’s counter compiles and runs again. It had stopped lowering the day the page was rewritten to this form, because a lambda’s parameter did not take mut; tests/specification/COMPILES.txt carries it as a program rather than a fragment now.

What is left:

  1. the checker: NK1141, and NK1138 where the word is missing built
  2. std’s lock over &mut T; the Option goes built — the compare-and-swap loop for word-sized T is not, and is the speed half;
  3. the emitter’s lowering of the block built for the address row; the second row and explain naming which are not;
  4. --sharing naming a get on a type that does not copy cheaply;
  5. NK2205’s help text and the tests that assert the old shape built.

#184 carries the rest.

This site is open source. Improve this page.