Commit ce9789d
Isolate and type fan-out payloads, check input at the front door, trace a Ctrl-C'd node (#76)
Three defects in `grapharc/runtime/graph.py`, all of the same family: a
contract the module documents, enforced everywhere except at one boundary.
**Fan-out handed every worker the same object, and never typed it.** `_enter`'s
comment states the rule — "Nodes get a deep copy: the returned dict is the
*only* write channel" — but the copy was guarded by
`isinstance(state, BaseModel)`, and a `Send` payload can be anything. Two Sends
built from one dict therefore gave both parallel workers the same live dict:
workers reported: ['dict hits=1 id=139338015848512',
'dict hits=2 id=139338015848512'] # same id
shared dict after run: {'who': 'orig', 'hits': [1, 1]}
That is a data race between nodes that are supposed to be isolated, and the
mutations travel through a channel no node declared a write to and no trace
event records — so the write-permission model and the audit trail both miss it,
and which worker ran first decides the answer. Fan-out is the one place the
isolation matters most; it was the one place that skipped it.
The other half of the same boundary: `_check_goto_target` validated `Send.node`
and left `Send.arg` alone, so `input_schema` — whose docstring says it "types a
fan-out worker's Send payload" — checked nothing. A dict where a model was
declared reached the worker and failed as a bare `AttributeError` several frames
from the dispatcher that produced it; a wrong model class sharing a field name
did not fail at all. That `Send.node` is checked and `Send.arg` is not reads as
an oversight rather than a decision, so `Send.arg` now gets the same treatment,
with `StateTypeError` naming the node, the schema and what arrived.
Every payload is deep-copied now, whatever its type. Declaring no `input_schema`
stays legal — a worker that names no schema is claiming nothing, so there is
nothing to check — but the copy is unconditional, and `add_node`'s docstring now
says so instead of leaving it to be inferred. A `BaseModel` payload was already
isolated correctly and still is, asserted by its own test.
**The front door was the one door the state contract did not hold.**
`state.py` opens by promising "a typo'd update key fails loudly at the edge
instead of silently polluting downstream nodes". `update_state` keeps that
promise and so does constructing the model; the entry points did not. They
handed `input` to LangGraph, which filters a dict down to known channels
*before* the state model is ever constructed, so `extra="forbid"` never got a
chance:
invoke({'quesiton': 'typo'}) -> {'out': "saw:''"} # ran on defaults
update_state({'quesiton': 'typo'}) -> WritePermissionError: unknown state fields
Misspell the field carrying the question and you get a complete, plausible run
against an empty question and are told nothing — the quietest failure in the
runtime, on the door every user goes through first. `invoke`, `stream`,
`ainvoke`, `astream` and `astream_events` now refuse an unknown input key, in
the same words `update_state` uses, from the same helper so the two cannot
drift. A wrongly *typed* input value was already loud and still raises
Pydantic's `ValidationError`; a state model or `None` is passed through
untouched.
**A Ctrl-C'd sync node left no ending in the trace.** The sync wrapper caught
`Exception`; the async twin catches `BaseException`, and its comment gives the
reason — "a stop with no trace line is a stop nobody can audit afterwards". So
the same node body, run each way:
sync trace phases: [topology, start]
async trace phases: [topology, start, error]
`metrics.summarize` then reported `errors: 0` for the sync run, and an audit
reads it as having simply stopped between nodes. Ctrl-C is not an exotic
ending; it is the commonest way a human stops a long run. The sync wrapper now
matches its twin. The exception is re-raised untouched — only the trace write
is new.
Tests: twelve, each failing on main. Fan-out — two workers sharing one dict
cannot see each other's mutations, a payload contradicting `input_schema` is
refused at dispatch, a wrong model class likewise, an untyped payload stays
legal, and a `BaseModel` payload is still isolated. Front door — `invoke` and
`stream` reject the typo verbatim, `ainvoke`/`astream` in a parametrised twin,
with the type-error and valid-input paths pinned unchanged. Ctrl-C — one test
asserts both wrappers write the `error` event for `KeyboardInterrupt` and
`SystemExit`, so the two paths cannot drift again.
Deep-copying every payload costs nothing measurable: 200 fan-out workers run in
41.1ms with the change and 41.6ms without (best of five, dict payloads, well
inside run-to-run noise), because 200 `copy.deepcopy` calls on a realistic
shard total 0.5ms against ~41ms of graph execution.
Fixes #67
Fixes #68
Fixes #70
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>1 parent ecfea58 commit ce9789d
4 files changed
Lines changed: 278 additions & 14 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
8 | 12 | | |
9 | 13 | | |
10 | 14 | | |
| |||
32 | 36 | | |
33 | 37 | | |
34 | 38 | | |
| 39 | + | |
35 | 40 | | |
36 | 41 | | |
37 | 42 | | |
| |||
62 | 67 | | |
63 | 68 | | |
64 | 69 | | |
65 | | - | |
66 | | - | |
67 | | - | |
68 | | - | |
69 | | - | |
70 | | - | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
71 | 84 | | |
72 | 85 | | |
73 | 86 | | |
| |||
308 | 321 | | |
309 | 322 | | |
310 | 323 | | |
| 324 | + | |
311 | 325 | | |
312 | 326 | | |
313 | 327 | | |
| |||
319 | 333 | | |
320 | 334 | | |
321 | 335 | | |
322 | | - | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
323 | 345 | | |
324 | 346 | | |
325 | 347 | | |
326 | 348 | | |
327 | 349 | | |
328 | 350 | | |
329 | 351 | | |
| 352 | + | |
330 | 353 | | |
331 | 354 | | |
332 | 355 | | |
| |||
454 | 477 | | |
455 | 478 | | |
456 | 479 | | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
457 | 506 | | |
458 | 507 | | |
459 | 508 | | |
| |||
469 | 518 | | |
470 | 519 | | |
471 | 520 | | |
| 521 | + | |
472 | 522 | | |
473 | 523 | | |
474 | 524 | | |
| |||
620 | 670 | | |
621 | 671 | | |
622 | 672 | | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
623 | 680 | | |
624 | 681 | | |
| 682 | + | |
| 683 | + | |
625 | 684 | | |
626 | 685 | | |
627 | 686 | | |
| |||
735 | 794 | | |
736 | 795 | | |
737 | 796 | | |
738 | | - | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
739 | 803 | | |
740 | 804 | | |
741 | 805 | | |
| |||
836 | 900 | | |
837 | 901 | | |
838 | 902 | | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
| 915 | + | |
| 916 | + | |
| 917 | + | |
| 918 | + | |
| 919 | + | |
| 920 | + | |
| 921 | + | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
839 | 929 | | |
840 | 930 | | |
841 | 931 | | |
| |||
854 | 944 | | |
855 | 945 | | |
856 | 946 | | |
| 947 | + | |
857 | 948 | | |
858 | 949 | | |
859 | 950 | | |
| |||
871 | 962 | | |
872 | 963 | | |
873 | 964 | | |
| 965 | + | |
874 | 966 | | |
875 | 967 | | |
876 | 968 | | |
| |||
889 | 981 | | |
890 | 982 | | |
891 | 983 | | |
| 984 | + | |
892 | 985 | | |
893 | 986 | | |
894 | 987 | | |
| |||
901 | 994 | | |
902 | 995 | | |
903 | 996 | | |
| 997 | + | |
904 | 998 | | |
905 | 999 | | |
906 | 1000 | | |
| |||
924 | 1018 | | |
925 | 1019 | | |
926 | 1020 | | |
| 1021 | + | |
927 | 1022 | | |
928 | 1023 | | |
929 | 1024 | | |
| |||
1005 | 1100 | | |
1006 | 1101 | | |
1007 | 1102 | | |
1008 | | - | |
1009 | | - | |
1010 | | - | |
1011 | | - | |
1012 | | - | |
| 1103 | + | |
1013 | 1104 | | |
1014 | 1105 | | |
1015 | 1106 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
804 | 804 | | |
805 | 805 | | |
806 | 806 | | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
0 commit comments