Skip to content

`runMultiple` full-parity plan

Status: delivered in v0.18.0, except Phase 5 (decided against, documented as a divergence) and Phase 6 (skipped). The single 🟡 parity row is now seven rows, five green. Phase 0 ran first and moved most of the plan — several assumptions below were wrong, and the sections are corrected in place. What each completion level actually earns is in What “done” buys, precisely — it is not “complete parity”.

notebookutils.notebook.runMultiple ships real DAG semantics — dependency order, skip cascade, cycle refusal — but the parity row was 🟡 for reasons this document turns into a worked plan. Phases are independent; each lands with its own parity row, witness, and CI line.

The direction that matters throughout is passes here, fails there. A gap that fails loudly on the emulator gets noticed; a gap the emulator papers over ships a defect to real Fabric with a green local run.

Phase 0 — pin the oracle: DONE, and it moved the plan

Section titled “Phase 0 — pin the oracle: DONE, and it moved the plan”

Answered against Microsoft’s current reference, NotebookUtils notebook run and orchestration. Several working assumptions were wrong, and the corrections are larger than the gaps originally listed.

QuestionAnswerEffect
What run() returnsThe exit value. “returns the exact string passed to notebookutils.notebook.exit(value)… If exit() isn’t called, an empty string ("")”Our status-string return is a parity bug. Breaking change, Phase 1
runMultiple result shape{name: {"exitVal": str, "exception": err or None}} — two keysOurs has exitVal/message/status/error. exception is absent, so result["exception"] KeyErrors
Failure behaviourRaises RunMultipleFailedException, partial results on ex.resultOurs returns quietly. Code following the documented try/except pattern never sees the exception
Default concurrency3 × available CPU cores; 0 means unlimitedThe retired mssparkutils page still says 50; the notebookutils page is current
DAG timeoutInSecondsDefault 43200 (12h)As assumed
timeoutPerCellInSecondsDefault 90, per cellConfirms the unit bug
Child session modelIsolated REPL instances within the existing Spark session, sharing its computeMatches the agent’s post-3a architecture — Phase 5 is much closer than assumed
useRootDefaultLakehouseNot an inheritance flag. A child specifying a different lakehouse than the parent is blocked; the flag bypasses that check. Lives in argumentsPhase 2 was designed against the wrong semantics
SignaturerunMultiple(dag, config=None); run(path, timeout_seconds=90, arguments=None, workspace="")Ours takes useRootDefaultLakehouse= and workspaceId=
  • validateDAG(dag) -> bool — a public method we do not implement at all. Catches duplicate activity names, missing dependencies and circular references. We already perform those checks inside runMultiple; exposing them is nearly free.
  • @activity('name').exitValue() — an expression usable inside args to pass a dependency’s exit value into a dependent. A documented data-flow mechanism with no equivalent here.
  • Duplicate activity names must be rejected. We silently keep the last.
  • workspace accepts a name or an id; ours is workspaceId only.
  • config — a second positional parameter (displayDAGViaGraphviz).

Phase 1 — wrong answers shipping today (Python only)

Section titled “Phase 1 — wrong answers shipping today (Python only)”

Three fixes, one branch:

  1. exitVal is hardcoded "". The value already flows end to end — the engine posts it, finalizeNotebookRun stores it, and GET …/jobs/instances/{jid}/notebookRun serves it (internal/api/notebooks.go). run() just never asks. Split the internals: a private _run_detail() returning (status, exit_value, failure_reason), with run() and runMultiple() each projecting what they need.
  2. timeoutPerCellInSeconds is passed as a whole-notebook deadline (python/notebookutils/notebook.py, the runMultiplerun call). Fabric’s field is per cell; a 10-cell notebook given 90s/cell gets 900s there and 90s total here, so a legitimately slow DAG fails locally that passes in production. Scale by the run detail’s cell count.
  3. run()’s return value, contingent on Phase 0: if real Fabric returns the exit value, ours returning "Completed" is a parity bug. Fixing it is a breaking change — e2e/notebookutils/notebook.py asserts status == "Completed", and consumers may too. Decision: follow the oracle, bump minor, fix our own witnesses in the same PR, release-notes entry.

Tests: move the stub in python/tests/test_notebook_run_multiple.py from run down to _run_detail so exit values are expressible. Add: exit value reaches results[name]["exitVal"]; no exit() call yields "" not None; a failed activity’s failure-reason field matches the Phase 0 answer.

E2E: the notebookutils witness’s child-nb is markdown-only on purpose (it completes without an engine). Proving exitVal end to end needs a child with a real cell calling exit() and an agent attached — extend e2e/notebook-driven (which already asserts exitValue == "3") rather than weaken the engineless witness.

CI: notebookutils job (unit) + notebook-driven suite (e2e). No badge-count change unless a new suite directory is added.

Phase 2 — the reference-run lakehouse rule (Python + Go)

Section titled “Phase 2 — the reference-run lakehouse rule (Python + Go)”

Phase 0 corrected this one outright. The plan assumed useRootDefaultLakehouse was an inheritance flag — make the child resolve against the root’s lakehouse. It is not. Fabric’s rule is a refusal:

Reference run allows child notebooks to run only if they use the same lakehouse as the parent, inherit the parent’s lakehouse, or neither defines one. The execution is blocked if the child specifies a different lakehouse than the parent notebook. To bypass this check, set useRootDefaultLakehouse: True in the arguments.

So the work was a guard, not a binding override. The emulator ran a mis-bound child happily and returned green, meaning the exact mistake this rule exists to catch passed locally and was blocked in production. Silently rebinding the child instead — the original plan — would have been the same defect wearing the opposite mask: a green run over data the author never pointed at.

Shipped: referenceRunLakehouseCode in internal/api/jobs.go, fed by parentLakehouseId / useRootDefaultLakehouse in the child’s executionData, which _execution_data in the shim now sends. Failure messages gained a per-code message, because “The job failed.” for a refusal with a specific fixable cause is indistinguishable from a cell that threw.

Test the asymmetry or the test proves nothing: a guard that refuses everything passes any test that only checks the blocked case. Both directions are asserted, in Go and in e2e/notebookutils.

CI: Go unit tests + the notebookutils e2e.

Phase 3a — per-namespace catalog resolution (agent; a latent bug today)

Section titled “Phase 3a — per-namespace catalog resolution (agent; a latent bug today)”

Not a runMultiple feature — a standing defect. The agent holds one SparkSession with per-Livy-session REPL namespaces (python/spark_agent/agent.py), so two pieces of state are process-wide: spark.conf.set(...) and spark.catalog.setCurrentDatabase(schema). Two notebooks bound to different lakehouses running concurrently fight over the current database, and the loser silently reads the wrong tables. ThreadingHTTPServer already permits this — no runMultiple involvement needed. Same defect class as the __nb_exit__ prelude race documented in internal/api/notebookdrive.go.

Fix: qualify table names at registration so no current-database state is needed, or set the current database per statement under a lock. Test: two differently-bound sessions running concurrently, each asserting it sees its own tables.

Independent of every other phase. Ship first.

CI: agent unit tests + e2e/livy.

Phase 3b — bounded concurrency (Python; after 3a)

Section titled “Phase 3b — bounded concurrency (Python; after 3a)”

Sequential execution hides a real class of user bug: two independent activities writing the same table collide on real Fabric and never collide here. Concurrency is worth having as a race-exposure feature, not a performance one. Cost is small — a concurrent notebook is a dict and a thread on the agent, not a second SparkSession.

Decision, stated rather than implied: default stays sequential even though Fabric’s default is 3× the CPU count, because reproducibility-by-default is the right property for a test harness. Explicit concurrency: N is honoured with a bounded pool per dependency level, and 0 means unlimited as Fabric documents. The sequential default is recorded in the parity row as a chosen divergence and declared in the differential suite’s allowlist.

Tests: rewrite “order within a level is the order given” as the concurrency=1 contract; add a concurrency=2 test asserting genuinely overlapping execution via enter/exit recording in the stub — never wall-clock timing.

CI: notebookutils unit job.

Phase 4 — retry, retryIntervalInSeconds, DAG timeoutInSeconds (Python)

Section titled “Phase 4 — retry, retryIntervalInSeconds, DAG timeoutInSeconds (Python)”

