Skip to content

Reactive execution misses globals read inside functions, producing silently stale output #512

Description

@jankoritak

Summary

A global variable read only inside a function body creates no dependency edge when the global is defined in a different block. Running the defining block with dependents therefore schedules nothing, and downstream blocks keep showing a successful but stale result.

Confirmed on Deepnote Cloud 2026-08-14.

Reproduction

# 1. Run all blocks. Then change old to new and run this block with dependencies.
source_value = "old"
# 2. This function reads a global defined in block 1.
def read_source():
    return source_value
# 3. This output should update after block 1 changes.
observed = read_source()
print(f"observed={observed!r}")
  1. Run all three blocks with source_value = "old" — block 3 prints observed='old'.
  2. Change block 1 to "new" and run block 1 with dependents.

Actual / Expected

Actual: nothing downstream runs. Block 3 still prints observed='old' while the variable explorer shows source_value = new.

Expected: reactive execution follows free-variable reads inside callables and reruns their consumers.

The symptom is invisible on a full run, since every block re-executes regardless of edges. It also requires the value to actually change.

The dependency graph shows block 1 as an isolated node with a source_value output chip and no outgoing edge; block 2 has no input chips. The only edge is block 2 → block 3 via read_source.

Root cause

Analyzer packages/reactivity/src/scripts/ast-analyzer.py, shipped as @deepnote/reactivity. visit_Name records a load only when the visitor is at global scope, or when the name is already in self.global_vars:

if isinstance(node.ctx, ast.Load):
    if node.id in BUILTINS_SET:
        self.generic_visit(node)
        return
    if self.current_scope_is_global():
        self.used_global_vars.add(node.id)
    elif node.id in self.global_vars:
        self.used_global_vars.add(node.id)

self.global_vars is per-block. A visitor instance analyzes one block, so a global defined in a different block is never in that set. A free variable read inside a function body is therefore invisible precisely when its definition lives in another block — the only case a cross-block dependency graph exists to model.

Direct run of VariableVisitor:

b1 source_value        defined=['source_value'] used=[]
b2 def read_source     defined=['read_source'] used=[]      # source_value missing
b3 caller              defined=['observed'] used=['read_source']
same-block global      defined=['f','g'] used=['g']         # works within one block
method reads global    defined=['C'] used=[]                # same defect via a class method

Reads inside a function whose global lives in the same block still work, which is why this is easy to miss in testing.

Provenance

Introduced in December 2024 when the analyzer started filtering function-local names out of used/defined variables; before that every load was tracked unconditionally:

if isinstance(node.ctx, ast.Load):
    if node.id not in dir(builtins):
        self.used_vars.add(node.id)

A cosmetic fix (function locals appearing in outputVariables) introduced a correctness regression. For a dependency graph that is the wrong trade: an extra edge costs a redundant re-run, a missing edge costs correctness. A fix restoring unconditional load tracking was written and approved at the time but never merged, and the code has since migrated to the @deepnote/reactivity package carrying the regression with it.

Impact

Downstream blocks display a successful but stale result while the variable explorer shows the new value. Reactive execution silently under-runs; the user has no signal that anything is out of date.

Suggested fix

Track loads at any depth, and subtract names actually bound in the enclosing scope chain (function and lambda parameters, comprehension targets, local assignments).

Related

See the companion issue on comprehension targets and lambda parameters being published as notebook variables: #513.

Both are the same underlying gap — the analyzer approximates Python scope with a per-block global_vars set, and block boundaries are not scope boundaries. This issue is the under-reporting direction (silently stale output); the companion issue is the over-reporting direction (phantom edges, spurious re-execution). A correct fix handles both directions at once: track loads at any depth, and subtract names actually bound in the enclosing scope chain. Fixing either direction alone risks re-introducing the other.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions