Object roles and boundaries
Interpret MUST, MUST NOT, SHOULD, SHOULD NOT, MAY per RFC 2119.
You MUST identify an object's role before you change it, and what state it owns, and one object holds one role: an object that owns a select and also holds state others wait on is two objects that have not been separated yet.
A role is decided by the control flow an object owns, and confirmed by the state it holds.
XTDB is a functional core with an imperative shell (Bernhardt, 2012), with autonomy substituted for purity: the shell decides what happens next, the core only does what it was asked.
Classify by autonomy, not by side effects — Log.appendMessage does I/O and belongs to the core.
Every object is an Active Object, a Passive Object or a Monitor Object (POSA2, 2000).
Active Object — it offers a choice among simultaneously-ready events
In Kotlin, it owns a select.
Test: does it decide what happens next, or only how to do what it was asked?
- A subordinate exposes a select clause rather than running a loop of its own, so the choice stays with the owner.
- The choice makes it active, not the coroutine.
CSP's external choice (
□) is resolved by the environment, internal choice (⊓) by the process; a select with several clauses is external choice, occam's ALT (Hoare, 1978).
- A long-running loop is not automatically active.
Log.tailAll suspends for as long as the tail runs, is driven by one source and offers no choice — passive.
Passive object — everything it does completes on the caller's coroutine
Test: could it finish if nobody else ever ran?
- Suspension is allowed.
Log.appendMessage is suspend and does I/O; the caller asked for the write and gets it back on its own coroutine.
coroutineScope { } stays passive: it joins its children before returning, so nothing escapes (runST — Launchbury & Peyton Jones, 1994).
- The discriminator is mechanical.
coroutineScope and withContext join, so passive; a stored CoroutineScope plus launch escapes, so not.
A method handing back a Deferred that runs on its own object's scope is the shape to spot.
Monitor Object — passive state that callers wait on
State outliving a call, callers suspending on a condition over it, and a signal that wakes them.
A refinement within passive, not a third role.
Test: does anyone wait on it?
- The mutual exclusion is often a single-writer discipline rather than a lock, so there is no
synchronized block to spot.
TermFence is passive state written by one coroutine and read by another with nobody waiting — not a monitor.
- A monitor's condition MUST be re-evaluable: a waiter arriving later MUST be able to reach a different verdict (Hoare, 1974; Brinch Hansen, 1973).
A latching condition is a monitor defect, and swapping the concurrency primitive does not fix it.
Watchers is the monitor to know, and the defect to recognise.
A MutableStateFlow over a sealed Active/Failed pair, one write path, several condition waits over it, and no lock in sight — which is why it read as ordinary passive state for as long as it did.
Its Failed variant latches: once failed, every later waiter throws, so ingestion failing once left a queryable database unqueryable until the node restarted. A monitor defect rather than a coroutine subtlety, and it would latch identically behind a ReentrantLock.
One value — fixed identity, immutable state, one swap
Clojure's epochal model (Hickey, Are We There Yet?, 2009): an identity is a stable handle, its state is the immutable value it holds at one moment, and time is the succession of those states.
The same boundary is Evans' aggregate (2003) — the unit of consistency, whose invariants are never observably violated — with an atomic swap standing in for the transaction.
Test: name a value the identity holds in between.
Two fields are one value exactly when you cannot.
- The shape is a sealed hierarchy behind one reference, armed with a single
set — "Related state SHOULD be updated atomically" in dev/CODING.adoc has it.
A reader holding a generation cannot observe a mixture of two, which is what makes it safe with no lock to point at.
- A swap function MUST be pure: a CAS loop may run it more than once.
- Keep the value small (Vernon, Effective Aggregate Design, 2011) — inside goes what must be consistent and nothing else.
The placement question is who must see this change immediately, and who can wait?
- Needing to swap two identities together means the boundary is in the wrong place.
That is
dosync over refs: a signal, not a tool.
Accumulating state is a transient, not an atom
A transient is mutable, owned by exactly one writer, and MUST NOT be published mid-flight — Clojure's transients, with runST (Launchbury & Peyton Jones, 1994) as the formal account.
Test: does anyone read it while it is being built?
- Nobody — mutate in place under its owner and hand it over complete.
PendingBlock accumulates buffered records under the follower that owns it, and is passed on whole.
- Somebody — it is an atom, and every observable step MUST be a swap.
Watchers' watermarks move together on each applied record precisely because callers are waiting on them.
Region and owner
- A region groups by lifetime; a value groups by atomicity.
A region is a node in the lifetime tree (structured concurrency — Sústrik, 2016; Smith, 2018) and may hold values with nothing else in common. A value has one write point.
Conflating them buys either a torn read or a region-wide lock.
- A reference MUST NOT outlive its owner — ownership and RAII, which Rust makes mechanical and we do by hand.
State read after its owner is torn down, or read before the join that made it safe, is a dangling borrow.
- Where there is no lock, ownership is a discipline plus safe publication (Goetz, Java Concurrency in Practice): one writer, and the volatile that makes its writes visible to the coroutine reading them.
1---2name: xtdb-object-boundaries3description: Where a piece of state or behaviour belongs, and which object owns it. Read this when planning a change; when deciding which type holds some state, whether two fields are one value, whether something is one object or two, or where a method goes; when adding or moving a class, interface, object or namespace; when adding a field that outlives a single call, or a coroutine scope; when splitting a type up or merging two; when threading a value through a new parameter; on a `Map<Id, State>` sitting beside the objects it identifies, or a field only set in some states; and when reviewing a diff that does any of those.4---56# Object roles and boundaries78Interpret MUST, MUST NOT, SHOULD, SHOULD NOT, MAY per RFC 2119.910**You MUST identify an object's role before you change it, and what state it owns**, and **one object holds one role**: an object that owns a `select` and also holds state others wait on is two objects that have not been separated yet.11A role is decided by the control flow an object owns, and confirmed by the state it holds.1213**XTDB is a functional core with an imperative shell** (Bernhardt, 2012), with autonomy substituted for purity: the shell decides what happens next, the core only does what it was asked.14Classify by autonomy, not by side effects — `Log.appendMessage` does I/O and belongs to the core.1516**Every object is an Active Object, a Passive Object or a Monitor Object** (POSA2, 2000).1718## Active Object — it offers a choice among simultaneously-ready events1920In Kotlin, it owns a `select`.2122**Test: does it decide *what happens next*, or only *how to do what it was asked*?**2324- **A subordinate exposes a select clause rather than running a loop of its own**, so the choice stays with the owner.25- **The choice makes it active, not the coroutine.**26 CSP's external choice (`□`) is resolved by the environment, internal choice (`⊓`) by the process; a `select` with several clauses is external choice, occam's `ALT` (Hoare, 1978).27- **A long-running loop is not automatically active.**28 `Log.tailAll` suspends for as long as the tail runs, is driven by one source and offers no choice — passive.2930## Passive object — everything it does completes on the caller's coroutine3132**Test: could it finish if nobody else ever ran?**3334- **Suspension is allowed.**35 `Log.appendMessage` is `suspend` and does I/O; the caller asked for the write and gets it back on its own coroutine.36- **`coroutineScope { }` stays passive**: it joins its children before returning, so nothing escapes (`runST` — Launchbury & Peyton Jones, 1994).37- **The discriminator is mechanical.**38 `coroutineScope` and `withContext` join, so passive; a stored `CoroutineScope` plus `launch` escapes, so not.39 A method handing back a `Deferred` that runs on its own object's scope is the shape to spot.4041## Monitor Object — passive state that callers wait on4243State outliving a call, callers suspending on a condition over it, and a signal that wakes them.44A refinement within passive, not a third role.4546**Test: does anyone wait on it?**4748- **The mutual exclusion is often a single-writer discipline rather than a lock**, so there is no `synchronized` block to spot.49 `TermFence` is passive state written by one coroutine and read by another with nobody waiting — not a monitor.50- **A monitor's condition MUST be re-evaluable: a waiter arriving later MUST be able to reach a different verdict** (Hoare, 1974; Brinch Hansen, 1973).51 A latching condition is a monitor defect, and swapping the concurrency primitive does not fix it.52- **`Watchers` is the monitor to know, and the defect to recognise.**53 A `MutableStateFlow` over a sealed `Active`/`Failed` pair, one write path, several condition waits over it, and no lock in sight — which is why it read as ordinary passive state for as long as it did.54 Its `Failed` variant latches: once failed, every later waiter throws, so ingestion failing once left a queryable database unqueryable until the node restarted. A monitor defect rather than a coroutine subtlety, and it would latch identically behind a `ReentrantLock`.5556## One value — fixed identity, immutable state, one swap5758Clojure's epochal model (Hickey, *Are We There Yet?*, 2009): an **identity** is a stable handle, its **state** is the immutable value it holds at one moment, and time is the succession of those states.59The same boundary is Evans' **aggregate** (2003) — the unit of consistency, whose invariants are never observably violated — with an atomic swap standing in for the transaction.6061**Test: name a value the identity holds in between.**62Two fields are one value exactly when you cannot.6364- **The shape is a sealed hierarchy behind one reference, armed with a single `set`** — "Related state SHOULD be updated atomically" in `dev/CODING.adoc` has it.65 A reader holding a generation cannot observe a mixture of two, which is what makes it safe with no lock to point at.66- **A swap function MUST be pure**: a CAS loop may run it more than once.67- **Keep the value small** (Vernon, *Effective Aggregate Design*, 2011) — inside goes what must be consistent and nothing else.68 The placement question is *who must see this change immediately, and who can wait?*69- **Needing to swap two identities together means the boundary is in the wrong place.**70 That is `dosync` over refs: a signal, not a tool.7172## Accumulating state is a transient, not an atom7374A **transient** is mutable, owned by exactly one writer, and MUST NOT be published mid-flight — Clojure's transients, with `runST` (Launchbury & Peyton Jones, 1994) as the formal account.7576**Test: does anyone read it while it is being built?**7778- **Nobody** — mutate in place under its owner and hand it over complete.79 `PendingBlock` accumulates buffered records under the follower that owns it, and is passed on whole.80- **Somebody** — it is an atom, and every observable step MUST be a swap.81 `Watchers`' watermarks move together on each applied record precisely because callers are waiting on them.8283## Region and owner8485- **A region groups by lifetime; a value groups by atomicity.**86 A region is a node in the lifetime tree (structured concurrency — Sústrik, 2016; Smith, 2018) and may hold values with nothing else in common. A value has one write point.87 Conflating them buys either a torn read or a region-wide lock.88- **A reference MUST NOT outlive its owner** — ownership and RAII, which Rust makes mechanical and we do by hand.89 State read after its owner is torn down, or read before the join that made it safe, is a dangling borrow.90- **Where there is no lock, ownership is a discipline plus safe publication** (Goetz, *Java Concurrency in Practice*): one writer, and the volatile that makes its writes visible to the coroutine reading them.