Straightforward once Phase 0 pins expiry semantics. Tests: retried-then-succeeded reports Completed; exhausted retries report the last error; a DAG timeout leaves no activity in an undefined state; dependents wait for a dependency’s retries before deciding skip.

CI: notebookutils unit job.

Phase 5 — shared parent Spark session: default no

Section titled “Phase 5 — shared parent Spark session: default no”

Real Fabric runs children in the parent’s session, so temp views and session config carry across activities. Ours gives each child its own session, /closed by a defer in driveNotebookRun — a child reusing the parent’s session must not close it, and shared-session plus Phase 3b concurrency reintroduces exactly the prelude-race class of defect (two children in one namespace racing on __nb_exit__ and the exit patch).

Largest change on this list, makes two other phases harder, and the benefit is narrow (sibling temp views). Decided: documented divergence — “children run in isolated sessions” is now its own 🟡 parity row and a declared entry in the differential suite’s allowlist. Implemented only if a real consumer demonstrates need.

Phase 0 narrowed the gap here more than expected. Fabric runs children on “isolated REPL instances within the existing Spark session”, sharing its compute — which is precisely the agent’s architecture after Phase 3a: one SparkSession, a REPL namespace per Livy session. What still differs is that a runMultiple child gets its own Livy session, and therefore its own newSession(), so session-scoped state such as temp views is not shared between siblings. That is a smaller and better-understood gap than “we do not share the parent’s session” implied.

Phase 6 — progress table / Graphviz rendering: skip

Section titled “Phase 6 — progress table / Graphviz rendering: skip”

Interactive-only. A text summary of final statuses is cheap if ever wanted; Graphviz is a dependency for zero harness value.

Phase 7 — differential witness: prove the gap list is complete

Section titled “Phase 7 — differential witness: prove the gap list is complete”

Every gap above was found by inspection — reading our code and Microsoft’s docs. That yields “gaps I could find,” which is strictly smaller than “gaps that exist.” Closing all of Phases 1–4 earns the claim no known divergence; it does not earn no divergence. Only running the same DAG against a real tenant and diffing the results does that.

The infrastructure already exists. python/fabric-target/conformance/ holds one suite that CI runs against the emulator on every push (e2e/fabric-target) and .github/workflows/real-fabric.yml runs against real Microsoft Fabric, secret-gated, weekly. FABRIC_TARGET is the only difference between the legs. That workflow’s header already states the intent: divergences found there are parity-map material — it is the fidelity oracle. So this phase adds test cases to an existing harness, not a harness.

Ids and timings can never match across targets, so the comparison is a normalised projection of the runMultiple results dict:

  • the set of activity names present as keys
  • each activity’s status
  • each activity’s exitVal
  • the failure/skip reason field’s shape (populated vs not), never its text
  • observed execution order, for the sequential contract

Anything outside that projection is deliberately not compared.

Known divergences must be declared, and must still be true

Section titled “Known divergences must be declared, and must still be true”

Phases 3b and 5 make choices that will diverge on the real leg — sequential default, isolated child sessions. A naive differential test would fail forever and get muted, which is worse than not having it. So the case carries an explicit known-divergence allowlist, on the model of _gated in docs/witnesses.json: each entry names the parity row that documents it, and an unlisted divergence fails the run.

The allowlist needs its own anti-rot check — an entry that no longer diverges must also fail, exactly as a declared skip that no longer skips is an error. Otherwise the list silently accumulates lies as Fabric changes.

This phase largely supersedes Phase 0: an observed round-trip beats a doc-reading assumption. Phase 0 still goes first, because Phase 1 needs an answer on run()’s return type before the real leg’s next weekly run, but every 0.x row should be revisited against what the differential case actually observes, and this document’s assumption table rewritten as fact.

Even complete, this proves parity for the DAGs the case exercises, on the tenant it ran against, at that time. It does not prove parity for all inputs. That is the normal limit of differential testing and it is worth stating in the parity row rather than rounding up to 🟢.

PhaseScopeEffortVerdict
3a per-namespace catalogagentMFirst — fixes a live bug
0 oracledocs researchSBefore 1’s return-type decision
1 exitVal + per-cell timeout + return typePythonSNow — wrong answers shipping
2 lakehouse inheritancePython + GoMDo
3b concurrencyPythonMAfter 3a
4 retry / timeoutsPythonSDo
5 shared sessionGo + agentL❌ No — documented divergence
6 progress UIPythonS❌ Skipped
7 differential witnessconformance suiteMThe only phase that proves completeness

