pub trait BindingBoundary: Send + Sync {
Show 22 methods
// Required methods
fn invoke_fn(
&self,
node_id: NodeId,
fn_id: FnId,
dep_data: &[DepBatch],
) -> FnResult;
fn custom_equals(
&self,
equals_handle: FnId,
a: HandleId,
b: HandleId,
) -> bool;
fn release_handle(&self, handle: HandleId);
// Provided methods
fn invoke_fn_with_core(
&self,
node_id: NodeId,
fn_id: FnId,
dep_data: &[DepBatch],
core: &dyn CoreFull,
) -> FnResult { ... }
fn retain_handle(&self, _handle: HandleId) { ... }
fn project_each(
&self,
_fn_id: FnId,
_inputs: &[HandleId],
) -> SmallVec<[HandleId; 1]> { ... }
fn predicate_each(
&self,
_fn_id: FnId,
_inputs: &[HandleId],
) -> SmallVec<[bool; 4]> { ... }
fn fold_each(
&self,
_fn_id: FnId,
_acc: HandleId,
_inputs: &[HandleId],
) -> SmallVec<[HandleId; 1]> { ... }
fn pairwise_pack(
&self,
_fn_id: FnId,
_prev: HandleId,
_current: HandleId,
) -> HandleId { ... }
fn pack_tuple(&self, _fn_id: FnId, _handles: &[HandleId]) -> HandleId { ... }
fn intern_node(&self, _node_id: NodeId) -> HandleId { ... }
fn producer_deactivate(
&self,
_node_id: NodeId,
_unsub: &dyn Fn(NodeId, SubscriptionId),
) { ... }
fn synthesize_pause_overflow_error(
&self,
_node_id: NodeId,
_dropped_count: u32,
_configured_max: usize,
_lock_held_duration_ms: u64,
) -> Option<HandleId> { ... }
fn cleanup_for(&self, _node_id: NodeId, _trigger: CleanupTrigger) { ... }
fn serialize_handle(&self, _handle: HandleId) -> Option<Value> { ... }
fn deserialize_value(&self, _value: Value) -> HandleId { ... }
fn invoke_tap_fn(&self, _fn_id: FnId, _handle: HandleId) { ... }
fn invoke_tap_error_fn(&self, _fn_id: FnId, _handle: HandleId) { ... }
fn invoke_tap_complete_fn(&self, _fn_id: FnId) { ... }
fn invoke_rescue_fn(
&self,
_fn_id: FnId,
_handle: HandleId,
) -> Result<HandleId, ()> { ... }
fn invoke_stratify_classifier_fn(
&self,
_fn_id: FnId,
_rules_handle: HandleId,
_value_handle: HandleId,
) -> bool { ... }
fn wipe_ctx(&self, _node_id: NodeId) { ... }
}Expand description
The FFI surface: every Core → user-code crossing goes through one of these three methods.
§Thread safety
Send + Sync because the Core dispatcher is sync but may be called from
multiple binding threads (e.g. multiple Node Workers sharing one Core via
Arc<Core>). Implementors must serialize access to the value registry
internally if needed. Free-threaded Python parity is the target — see
~/src/graphrefly-py compat/asyncio.py for the current shape that
will simplify dramatically once this trait is the substrate.
Required Methods§
Sourcefn invoke_fn(
&self,
node_id: NodeId,
fn_id: FnId,
dep_data: &[DepBatch],
) -> FnResult
fn invoke_fn( &self, node_id: NodeId, fn_id: FnId, dep_data: &[DepBatch], ) -> FnResult
Invoke a user function. The Core knows the fn’s identity (fn_id) and
the current dep batch data; the binding side dereferences handles,
runs the fn, registers the output, and returns the new handle.
dep_data carries per-dep batch arrays (R1.3.6.b coalescing): outside
batch() scope each dep has at most 1 DATA handle; inside batch(),
K consecutive emits on the same source produce K entries per dep.
The binding side bulk-dereferences all handles, calls user code, and
returns the result — one FFI call per fn fire regardless of dep count
or batch depth.
Errors thrown by user code are reported by returning a FnResult::Data
with a handle that resolves to an error value; the Core then forwards
[ERROR, handle] per R1.2.5. (Or the binding side surfaces them via a
separate channel — exact error-propagation discipline is binding-side.)
Sourcefn custom_equals(&self, equals_handle: FnId, a: HandleId, b: HandleId) -> bool
fn custom_equals(&self, equals_handle: FnId, a: HandleId, b: HandleId) -> bool
Custom equals oracle. Called only when a node declares
EqualsMode::Custom. Identity equals (the default) is a u64 compare
inside the Core — zero FFI per check.
Per H2 IdentityEqualsIsPureCore ASSUME in
~/src/graphrefly-ts/docs/research/handle-protocol.tla,
the binding-side impl MUST extend identity (i.e. always treat
a == b as equal at the handle level). Otherwise a node could observe
its own cached value as different from itself — a fundamental violation.
Sourcefn release_handle(&self, handle: HandleId)
fn release_handle(&self, handle: HandleId)
Decrement the refcount on handle. Called when the Core no longer
holds handle in any cache slot or message buffer. The binding side
drops the underlying value when its refcount reaches zero.
Implementing this as a no-op is safe during prototyping; it matters
for memory pressure under sustained load. The TS prototype’s
bindings.ts uses this to drive a Map<HandleId, { value, refcount }>.
§Leaf-operation contract (/qa F2/M1, 2026-05-10) — HARD requirement
Implementations MUST NOT re-enter Core from release_handle. The
Core’s lock-held release paths (Drop for CoreState,
Core::reset_for_fresh_lifecycle Phase 3 / 3b / 5,
OperatorScratch::release_handles callers) invoke
release_handle while the state mutex is held; re-entering Core
(via emit / subscribe / register / nested release_handle
on a different handle that triggers a final-Drop hook into Core,
etc.) deadlocks against that lock.
“Re-entry into Core” here means: calling any method on the same
Core instance (or any Core that shares state via Arc::clone
of the inner state mutex). Independent Cores are fine.
Safe operations inside release_handle:
- Pure value-registry bookkeeping (decrement refcount, drop the
underlying
Twhen count reaches zero). - Logging / metrics that don’t re-enter Core.
- Calling other binding methods that themselves honor the leaf-op contract (e.g., a binding-internal mutex).
Forbidden operations inside release_handle:
- Any
Core::emit/subscribe/register*/complete/error/teardown/invalidate/pause/resume/set_deps/batch/begin_batch. - Recursive
BindingBoundary::release_handleon this binding that fans into Core via a binding-side final-Drop.
Bindings that need lifecycle hooks on value-drop should buffer the events and process them asynchronously (e.g., next tick on the host runtime) so the Core lock is released before re-entrance.
Provided Methods§
Sourcefn invoke_fn_with_core(
&self,
node_id: NodeId,
fn_id: FnId,
dep_data: &[DepBatch],
core: &dyn CoreFull,
) -> FnResult
fn invoke_fn_with_core( &self, node_id: NodeId, fn_id: FnId, dep_data: &[DepBatch], core: &dyn CoreFull, ) -> FnResult
Invoke a user function with the owner-side full Core facade in
hand (D246 rule 5 / D245 Option A — the producer-build facade
hand-off, folding D245).
Core calls this — not the parameterless Self::invoke_fn — for
every fn-fire dispatch ([crate::batch] fire_regular Phase 2),
passing self as &dyn CoreFull (the one object-safe
mutation+inspection+serialize+mailbox facade, crate::node::CoreFull).
A binding whose invoke_fn builds a producer (the operators test
harness, the napi bench binding) needs a real Core surface during
the build — subscribe/register_* for upstream wiring, not just
the emit-only mailbox. D231 located producer build/teardown
owner-side with &Core; D245 made that concrete at the trait
surface so the build path receives a Core facade explicitly
without a thread-local / Core clone / stored back-reference
(all β-invalid under the actor model — Core is move-only).
The default forwards to Self::invoke_fn (ignoring core), so
every binding that does not build producers is completely
unaffected — core/structures/storage/graph test harnesses and the
napi non-producer paths keep their existing parameterless
invoke_fn semantics with zero behavior change. Only a
producer-building binding overrides this to construct its
ProducerCtx from the passed &dyn CoreFull.
Sourcefn retain_handle(&self, _handle: HandleId)
fn retain_handle(&self, _handle: HandleId)
Increment the refcount on handle. Called when the Core takes an
additional reference to an existing handle that the binding side
already interned — used by the pause buffer (a buffered
Data(H) outlives the cache slot that originally interned H, so
the buffer needs its own refcount share to keep H alive across
later cache replacements), the operator-scratch pipeline (Scan/Reduce
seed retain, Last default retain), and Phase G’s D-α fresh-scratch
install.
Default: no-op. Bindings that don’t track refcounts (e.g., trivial
always-alive registries) can leave the default. Bindings that do
(TS bindings.ts, the test runtime, the napi-rs bench harness) must
override to bump the per-handle refcount.
§Leaf-operation contract — HARD requirement
Same leaf-op contract as Self::release_handle: implementations
MUST NOT re-enter Core from retain_handle. Core call sites
(notably make_op_scratch_with_binding called from Phase G’s
lock-held window) assume the retain is a pure refcount bump.
Sourcefn project_each(
&self,
_fn_id: FnId,
_inputs: &[HandleId],
) -> SmallVec<[HandleId; 1]>
fn project_each( &self, _fn_id: FnId, _inputs: &[HandleId], ) -> SmallVec<[HandleId; 1]>
OperatorOp::Map — element-wise transform. For each input handle,
the binding dereferences to T, calls the user Fn(T) -> R, and
returns the new handle. Output length equals input length.
Sourcefn predicate_each(
&self,
_fn_id: FnId,
_inputs: &[HandleId],
) -> SmallVec<[bool; 4]>
fn predicate_each( &self, _fn_id: FnId, _inputs: &[HandleId], ) -> SmallVec<[bool; 4]>
OperatorOp::Filter — element-wise predicate. For each input
handle, the binding dereferences to T, calls the user
Fn(T) -> bool, and returns the boolean. Output length equals
input length.
Sourcefn fold_each(
&self,
_fn_id: FnId,
_acc: HandleId,
_inputs: &[HandleId],
) -> SmallVec<[HandleId; 1]>
fn fold_each( &self, _fn_id: FnId, _acc: HandleId, _inputs: &[HandleId], ) -> SmallVec<[HandleId; 1]>
OperatorOp::Scan / OperatorOp::Reduce — left-fold. The binding
dereferences acc to R and each input to T, runs
Fn(R, T) -> R for each input feeding the previous result forward,
and returns each intermediate R as a fresh handle. Output length
equals input length (Scan emits each entry; Reduce uses only the
last). The starting acc is owned by Core; the binding must NOT
release it.
Sourcefn pairwise_pack(
&self,
_fn_id: FnId,
_prev: HandleId,
_current: HandleId,
) -> HandleId
fn pairwise_pack( &self, _fn_id: FnId, _prev: HandleId, _current: HandleId, ) -> HandleId
OperatorOp::Pairwise — pack (prev, current) into a tuple value.
Called once per pair (Core iterates and updates prev between
calls). Returns the binding-side handle for the new tuple value.
Sourcefn pack_tuple(&self, _fn_id: FnId, _handles: &[HandleId]) -> HandleId
fn pack_tuple(&self, _fn_id: FnId, _handles: &[HandleId]) -> HandleId
OperatorOp::Combine / OperatorOp::WithLatestFrom — pack N
handles into a single tuple/array handle (Slice C-2, D020). The
binding dereferences each input handle to T, constructs a tuple
value (e.g., [T0, T1, ..., Tn]), and returns the new handle.
The returned handle has a pre-bumped retain (caller takes ownership
without additional retain_handle call).
Sourcefn intern_node(&self, _node_id: NodeId) -> HandleId
fn intern_node(&self, _node_id: NodeId) -> HandleId
Intern a NodeId as a value handle. Used by windowing operators
(window, window_count) to emit inner sub-node identities as
DATA payloads. The binding side stores the node id as a value in
its value registry and returns the corresponding HandleId.
The returned handle has a pre-bumped retain (caller takes ownership).
Default panics — bindings that ship window operators MUST override.
Sourcefn producer_deactivate(
&self,
_node_id: NodeId,
_unsub: &dyn Fn(NodeId, SubscriptionId),
)
fn producer_deactivate( &self, _node_id: NodeId, _unsub: &dyn Fn(NodeId, SubscriptionId), )
Called when a producer node loses its last subscriber. The binding
should drop any per-node state for node_id — captured closures,
recorded upstream (NodeId, SubscriptionId) pairs, etc. Default
no-op for bindings that don’t ship producers.
Fires lock-released; re-entrance into Core is permitted.
S2b / D229: core-level RAII Subscription is retired (D223/D225 —
a parameterless Drop cannot reach a relocating owned Core). The
binding therefore records upstream subs as (NodeId, SubscriptionId) and unsubscribes them HERE via unsub — a
Core::unsubscribe-capable closure the owner-driven unsubscribe
chain passes in (it holds the &Core already, exactly the
synchronous owner context D225 requires; no Weak<C>, no
unsafe, no parameterless-Drop). Calling unsub(up_node, up_sub) is behaviour-identical to the old Subscription::Drop
(it runs the same deregister + Phase-G chain, and re-entrant
producer cascades work because the call is lock-released).
Sourcefn synthesize_pause_overflow_error(
&self,
_node_id: NodeId,
_dropped_count: u32,
_configured_max: usize,
_lock_held_duration_ms: u64,
) -> Option<HandleId>
fn synthesize_pause_overflow_error( &self, _node_id: NodeId, _dropped_count: u32, _configured_max: usize, _lock_held_duration_ms: u64, ) -> Option<HandleId>
R1.3.8.c / Lock 6.A — synthesize an ERROR payload when a paused
node’s pause buffer overflows the configured cap. Called once per
overflow event (the first drop of a pause cycle); the returned
handle becomes the payload of Message::Error that Core then
emits via the standard terminal cascade.
Bindings that ship their own diagnostic shape (e.g. structured
{ code, nodeId, dropped, configuredMax, lockHeldDurationMs }
JSON for the JS / Python sides) override this to intern such a
value and return its handle.
Default returns None — Rust core falls back to the silent
drop + ResumeReport.dropped path. This preserves backward
compatibility for bindings that haven’t yet wired up R1.3.8.c.
New bindings SHOULD implement this method to satisfy the
canonical-spec invariant.
Returning None consumes the overflow event for the cycle
(QA A9, 2026-05-07): the per-pause-cycle overflow_reported flag
is set BEFORE this hook is called, so a None return doesn’t
re-attempt synthesis on subsequent overflows in the same cycle.
If the binding is going to dynamically wire up the hook
(configuration-driven), do so before any pause cycle that may
overflow.
Fires lock-released. The binding may re-enter Core (typical
implementation just calls intern_value and returns).
lock_held_duration_ms is the wall-clock-monotonic duration in
milliseconds since the node first transitioned Active → Paused
at the start of this pause cycle; it gives consumers a sense of
how long a leaked controller held the lockset before overflow.
Sub-millisecond durations truncate to 0 (QA A8, 2026-05-07).
Sourcefn cleanup_for(&self, _node_id: NodeId, _trigger: CleanupTrigger)
fn cleanup_for(&self, _node_id: NodeId, _trigger: CleanupTrigger)
Fire a registered user-cleanup hook for node_id at the lifecycle
moment indicated by trigger.
§Binding-side contract
Per D055, bindings own a Mutex<HashMap<NodeId, NodeCtxState>> where
NodeCtxState = { store, current_cleanup }. On each invoke_fn the
binding overwrites current_cleanup with whatever cleanup spec the
user fn returned. When Core later fires cleanup_for(node, trigger),
the binding’s impl looks up current_cleanup.<trigger_slot> and
invokes it if present. Absent slots are no-ops.
Lock discipline. Bindings MUST release their node_ctx lock
before invoking the user closure (clone the closure handle out of
the map, drop the lock, fire). Holding the binding-side lock
across the user closure deadlocks if the user re-enters the
binding’s high-level API from inside the cleanup.
Re-entrance into Core (D045 / D060). Permitted: release_handle,
Core::up(other_node, Pause/Resume/Invalidate/Teardown), Core::emit
for unrelated nodes, Core::subscribe / drop a Subscription. Not
permitted (undefined behavior): Core::emit(self_node, ...) from
inside OnRerun (creates a fresh emit during the wave’s pre-fire
window); Core::subscribe(self_node) from inside OnDeactivation
(self-resubscribe race during deactivation).
Panic isolation (D060). Core stays panic-naive about user code
— bindings SHOULD wrap user-closure invocations in catch_unwind
and surface failures via the host language’s idiom (JS exception
→ console.error, Python panic → warning, Rust panic → log + propagate
per binding policy). Core’s deferred-drain for OnInvalidate wraps
each cleanup_for call in catch_unwind itself to prevent a single
panic from short-circuiting the per-wave drain (all queued cleanup
attempts run; the last panic re-raises after the drain completes).
OnRerun and OnDeactivation fire inline lock-released — a
panicking cleanup_for propagates out of fire_regular Phase 1.5
or Subscription::Drop respectively (Drop guarantees apply: state
lock already released).
Panic-discard guarantee gap (D061). When a wave is panic-discarded
(an invoke_fn panics mid-wave), the queued OnInvalidate cleanup
hooks are dropped silently — the cascade’s recorded cache-clears
never fire their cleanups. External-resource cleanup (file handles,
network sockets, external transactions) attached to OnInvalidate
MUST be idempotent at process exit / next successful invalidate
cycle. OnRerun panic-discard is moot (panic in OnRerun aborts
the wave’s fire_regular before it can corrupt state). OnDeactivation
panic-discard is moot (Drop is invoked during stack unwinding;
double-panic during drop aborts per std::process::abort semantics).
Per-trigger lifecycle. See CleanupTrigger for per-variant
firing rules. After each trigger fires, the binding decides whether
to clear current_cleanup:
OnRerun: do NOT clear; the nextinvoke_fnwill overwrite.OnInvalidate: do NOT clear; multiple INVALIDATEs across waves can re-fire the same closure.OnDeactivation: clear (D059) — one-shot per activation cycle;storepersists separately per R2.4.6.
Default no-op so bindings without cleanup-hook support compile unchanged.
Sourcefn serialize_handle(&self, _handle: HandleId) -> Option<Value>
fn serialize_handle(&self, _handle: HandleId) -> Option<Value>
Serialize a handle’s value to JSON for snapshot persistence.
Returns None if the handle is unknown or unresolvable.
Called by Graph::snapshot() for each node’s cache handle.
The binding dereferences handle to its underlying T and
produces a serde_json::Value that will survive serialization
round-trips (i.e., JSON-safe: no functions, no circular refs).
Default returns None — bindings that don’t support snapshots
get value: null in the snapshot output.
Sourcefn deserialize_value(&self, _value: Value) -> HandleId
fn deserialize_value(&self, _value: Value) -> HandleId
Deserialize a JSON value back into a handle for snapshot restore.
The binding interns the value and returns its HandleId.
Called by Graph::restore() / Graph::from_snapshot() for each
node’s serialized value.
Default panics — bindings that restore snapshots MUST override.
Sourcefn invoke_tap_fn(&self, _fn_id: FnId, _handle: HandleId)
fn invoke_tap_fn(&self, _fn_id: FnId, _handle: HandleId)
Side-effect tap: invoke a user callback with a DATA handle for observation purposes. The callback must NOT produce a return value or modify the handle’s refcount — it is purely for side-effects (logging, metrics, debugging).
Called by tap and on_first_data operators on each (or first)
DATA emission.
Sourcefn invoke_tap_error_fn(&self, _fn_id: FnId, _handle: HandleId)
fn invoke_tap_error_fn(&self, _fn_id: FnId, _handle: HandleId)
Side-effect tap on ERROR: invoke a user callback with the error handle. Purely for observation — must not modify refcounts.
Sourcefn invoke_tap_complete_fn(&self, _fn_id: FnId)
fn invoke_tap_complete_fn(&self, _fn_id: FnId)
Side-effect tap on COMPLETE: invoke a user callback.
Sourcefn invoke_rescue_fn(
&self,
_fn_id: FnId,
_handle: HandleId,
) -> Result<HandleId, ()>
fn invoke_rescue_fn( &self, _fn_id: FnId, _handle: HandleId, ) -> Result<HandleId, ()>
Error recovery: invoke a user callback with the error handle.
Returns Ok(recovered_handle) if recovery succeeded (the
binding interns the recovered value and returns its handle with
a pre-bumped retain). Returns Err(()) if recovery failed and
the original error should propagate.
Sourcefn invoke_stratify_classifier_fn(
&self,
_fn_id: FnId,
_rules_handle: HandleId,
_value_handle: HandleId,
) -> bool
fn invoke_stratify_classifier_fn( &self, _fn_id: FnId, _rules_handle: HandleId, _value_handle: HandleId, ) -> bool
Stratify classifier — invoke a user predicate with BOTH the
latest rules handle AND the current value handle. Returns
true if the value belongs to this branch.
Called by graphrefly_operators::stratify_branch on each
source DATA. The binding-side closure (registered via
OperatorBinding::register_stratify_classifier) typically
dereferences both handles, looks up its branch’s rule by name
inside the rules array, and runs the rule’s classify(value).
Returning false for “rule not found” or “classifier threw”
matches TS stratify semantics.
Default panics — bindings that ship the stratify operator MUST override.
Sourcefn wipe_ctx(&self, _node_id: NodeId)
fn wipe_ctx(&self, _node_id: NodeId)
Wipe the binding-side ctx state for node_id.
Called by Core ONLY on resubscribable terminal reset, per R2.4.6:
ctx.store is “wiped automatically: on resubscribable terminal
reset (when a resubscribable: true node hits COMPLETE/ERROR
and is later resubscribed)”. Bindings drop their NodeCtxState
entry for node_id, releasing both store and any residual
current_cleanup.
Default deactivation does NOT trigger wipe — store persists
across deactivation/reactivation cycles by spec design (mirrored
here to match canonical; current TS impl wipes on deactivation
per docstring at node.ts:189-190 and is on the Phase 13.6.B
migration list per canonical-spec §11 item 3).
Fires lock-released. Re-entrance into Core is permitted; typical
implementations just call node_ctx.lock().remove(&node_id).
Default no-op.