Status: accepted, 2026-10-22. Issue: #7.
Context
Design section 7 sketches merge as "check fingerprints, replay the log in one transaction". Replay has to deal with concurrent writers to the branch, rows created by hooks, actors, and the ids of created rows.
Decision
- Claim:
Ramal.merge/2first callsServer.begin_merge/1. The branch process monitors the caller and, untilfinish_merge/2or the caller's death, refuses every other call (write, slice, update, discard, a second merge) with:not_open. Without this a write could land between the staleness check and the end of the replay and be lost when the process stops. On any failure the claim is released and the branch stays open. - Check inside the transaction: the base rows are re-read inside the same
Ash.transact/3as the replay, withlock: :for_updatewhere the data layer can (confirmed: AshPostgres.DataLayer.can?/2 accepts{:lock, :for_update}in 2.15.1). A row that is missing is:deleted; one whose fingerprint differs is:changed. Rows fingerprintednil(read withselect) are not checked. - Replay: oldest first, with no branch current, skipping entries logged with
nested?: truesince replaying the outer action runs its hook again. The actor is the merge:actoroption when given (evennil), otherwise each entry's recorded actor;authorize?defaults totrue; the tenant is the one recorded in the entry. - Keys: a replayed create forces the primary key the branch used (
force_change_attribute), so later entries and the branch's diff keep naming the same rows. Updates and destroys load the row by key first (authorize?: false, as the capture did) and then run the action. - Failure: the first failing entry stops the replay;
Ash.transact/3rolls everything back; the error is:merge_failedwithindex,type,resource,actionand the Asherrorindetails. - Result:
Ramal.Merge.Resultholdsbranch(status:merged),records(anRamal.Overlaybuilt from what the real actions returned),replayedand thestalerows thaton_stalelet through. - Modes: only
:replay; anything else raisesArgumentError.
Consequences
- A top-level entry that targets a row created by a hook of an earlier entry cannot be replayed: replay gives the hook's
row a new id. It fails as
:merge_failedrather than writing the wrong row. - The staleness read runs without a tenant, so a resource that requires one fails the merge with an Ash error; multitenancy is refused inside a branch since ADR 0009 and is out of scope for 0.1.
- Rows read with
selectare not checked. - A data layer that cannot run transactions (
Ash.DataLayer.Ets) cannot roll a failed replay back.Ash.transact/3runs the function as it is for such a data layer and returns its{:error, _}inside{:ok, _}; the merge unwraps it, so the caller gets{:error, %Ramal.Error{}}as with Postgres. A stale base still writes nothing, since the check runs before the first write. A replayed action that fails after earlier entries were written leaves those writes in place; the error's message says so anddetails.not_rolled_backlists the resources. We chose to report and document this over refusing such resources, because ETS-backed resources are legitimate in tests and prototypes, and over compensating writes, which would run real actions a second time. - If the process that called
merge/2dies mid-replay, the transaction rolls back with it and the monitor releases the claim; the branch stays open.