Two surfaces Phase 0 discovered are recorded but not built, because neither is a wrong answer today — both are absences that fail loudly:

  • @activity('name').exitValue() in args, Fabric’s documented way to pass a dependency’s exit value into a dependent. Now that exitVal is real, this is a small addition and the natural next increment.
  • config / displayDAGViaGraphviz — accepted and ignored; see Phase 6.

The single 🟡 parity row cannot track six independently-landing phases — split it: DAG ordering (🟢 today), exit values, lakehouse inheritance, concurrency, retry/timeout, session sharing. Each row gets its own witness in docs/witnesses.json; scripts/check_witnesses.py --strict stays clean.

Every phase’s definition of done: parity row updated, witness naming a test that exists, CI job named, README orchestration bullet still accurate.

Code and its tests are separate rows on purpose: an implementation item that carries its own proof inside it is how an untested change reads as done.

Status: every item below is done except where marked. Phase 0 answered first and moved several phases; Phase 5 was decided rather than built, and Phase 6 was skipped. What each completion level actually earns is at the end.

#Action itemOutput
0.1Confirm what real Fabric’s run() returns (exit value vs status)Decision recorded here; gates 1.5
0.2Confirm failure reporting shape: message vs error, exitVal on failureGates 1.8
0.3Confirm default concurrency (~50?)Gates 3b wording
0.4Confirm DAG timeoutInSeconds expiry behaviourGates 4.3
0.5Confirm children share the parent’s Spark sessionConfirms Phase 5’s divergence wording
0.6Cite each source here; unconfirmed items marked “documented divergence”This document
#Action itemFilesTest proving it
3a.1Remove process-wide current-database state: qualify table names at registration (or per-statement lock)python/spark_agent/agent.pyregister_tables, ns3a.2
3a.2Concurrency test: two sessions bound to different lakehouses run simultaneously; each sees only its own tablesagent testsnew
3a.3Mutation check: revert the fix, confirm 3a.2 failsdone: the mutation fails 6 tests
3a.4Verify e2e/livy + e2e/notebook-driven still greenCIexisting suites

Phase 1 — wrong answers shipping today ✅

Section titled “Phase 1 — wrong answers shipping today ✅”
#Action itemFilesTest proving it
1.1Extract _run_detail()(status, exit_value, failure_reason) fetching …/notebookRun after terminal statepython/notebookutils/notebook.pycovered via 1.6–1.8
1.2Move test stub from run to _run_detailpython/tests/test_notebook_run_multiple.pyall 14 existing tests still pass
1.3runMultiple populates exitVal from the child’s exit valuenotebook.py1.6
1.4Fix timeoutPerCellInSeconds: scale by the run detail’s cell count, not whole-notebooknotebook.py1.7
1.5run() returns the exit value — Phase 0 confirmed it, so this is breakingnotebook.pye2e assertion updated in the same commit
1.6Tests: exit value reaches exitVal; no exit() call → "" not Nonetestsnew ×2
1.7Test: N-cell notebook with per-cell timeout T gets N×T deadlinetestsnew
1.8Test: a failed activity carries Fabric’s exception key (0.2’s answer)testsnew
1.9E2E: extend e2e/notebook-driven — parent runMultiple over a child that calls exit(); assert the value round-trips through a real enginee2e/notebook-driven/new witness step
1.10If 1.5 breaks run(): update e2e/notebookutils/notebook.py, release-notes entry, minor bumpe2e, docs/release-notesexisting witness

Phase 2 — the reference-run lakehouse rule ✅

