Skip to content

refactor: Run the graph on the caller's thread - #321

Merged
binaryaaron merged 5 commits into
aagonzales/graph-sdk/feat/mainfrom
binaryaaron/graph-sdk/refactor/sync-core
Oct 22, 2026
Merged

binaryaaron merged 5 commits into
aagonzales/graph-sdk/feat/mainfrom
binaryaaron/graph-sdk/refactor/sync-core

Conversation

@binaryaaron

Copy link
Copy Markdown
Collaborator

Branch binaryaaron/graph-sdk/refactor/sync-core, five commits on the graph SDK line (PR #319). Branched at e0d8b317; it merges cleanly onto the current line head dddd02fa, which is one docs commit ahead.

Summary

The synchronous facade (run(), preview(), evaluate()) no longer starts a thread per invocation. Each invocation runs its graph on a private event loop on the caller's own thread, and every body runs inline on that thread. Ctrl-C therefore lands where 0.4.0 put it: inside DataDesigner's wait on the caller's thread.

Three steps make that possible, one commit each, plus one commit of tests and one of docs:

  1. Substitute's map runs opaque. It now uses DataDesigner's own transport, retries and scheduling on every route, as it already did when its model skipped the health check, and as 0.4.0 does. The graph accounts it like Redact, Annotate, Hash and Rewrite: one invocation with unknown physical requests. The per-request bridge, its physical transport and the development-only qualified detection profile (its other consumer, not reachable from the facade) are deleted.
  2. Bookkeeping is synchronous. Bodies and DataDesigner's per-row hooks no longer call back into the graph's event loop and wait for it. Their bookkeeping never suspends, so it runs on the calling thread under one lock; drive() raises if it ever suspends. Rewrite's repair loop needs the scheduler to start the next iteration between two bookkeeping calls, so a synchronous call that changed the ledger applies one scheduler step that starts subgraphs and closes blocked work but launches nothing. Owners with physical children (the SDK's worker path) keep their event-loop gate.
  3. Private loop on the caller's thread. new_event_loop() + run_until_complete, with no asyncio.Runner and so no SIGINT handler. A caller already inside a running loop (Jupyter, asyncio.run) is blocked by the synchronous call anyway. Its running-loop marker is set aside for the private loop and restored around each body. On Python 3.14, which tracks the executing task per thread, the caller's current task is swapped with the marker. The host thread, the caller-thread queue, handoff and the interrupt settle/drain code are gone.
  4. Tests. One subprocess Ctrl-C test with three cases replaces seven interrupt tests. The same file passes unchanged against 0.4.0 (main).
  5. Docs. The graph SDK notes describe where graph bookkeeping now runs, and the deleted qualified detection profile is dropped from them.

Behaviour

Area Before (graph SDK line) After 0.4.0
Threads per run() one anonymizer-graph-host per invocation (two or three with a delegating detector) none from Anonymizer; only DataDesigner's engine threads DataDesigner's only
Where caller code, bodies, DataDesigner calls and graph bookkeeping the thread that triggers it: the caller's thread for bodies, DataDesigner's threads for its per-row hooks (under the runtime lock), except bookkeeping on the host thread caller's thread caller's thread
Ctrl-C during run()/preview() caught in the host-queue server, then settled and drained raised inside DataDesigner's wait; DataDesigner cancels its requests; the run raises KeyboardInterrupt same as after
Ctrl-C inside asyncio.run needs a second Ctrl-C needs a second Ctrl-C needs a second Ctrl-C
Jupyter (running loop in every cell) works through the host thread works through the marker swap; Kernel → Interrupt stops the run and the kernel stays healthy works
Substitute map with a health-checked model per-request graph accounting through a bridged transport DataDesigner's own transport; the graph records no physical requests DataDesigner's own transport
Caller detector **kwargs received request_bridge=None no request_bridge keyword no request_bridge keyword

Rows, failed records, retries and dtypes show no differences against 0.4.0 beyond the line head's 102 accepted (allowlisted) rows: the parity corpus (166 cases, pandas 2 and 3) reports 0 blocking differences at every commit, with the same allowlisted rows as the line head.

Context variables: copy-in holds as before (values set before the call are visible inside); copy-out still differs from 0.4.0, where a value set inside caller code was visible after run() returned. That is the accepted delta recorded in the PR #319 status block, unchanged by this PR.

Not a compatibility contract

