Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,18 @@ Tracked follow-ups (not in the seam spec):
`target_has_no_class`). Design
`docs/designs/atomic-add-relationship-design.md`; plan
`docs/superpowers/plans/2026-06-21-atomic-add-relationship.md`.
- **Batch `mutate([Mutation])`** — the tier-3 entry point.
- **Batch `mutate([Mutation])`** — IMPLEMENTED. Tier-3 batch entry point
`graphdb_mgr:mutate/1`: applies an ordered list of `add_relationship` /
`retire_node` / `unretire_node` mutations atomically in one
`graphdb_mgr:transaction/1`, composing tier-1 primitives directly. Opaque
bare-reason contract (`{ok, [ok, ...]}` | `{error, Reason}`, whole-batch
rollback, `mutate([]) -> {ok, []}`). Phase 2 resolves the seeded attr
nrefs once and allocates one rel-id pair per `add_relationship` outside
the transaction; phase 3 folds the prepared list in order. Required one
behaviour-preserving extraction —
`graphdb_instance:add_relationship_in_txn/9`. Design
`docs/designs/batch-mutate-design.md`; plan
`docs/superpowers/plans/2026-06-24-batch-mutate.md`.
- **Converge default-template name search** — IMPLEMENTED. The shared walk is
now `graphdb_class:find_template_by_name_in_txn/2` (exported tier-1
in-transaction primitive). `default_template_in_txn/1` delegates to it with
Expand Down
13 changes: 13 additions & 0 deletions apps/graphdb/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,12 @@ Creates and manages instance nodes in the project (instance space).

- `create_instance/3,4,5` (name, class_nref, compositional_parent_nref [, connection_resolver [, conflict_resolver]]) — atomically writes the node record AND the instance→class membership relationship pair (arc labels nref=29 and nref=30), then fires composition rules (F4 B2). Returns `{ok, Nref, Report}` on success or `{error, Reason, Report}` on rule-firing failure; pre-plan validation errors (unknown class, non-instantiable class, etc.) return `{error, Reason}` (2-tuple). Rejects a class marked non-instantiable with `{error, {class_not_instantiable, ClassNref}}` (L9). Propose-mode composition rules surface as `proposed` outcomes in the report (B3); nothing is materialised for them. `/4` threads a connection **resolver** (`fun((ConnContext) -> {connect, [Target]} | defer end`): the RESOLVE step fires effective ConnectionRules (F4 B4) — `mandatory` connections to existing targets land in the root transaction, `auto` post-commit, `defer`/`propose` are reported only; targets are validated (exists, instance, instance-of target_class-or-subclass). `/3` uses the built-in `report_only` (defer-all) connection resolver, so connection rules surface as `required`/`not_connected`/`proposed` outcomes and nothing is connected. `/5` threads a B5 **conflict resolver** (`fun((#{kind, rules, class_nref}) -> [Pair])`); `/3` and `/4` inject the built-in `graphdb_rules:default_conflict_resolver/0`, which shadows conflicting inherited rules (nearest-level winner by mode priority), merges multiplicity (nearest Min, greatest Max), and demotes both-real-template losers to `propose` (F4 B5).
- `add_relationship/4,5,6` (source_nref, characterization_nref, target_nref, reciprocal_nref [, template_nref [, {FwdAVPs, RevAVPs}]]) — validates endpoints, resolves source/target class and template scope, and writes the two directed `kind=connection` rows in a **single** `graphdb_mgr:transaction/1` (TOCTOU-isolated). The rel-id pair is allocated up-front (outside the transaction) via `rel_id_server:get_id_pair/0`. `/4` uses the source class's default template; `/5` takes an explicit template nref; `/6` adds per-direction AVPs.
- `add_relationship_in_txn/9` (IdPair, S, C, T, R, TemplateSpec, AVPSpec,
TkAttr, RetAttr) — tier-1 **in-transaction** primitive (bare-mnesia twin
of `add_relationship`'s transaction body; aborts on failure, never opens
its own txn). The caller allocates the rel-id pair up-front.
`do_add_relationship/7` (tier-2) and `graphdb_mgr:mutate/1` (tier-3) both
compose it into their single transaction.
- `add_class_membership/2` (instance_nref, class_nref) — adds a membership arc pair; also rejects a non-instantiable class target with `{error, {class_not_instantiable, ClassNref}}` (L9)
- `get_instance/1`, `children/1`, `compositional_ancestors/1`, `resolve_value/2`

Expand Down Expand Up @@ -371,6 +377,13 @@ Single public entry point; delegates to the five specialized workers.
- In `init/1`: checks if `nodes` table is empty; if so, calls `graphdb_bootstrap:load/0`
- Rejects any runtime request to create, modify, or delete a `category` node with `{error, category_nodes_are_immutable}`
- Sequences Nref allocation → record write → Nref confirmation
- `mutate/1` — tier-3 batch entry point. Applies an ordered list of
`add_relationship` / `retire_node` / `unretire_node` mutations atomically
in one `transaction/1` (all commit or none). Tagged-tuple grammar; opaque
bare-reason contract `{ok, [ok, ...]}` | `{error, Reason}` with whole-batch
rollback; `mutate([]) -> {ok, []}`. A **plain function**, not a
`gen_server:call` — it owns the transaction in the caller's process. See
`docs/designs/batch-mutate-design.md`.

