0007. Merge: claim, check, replay

Copy Markdown View Source

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/2 first calls Server.begin_merge/1. The branch process monitors the caller and, until finish_merge/2 or 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/3 as the replay, with lock: :for_update where 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 fingerprinted nil (read with select) are not checked.
  • Replay: oldest first, with no branch current, skipping entries logged with nested?: true since replaying the outer action runs its hook again. The actor is the merge :actor option when given (even nil), otherwise each entry's recorded actor; authorize? defaults to true; 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/3 rolls everything back; the error is :merge_failed with index, type, resource, action and the Ash error in details.
  • Result: Ramal.Merge.Result holds branch (status :merged), records (an Ramal.Overlay built from what the real actions returned), replayed and the stale rows that on_stale let through.
  • Modes: only :replay; anything else raises ArgumentError.

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_failed rather 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 select are not checked.
  • A data layer that cannot run transactions (Ash.DataLayer.Ets) cannot roll a failed replay back. Ash.transact/3 runs 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 and details.not_rolled_back lists 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/2 dies mid-replay, the transaction rolls back with it and the monitor releases the claim; the branch stays open.