Section titled “Phase 2 — the reference-run lakehouse rule ✅”
#Action itemFilesTest proving it
2.1executionData carries parentLakehouseId + useRootDefaultLakehouseinternal/api/jobs.go2.4
2.2referenceRunLakehouseCode blocks a mismatched childjobs.go2.4
2.3The shim sends the parent context; the flag is lifted out of argumentsnotebook.py2.5
2.4Go tests: blocked / same / inherits / bypassed / not-a-reference-runGo unit7 new
2.5Python tests: parent lakehouse sent, flag lifted, absent unless askedtests4 new
2.6E2E asymmetry: blocked, AND bypassed — a guard refusing everything must faile2e/notebookutilsnew, both directions
#Action itemFilesTest proving it
3b.1Bounded pool per dependency level; default stays sequentialnotebook.py3b.3–3b.5
3b.2Rewrite order test as the concurrency=1 contracttestsrewritten
3b.3Test: concurrency=2 → genuinely overlapping execution (enter/exit recording, no wall-clock)testsnew
3b.4Test: failure during concurrent level still skips dependents correctlytestsnew
3b.5Test: pool never exceeds N in-flighttestsnew
3b.6Parity row: sequential default recorded as chosen divergenceparity.mdwitness check
#Action itemFilesTest proving it
4.1Per-activity retry + retryIntervalInSecondsnotebook.py4.4–4.5
4.2DAG-level timeoutInSeconds wall clocknotebook.py4.6
4.3Expiry behaviour per 0.4notebook.py4.6
4.4Tests: retried-then-succeeded → Completed; exhausted retries → last errortestsnew ×2
4.5Test: dependents wait out a dependency’s retries before deciding skiptestsnew
4.6Test: DAG timeout leaves no activity in an undefined state; interval honoured without real sleeps (injectable clock)testsnew ×2
#Action itemFiles
5.1Parity row: “children run in isolated sessions” as permanent documented divergenceparity.md
6.1Skip recorded above — nothing further
#Action itemFilesTest proving it
7.1Notebook fixture creatable on both targets by display name (a small DAG: two roots, one dependent, one failing branch)python/fabric-target/conformance/7.3
7.2Normalising projection helper: keys, statuses, exitVal, reason-populated, observed order — never ids or timingsconformance suite7.3
7.3Differential case: run the DAG, project, compare emulator leg vs real legconformance suitenew
7.4Known-divergence allowlist; each entry names the parity row documenting it; an unlisted divergence failsconformance suite7.5
7.5Anti-rot check: an allowlist entry that no longer diverges fails, as a declared skip that no longer skips doesconformance suitenew
7.6Register the real leg in docs/witnesses.json _gated with its secret requirement and weekly cadencewitnesses.jsoncheck_witnesses.py --strict
7.7Rewrite this document’s Phase 0 assumption table as observed fact from the first real-leg rundoc 39
7.8Parity row states the ceiling: proven for the DAGs exercised, on that tenant, at that timeparity.mdwitness check

CI: e2e/fabric-target (emulator leg, every push) + real-fabric workflow (real leg, weekly, secret-gated).

#Action itemVerification
X.1Split the single 🟡 row into 6: ordering (🟢 now), exit values, lakehouse inheritance, concurrency, retry/timeout, session sharingdocs/parity.md
X.2One witness per new row in docs/witnesses.jsoncheck_witnesses.py --strict exit 0
X.3Each phase names its CI job; new e2e steps live in existing suites (no badge-count change) — if a new suite directory is ever added, update the badge countCI config + badge
X.4Coverage floor: new Python keeps total ≥70%coverage job
X.5README orchestration bullet re-checked after each phaseREADME.md
X.6This document updated as phases land (mark done, record the 0.x answers)doc 39
X.7Release-notes entries for behaviour changes (1.5 especially)docs/release-notes

Totals: ~53 items — 6 research, 16 implementation, 22 new/rewritten tests, 9 doc/CI. The test-heavy ratio is deliberate: 1.6–1.8 and 3a.2 are the rows that make the parity claims mean anything, and all of Phase 7 is the row that makes the list mean anything.

CompletedClaim earned
Phases 1–4No known divergence in the pursued surface
+ Phases 5, 6 declined in writingThree divergences chosen and documented, not overlooked
+ Phase 7No divergence observed against a real tenant, for the DAGs exercised

None of those is unqualified “complete parity,” and the parity rows should never claim it. runMultiple is an orchestrator: its fidelity is also bounded by the child notebook runs underneath it, which sit on Sail or JVM Spark rather than Fabric’s Spark runtime — a far larger surface, tracked in 37-runtime-fidelity-gaps.md and 38-framework-conformance.md.