---

Expand Down
54 changes: 38 additions & 16 deletions apps/graphdb/src/graphdb_instance.erl
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@
add_relationship/5,
add_relationship/6,
add_class_membership/2,
%% Tier-1 in-transaction primitive (write-path seam)
add_relationship_in_txn/9,
%% Lookups
get_instance/1,
children/1,
Expand Down Expand Up @@ -1192,28 +1194,48 @@ do_add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref,
RetAttr = State#state.retired_nref,
%% Allocate the rel-id pair up-front, OUTSIDE the transaction: get_id_pair
%% is a gen_server call and must never run inside an mnesia fun. A
%% validation abort below orphans this pair -- harmless (allocate-outside-
%% transaction doctrine).
%% validation abort inside the primitive orphans this pair -- harmless
%% (allocate-outside-transaction doctrine).
IdPair = rel_id_server:get_id_pair(),
Txn = fun() ->
ok = validate_arc_endpoints_in_txn(SourceNref, CharNref, TargetNref,
ReciprocalNref, TkAttr, RetAttr),
{SourceClass, TargetClass} =
resolve_arc_classes_in_txn(SourceNref, TargetNref),
TemplateNref = resolve_template_in_txn(TemplateSpec, SourceClass),
ok = graphdb_class:validate_template_scope_in_txn(TemplateNref,
SourceClass, TargetClass),
Rows = build_connection_rows(IdPair, SourceNref, CharNref, TargetNref,
ReciprocalNref, TemplateNref, AVPSpec),
lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end,
Rows)
end,
case graphdb_mgr:transaction(Txn) of
case graphdb_mgr:transaction(fun() ->
add_relationship_in_txn(IdPair, SourceNref, CharNref, TargetNref,
ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr)
end) of
{ok, ok} -> ok;
{error, _} = Err -> Err
end.


%%-----------------------------------------------------------------------------
%% add_relationship_in_txn(IdPair, Source, Char, Target, Reciprocal,
%% TemplateSpec, AVPSpec, TkAttr, RetAttr) -> ok
%%
%% Tier-1 write-path primitive. Must run inside an active mnesia transaction;
%% never opens its own. Validates endpoints, resolves source/target class and
%% template scope, then writes the two directed connection rows -- all with
%% bare mnesia ops, signalling any domain failure via mnesia:abort/1. The
%% rel-id pair must be allocated by the caller (get_id_pair is a gen_server
%% call and must never run inside an mnesia fun). Composes into a caller's
%% single transaction (the write-path seam's tier-1 contract); used by both
%% do_add_relationship/7 (tier-2) and graphdb_mgr:mutate/1 (tier-3).
%% Phase order: validate endpoints -> resolve classes -> resolve template ->
%% validate scope -> write.
%%-----------------------------------------------------------------------------
add_relationship_in_txn({_Id1, _Id2} = IdPair, SourceNref, CharNref,
TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr) ->
ok = validate_arc_endpoints_in_txn(SourceNref, CharNref, TargetNref,
ReciprocalNref, TkAttr, RetAttr),
{SourceClass, TargetClass} =
resolve_arc_classes_in_txn(SourceNref, TargetNref),
TemplateNref = resolve_template_in_txn(TemplateSpec, SourceClass),
ok = graphdb_class:validate_template_scope_in_txn(TemplateNref,
SourceClass, TargetClass),
Rows = build_connection_rows(IdPair, SourceNref, CharNref, TargetNref,
ReciprocalNref, TemplateNref, AVPSpec),
lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end,
Rows).


%%-----------------------------------------------------------------------------
%% validate_arc_endpoints_in_txn(Source, Char, Target, Reciprocal, TkAttr,
%% RetAttr) -> ok (aborts the enclosing transaction on any violation)
Expand Down
115 changes: 115 additions & 0 deletions apps/graphdb/src/graphdb_mgr.erl
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@
retire_node/1,
unretire_node/1,
update_node_avps/2,
%% Batch write (tier-3 entry point)
mutate/1,
%% Transaction helper (write-path seam)
transaction/1,
%% Cache invariant audit / repair
Expand Down Expand Up @@ -296,6 +298,119 @@ transaction(Fun) ->
end.