Until the graph SDK is published (REQ 13), nothing about anonymizer.graph or the engine modules behind it is a compatibility contract: names, export order, signatures, defaults, exception constructors, class pickles, thread names, the graph's request inventory and import cost may change. Tests that pin them are refactor safety nets, re-recorded with the change that moves them. What users can rely on is the shipped Anonymizer/config API, its result columns and errors, and Ctrl-C behaviour; this PR keeps those as the parity corpus and the subprocess Ctrl-C test show. anonymizer.graph.__all__ (228 names) and every public signature are byte-identical to the base.

Removed engine names and DataDesigner private hooks

Removed engine-level names (none re-exported by anonymizer.graph): harness.kernel.{acquire_native_transport, qualify_detection_redact, ensure_async_engine_loop, NATIVE_TRANSPORT_GATE_TIMEOUT_NS}; detection._graph_profile.{execute_detection_redact, ensure_async_engine_loop} and the run_detection_redact(limits=, opaque=) keywords; DetectionRedactInvocation.submit and its fields physical, slots, execution, semantic_loop, handoff; DetectionBinding.check_outcomes; ndd._graph_requests.{NddPhysicalTransport, NddRequestBridge, SingleAttemptTransportError, require_binding_capabilities, scheduler_salvage_rounds, skip_health_checks_env}; the native-map route in ndd._route; the module replace/_native_map_requests.py. A caller detector declared with **kwargs no longer receives request_bridge, which matches 0.4.0.

DataDesigner private dependencies removed: DataDesigner._create_resource_provider; ModelRegistry(model_facade_factory=) and ModelRegistry._model_facade_factory; ModelFacade.agenerate as a bridge hook; models.clients.retry.create_retry_transport; the _HTTP_CLIENT_FIELDS coupling; readiness._SKIP_MODEL_HEALTH_CHECKS_ENV; AsyncTaskScheduler(salvage_max_rounds=); the bridge's use of parsers.errors.ParserException (the judge's use remains, as on main).

Private hooks that remain: DataDesigner._create_dataset_builder, BatchStage, SDG_CONFIG_FILENAME and DatasetBuilder/ArtifactStorage internals (engine/ndd/_graph_requests.py:51-82), and the generator.agenerate patch in engine/ndd/_source_frame.py.

Size

Commit src net
Opaque Substitute map, delete the native request path -1,722
Synchronous bookkeeping +35
Private loop on the caller's thread -105
Tests 0
Total -1,792 (+324 / -2,116)

Tests: +334 / -2,673. Docs: +12 / -32.

Testing

  • Full suite on pandas 2.3.3 and 3.0.6 at every commit (5,538 passed on each at the tip). make format-check, make typecheck and mkdocs build --strict pass.
  • Parity corpus against main at every commit: 0 blocking (pandas 2 and 3), plus the main+fix(interface): only pass entity_label_examples when the detector accepts it #311 reference cases.
  • tests/interface/test_interrupt.py: three cases (plain; inside asyncio.run; second Ctrl-C), each passing on main, the line head and this branch with identical event sequences.
  • tests/interface/test_thread_placement.py green. It now also rejects any anonymizer-graph* thread started during a run.
  • Topology-relevant tests on Python 3.13 and 3.14.
  • A real ipykernel ran every route and a Kernel → Interrupt.

Not verified

  • Windows and macOS.
  • The full suite on Python 3.12, 3.13 and 3.14 (3.13 and 3.14 ran the topology-relevant files only).
  • Python 3.15.
  • Live providers, including a health-checked Substitute model against a real endpoint.
  • A browser-driven notebook, and nest_asyncio with the real library.
  • Contention on the bookkeeping lock beyond the test suite.
  • A graph whose body needs a newly launched operation mid-body. None exists on the facade today; such an operation would start after the body returns.
  • graph_sdk/_foreign_transport.py stays. It has no Anonymizer consumer left, but removing it is a separate SDK-surface decision.

🤖 Generated with Claude Code

binaryaaron and others added 5 commits October 21, 2026 18:00
… path

Substitute's replacement map now runs on DataDesigner's own transport, retries
and scheduling on every route, as it already did when the model skipped its
health check and as 0.4.0 does. The graph accounts the map like Redact,
Annotate, Hash and Rewrite: one opaque invocation with unknown physical
requests. Rows, failed records, retries and cancellation follow DataDesigner.

With no native map left, the per-request bridge has no consumer:

- delete the native map module, the request bridge and physical transport,
  and the kernel's native transport acquisition and branches;
