Skip to content

Commit 4fb96cb

Browse files
prepare-sync: engine wiring + phase-gated forward in _dispatch
Step 2 of PR-C.3. ``_dispatch`` now calls each subscribed observer's optional ``prepare_sync(event)`` synchronously BEFORE queueing for ``"started"``-phase events, with the same isolation contract as the async path: ``warnings.warn`` on exception, doesn't block queueing or subsequent events. Phase-gated: forwarding to ``prepare_sync`` only fires when ``"started"`` is in the subscribed observer's ``phases`` set — mirrors how ``deliver_loop`` filters async dispatch. A user who explicitly subscribes only to ``{"completed"}`` gets neither the sync prep nor the async started events, so the wrapper acts as a uniform phase shield across both axes. Hook is opt-in via ``hasattr`` — observers without ``prepare_sync`` are unaffected. OTel observer's ``prepare_sync`` method lands in step 3.
1 parent 3d715a4 commit 4fb96cb

1 file changed

Lines changed: 34 additions & 0 deletions

File tree

src/openarmature/graph/observer.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,12 +363,46 @@ def take_step(self) -> int:
363363
def _dispatch(context: _InvocationContext, event: NodeEvent) -> None:
364364
"""Enqueue a node event for the delivery worker.
365365
366+
For ``"started"``-phase events, also call any subscribed observer's
367+
optional ``prepare_sync(event)`` synchronously — in the engine task,
368+
BEFORE queueing — so observers that need to publish per-event state
369+
the engine itself reads in the same engine-task scope (e.g., the
370+
OTel observer setting ``current_active_observer_span`` for the
371+
engine to attach into the OTel context) can do so before the node
372+
body runs.
373+
374+
Phase-gated forwarding: ``prepare_sync`` only fires when ``"started"``
375+
is in the subscribed observer's ``phases`` set, mirroring how the
376+
async ``deliver_loop`` filters dispatch. A user who explicitly
377+
subscribes only to ``{"completed"}`` doesn't get the synchronous
378+
prep — the wrapper acts as a uniform phase shield across both
379+
sync prep and async dispatch.
380+
381+
Errors from ``prepare_sync`` follow the same isolation contract as
382+
the async path per spec §6: don't propagate, don't break siblings,
383+
don't block the queueing or subsequent events. Reported via
384+
``warnings.warn``.
385+
366386
No-op when no observers exist for this depth — avoids paying the queue
367387
overhead for graphs that don't observe anything.
368388
"""
369389
observers = context.full_observers()
370390
if not observers:
371391
return
392+
if event.phase == "started":
393+
for subscribed in observers:
394+
if "started" not in subscribed.phases:
395+
continue
396+
prepare_sync = getattr(subscribed.observer, "prepare_sync", None)
397+
if prepare_sync is None:
398+
continue
399+
try:
400+
prepare_sync(event)
401+
except Exception as e:
402+
warnings.warn(
403+
f"observer prepare_sync raised {type(e).__name__}: {e}",
404+
stacklevel=2,
405+
)
372406
context.queue.put_nowait(_QueuedItem(event=event, observers=observers))
373407

374408

0 commit comments

Comments
 (0)