%%-----------------------------------------------------------------------------
%% mutate([Mutation]) -> {ok, [Result]} | {error, Reason}
%%
%% Tier-3 batch write entry point: applies an ordered list of mutations
%% ATOMICALLY in one graphdb_mgr:transaction/1, composing the write-path
%% seam's tier-1 primitives directly. All commit or none do.
%%
%% Mutation grammar (tagged tuples mirroring the public arities):
%% {add_relationship, S, C, T, R} default template, no AVPs
%% {add_relationship, S, C, T, R, Template} explicit template nref
%% {add_relationship, S, C, T, R, Template, {Fwd, Rev}} + per-direction AVPs
%% {retire_node, Nref}
%% {unretire_node, Nref}
%%
%% Returns {ok, [Result]} -- one native success value per mutation in list
%% order (every op returns `ok` today, so {ok, [ok, ok, ...]}) -- or the bare
%% {error, Reason} of the first aborting mutation with the whole batch rolled
%% back. mutate([]) -> {ok, []} (no transaction opened).
%%
%% Three phases: (1) static validation -- tuple shape + the permanent-tier
%% guard, no DB, no allocation; (2) a resource pre-pass OUTSIDE the
%% transaction -- resolve the seeded attr nrefs once and allocate one rel-id
%% pair per add_relationship (gen_server calls); (3) one transaction folding
%% the prepared list in order, dispatching each to a tier-1 in-txn primitive.
%%
%% Plain function, not a gen_server:call -- mnesia:transaction/1 runs in the
%% calling process and phase 2 calls OTHER gen_servers, so routing mutate
%% through graphdb_mgr would needlessly serialise batches.
%% See docs/designs/batch-mutate-design.md.
%%-----------------------------------------------------------------------------
-spec mutate([tuple()]) -> {ok, [term()]} | {error, term()}.
mutate(Mutations) ->
case validate_mutations(Mutations) of
ok -> run_mutations(Mutations);
{error, _} = Err -> Err
end.

%% Phase 1: static validation. No DB access, no allocation. A malformed term
%% -> {error, {bad_mutation, M}}; a permanent-tier retire/unretire ->
%% {error, permanent_node_immutable} (the same static guard set_retired/3
%% applies in the solo path).
validate_mutations([]) ->
ok;
validate_mutations([M | Rest]) ->
case validate_mutation(M) of
ok -> validate_mutations(Rest);
{error, _} = Err -> Err
end.

validate_mutation({add_relationship, _S, _C, _T, _R}) ->
ok;
validate_mutation({add_relationship, _S, _C, _T, _R, _Template}) ->
ok;
validate_mutation({add_relationship, _S, _C, _T, _R, _Template, {_Fwd, _Rev}}) ->
ok;
validate_mutation({retire_node, Nref}) when is_integer(Nref) ->
tier_guard(Nref);
validate_mutation({unretire_node, Nref}) when is_integer(Nref) ->
tier_guard(Nref);
validate_mutation(M) ->
{error, {bad_mutation, M}}.

tier_guard(Nref) when Nref >= ?NREF_START -> ok;
tier_guard(_Nref) -> {error, permanent_node_immutable}.

%% Phases 2 + 3. Precondition: Mutations already passed validate_mutations/1.
%% Empty batch short-circuits with no transaction.
run_mutations([]) ->
{ok, []};
run_mutations(Mutations) ->
%% Phase 2 (outside the transaction): resolve the seeded attr nrefs once,
%% and allocate one rel-id pair per add_relationship.
{ok, #{target_kind := TkAttr, retired := RetAttr}} =
graphdb_attr:seeded_nrefs(),
Prepared = [prepare(M) || M <- Mutations],
%% Phase 3: one transaction folding the prepared list in order.
graphdb_mgr:transaction(fun() ->
[dispatch(P, TkAttr, RetAttr) || P <- Prepared]
end).

%% Phase 2 per-mutation prep. Allocates one rel-id pair per add_relationship
%% via rel_id_server (a gen_server call -- MUST stay outside the transaction)
%% and normalises each add_relationship to the explicit
%% (TemplateSpec, AVPSpec) form. retire/unretire need no resources.
%% Prepared add_relationship shape:
%% {add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec}
prepare({add_relationship, S, C, T, R}) ->
{add_relationship, rel_id_server:get_id_pair(), S, C, T, R,
default, {[], []}};
prepare({add_relationship, S, C, T, R, Template}) ->
{add_relationship, rel_id_server:get_id_pair(), S, C, T, R,
Template, {[], []}};
prepare({add_relationship, S, C, T, R, Template, AVPSpec}) ->
{add_relationship, rel_id_server:get_id_pair(), S, C, T, R,
Template, AVPSpec};
prepare({retire_node, _Nref} = M) ->
M;
prepare({unretire_node, _Nref} = M) ->
M.

%% Phase 3 dispatch. Runs INSIDE the transaction: no gen_server calls, no
%% transaction/1, no rel-id allocation here (all done in phase 2). Each
%% tier-1 primitive returns ok or calls mnesia:abort/1.
dispatch({add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec},
TkAttr, RetAttr) ->
graphdb_instance:add_relationship_in_txn(IdPair, S, C, T, R, TemplateSpec,
AVPSpec, TkAttr, RetAttr);
dispatch({retire_node, Nref}, _TkAttr, RetAttr) ->
set_retired_(Nref, true, RetAttr);
dispatch({unretire_node, Nref}, _TkAttr, RetAttr) ->
set_retired_(Nref, false, RetAttr).


%%-----------------------------------------------------------------------------
%% verify_caches() -> ok | {error, [{Nref, Field, Expected, Actual}, ...]}
%%
Expand Down
Loading
Loading