- delete the facade-unreachable qualified detection/Redact profile and the
  Redact assessment it published, which were the bridge's other consumer;
- drop the request_bridge keyword from the adapter and detection workflow,
  so caller detectors no longer receive it (0.4.0 never passed it);
- keep only the source-frame capability checks of the DataDesigner binding.

Tests that pinned per-request receipts now assert an empty request
inventory; tests of the deleted profile, bridge and native transport go.

Measured (src): 16 files, +52 / -1774, net -1722.
Tests: 16 files, +83 / -2283. Docs: 1 file, +7 / -26.
Gate: full suite pandas 2.3.3 and 3.0.6 5555 passed each; make format-check
and make typecheck clean; parity corpus vs main f4f2b73 0 BLOCKING (both
pandas), R0 0 BLOCKING.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
…g to the owner loop

The facade's bodies and DataDesigner's per-row hooks no longer call back
into the graph's owner loop and wait for it. Their bookkeeping (enter,
complete, ready, result, authorize) never awaits anything that can
suspend, so it now runs to completion on the calling thread under one
runtime lock; `drive()` raises if an owner path ever suspends, which turns
the "no owner path suspends" claim into a runtime check.

This applies to every owner without physical children, which after the
opaque Substitute map is every facade invocation. An owner with physical
children (the SDK's worker/foreign-transport path) keeps its owner-loop
gate, so in-flight children still serialize bookkeeping as before.

One dependency was not visible in the owner methods: Rewrite's repair loop
needs the scheduler to start the next iteration's subgraph between two
bookkeeping calls of the same body. After a synchronous call that changed
the ledger, the runtime applies one scheduler frontier without launching
work (subgraph starts and closures only); launches stay on the owner loop.
Scheduler state transitions and synchronous bookkeeping hold the same
runtime lock, and the scheduler is woken thread-safely.

The bindings' loop fields and the hop sites' loop arguments go with the
hops. The owner-plural yields (asyncio.sleep(0) every 64 items) go too.
One validation-plugin test case injected a suspending _authorize_source;
that interleaving no longer exists and the case is removed.

Measured (src): 9 files, +140 / -105, net +35 (the design estimated about -50; the
SDK child path keeps its owner-loop gate and the frontier step is new).
Tests: 1 file, +2 / -17.
Gate: full suite pandas 2.3.3 and 3.0.6 5554 passed each; make format-check
and make typecheck clean; parity corpus vs main f4f2b73 0 BLOCKING (both
pandas), R0 0 BLOCKING. Runtime probe (tests/interface): 10,038 synchronous
drives, 0 owner-loop hops, 0 suspensions.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
… thread

With no bookkeeping hop left, nothing needs the graph's event loop to keep
running while a synchronous body runs. Each public run, preview and
evaluate now runs its graph on a private event loop on the caller's own
thread (new_event_loop + run_until_complete, no Runner and so no SIGINT
handler), and every body runs inline on that thread. The per-invocation
host thread, the caller-thread queue and its serve loop, the handoff
plumbing, and the settle/drain/cancel_host interrupt machinery are gone.

A caller that already runs a loop (Jupyter, asyncio.run) is blocked by the
synchronous call anyway: its running-loop marker is set aside for the
private loop and restored around each body and on return. Python 3.14
tracks the executing task per thread, so on 3.14 the caller's current task
is swapped with the marker. A caller that is itself a BlockingPool worker
may start an invocation: it uses no pool, so it cannot wait on its own.

Ctrl-C now lands where the body waits, inside DataDesigner, as in 0.4.0.
A KeyboardInterrupt or SystemExit raised in a body is kept, the invocation
is cancelled and settled, and the same exception is re-raised with the
execution retained by the traceback. Bookkeeping, nested invocations and
DataDesigner calls all run on the caller's thread; only DataDesigner's own
engine threads remain.

Tests: the queue-topology interrupt test (test_interrupt_handoff.py) and
the nested-handoff test go; route rows stop pinning the handoff; the
threads-started test now rejects any anonymizer-graph thread, including the
former host.

Measured (src): 9 files, +135 / -240, net -105 (design estimate about -130).
Tests: 6 files, +19 / -136. Docs: 2 files, +5 / -6.
Gate: full suite pandas 2.3.3 and 3.0.6 5547 passed each; make format-check
and make typecheck clean; parity corpus vs main f4f2b73 0 BLOCKING (both
pandas), R0 0 BLOCKING; test_thread_placement.py green; threads started per
run (5 methods x builtin/delegating detector): no anonymizer thread.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
A user relies on one thing: Ctrl-C during run() or preview() stops
promptly, cancels the in-flight model requests, raises KeyboardInterrupt
and does not hang. test_interrupt.py checks exactly that in a subprocess
with a real Anonymizer and DataDesigner whose HTTP transport holds one kind
of request open, and real SIGINTs:

- plain: Redact run(), detection request held; one Ctrl-C;
- asyncio_run: Substitute preview() called from inside asyncio.run; the
  Runner takes the first Ctrl-C, a second one stops the run;
- second: Rewrite run() with a request that ignores cancellation, so
  DataDesigner's post-cancel wait holds until a second Ctrl-C.

Each case asserts the exit status, the time after the last SIGINT and the
exact event sequence. The script uses only the public API and
DataDesigner's transport hook; the sequences were recorded on main
f4f2b73, and the unchanged file passes there.

Removed: the remaining in-process and subprocess interrupt tests that
pinned the old host topology or graph internals (DataDesigner compat
interrupt cases, the injected-redact and evaluate interrupt cases). The
queue, native-map and qualified-profile interrupt tests went with the code
they tested in the previous commits. Main's telemetry CANCELED test stays.

Measured: tests 4 files, +228 / -237; src unchanged.
Gate: full suite pandas 2.3.3 and 3.0.6 5538 passed each; make
format-check and make typecheck clean; test_interrupt.py 3/3 against main
f4f2b73, the graph-SDK line head e0d8b31 and this branch (three runs
each); src identical to the previous commit, whose corpus run was 0
BLOCKING.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
The thread paragraph in graph-sdk.md and the cutover note said all graph
bookkeeping runs on the caller's thread. Bodies and the bookkeeping they
perform do, but the bookkeeping that DataDesigner's per-row hooks trigger
is driven synchronously on DataDesigner's own threads, under the runtime
lock (_InvocationRuntime.lock, _execution_runtime.py:200; held by
_DelegatedOwner._drive_call, _execution_delegated.py:914-936). Both texts
now say so.

Also removes the paragraph on the qualified development detection/Redact
profile, deleted earlier on this branch, and the stale request_bridge
mentions in the detector-signature test docstrings (text only).

Measured: docs 2 files, +7 / -12; tests 1 file, +2 / -2; src unchanged.
Gate: mkdocs build --strict passes; make format-check clean;
test_detector_signature_compat.py and test_thread_placement.py 52 passed
on pandas 2.3.3 and 3.0.6.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
@binaryaaron binaryaaron changed the title Run the graph on the caller's thread: opaque Substitute map, synchronous bookkeeping, no host thread refactor: Run the graph on the caller's thread: opaque Substitute map, synchronous bookkeeping, no host thread Oct 22, 2026
@binaryaaron binaryaaron changed the title refactor: Run the graph on the caller's thread: opaque Substitute map, synchronous bookkeeping, no host thread refactor: Run the graph on the caller's thread Oct 22, 2026
@binaryaaron
binaryaaron marked this pull request as ready for review October 22, 2026 18:48
@binaryaaron
binaryaaron requested a review from a team as a code owner October 22, 2026 18:48
@binaryaaron
binaryaaron merged commit 7c386a0 into aagonzales/graph-sdk/feat/main Oct 22, 2026
10 of 13 checks passed
@binaryaaron
binaryaaron deleted the binaryaaron/graph-sdk/refactor/sync-core branch October 22, 2026 18:49
@greptile-apps

greptile-apps Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge based on the reviewed changes.

Summary

The PR moves synchronous graph execution onto a private event loop on the caller’s thread.

  • Synchronous graph calls now run on the caller’s thread.
  • Graph bookkeeping now runs inline with synchronous bodies.
  • Substitute maps now use DataDesigner’s own transport and retries.

Acknowledged by binaryaaron: internal engine APIs are not compatibility contracts; context-variable changes inside a call remain isolated; and the documented second-interrupt behavior is intentional.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["Synchronous caller"] --> B["Private event loop on caller thread"]
  B --> C["Body runs inline"]
  C --> D["Graph bookkeeping under invocation lock"]
  C --> E["DataDesigner call and wait"]
  E --> F["DataDesigner engine threads"]
  F --> D
  B --> G["Close private loop and restore caller"]
Loading

Reviews (1) · Last reviewed commit: "docs: describe where graph bookkeeping r..." · Reviewed by Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant