Repository: marimo-team/marimo
Pull request #4920: fix: Fix execution hash regression 
Author: login: dmadisetti
Created: 2025-05-14T06:02:40Z | Merged: 2025-05-14T22:51:58Z

@akshayka

should traverse all parents starting from ref definition.


Diff:
diff --git a/marimo/_save/hash.py b/marimo/_save/hash.py
index 4e7d543c1db..e38dbc3f347 100644
--- a/marimo/_save/hash.py
+++ b/marimo/_save/hash.py
@@ -330,10 +330,13 @@ def __init__(
            these methods is that Nix sandboxes all execution, preventing external file access, and
            internet. Sources of non-determinism are not accounted for in this implementation, and are
            left to the user.
+           NB. The ContextExecutionPath is an extended case of ExecutionPath hashing, just utilizing
+           additional context.
 
-        In both cases, as long as the module is deterministic, the output will be deterministic. NB.
-        The ContextExecutionPath is an extended case of ExecutionPath hashing, just utilizing
-        additional context.
+        In these cases, as long as the module is deterministic, the output will be deterministic.
+        A side effects api is implemented to account for "uncontrollable external state" when
+        utilizing builtin functions like `mo.watch.file`, but the onus is put on the user currently
+        to manage external state.
 
         For optimization, the content hash is performed after the execution cache- however the content
         references are collected first. This deferred content hash is useful in cases like repeated
@@ -932,7 +935,13 @@ def cells_from_refs(self, refs: set[Name]) -> set[CellId_t]:
         ref_cells = set().union(
             *[self.graph.definitions.get(ref, set()) for ref in refs]
         )
-        return ancestors & ref_cells
+        cell_basis = ancestors & ref_cells
+        return (
+            set().union(
+                *[self.graph.ancestors(cell_id) for cell_id in cell_basis]
+            )
+            | cell_basis
+        )
 
 
 def cache_attempt_from_hash(
diff --git a/tests/_save/test_hash.py b/tests/_save/test_hash.py
index 7215676d2b8..da1c9e7acd8 100644
--- a/tests/_save/test_hash.py
+++ b/tests/_save/test_hash.py
@@ -7,6 +7,7 @@
 
 import pytest
 
+from marimo._ast.app import App
 from marimo._dependencies.dependencies import DependencyManager
 from marimo._runtime.runtime import Kernel
 from tests.conftest import ExecReqProvider
@@ -217,22 +218,76 @@ def load() -> tuple[int]:
             return shared
 
     @staticmethod
-    def test_transitive_content_hash(app) -> None:
-        @app.cell
-        def load() -> tuple[int]:
-            from marimo._save.save import persistent_cache
-            from tests._save.loaders.mocks import MockLoader
+    def test_transitive_content_hash() -> None:
+        app1 = App()
+        app1._anonymous_file = True
+        app1._pytest_rewrite = True
 
-            shared = [None, object()]
-            return persistent_cache, MockLoader, shared
+        @app1.cell
+        def _():
+            import marimo as mo
 
-        @app.cell
-        def one(persistent_cache, MockLoader, shared) -> tuple[int]:
-            _a = len(shared)
-            with persistent_cache(name="one", _loader=MockLoader()) as cache:
-                Y = 8 + _a
-            assert cache._cache.cache_type == "ContentAddressed"
-            return (Y,)
+            return mo
+
+        @app1.cell
+        def _():
+            value = False
+
+        @app1.cell
+        def cache_1(args, mo):
+            with mo.persistent_cache("cache_bug") as cache:
+                output = args.value
+            assert cache.cache_type == "ExecutionPath"
+
+        @app1.cell
+        def _(value):
+            class Unhashable:
+                def __eq__(self, other):
+                    return isinstance(other, Unhashable)
+
+                __hash__ = None  # Makes instances unhashable
+
+            args = Unhashable()
+            args.value = value
+            return (args,)
+
+        app2 = App()
+        app2._anonymous_file = True
+        app2._pytest_rewrite = True
+
+        @app2.cell
+        def _():
+            import marimo as mo
+
+            return mo
+
+        @app2.cell
+        def _():
+            value = True
+
+        @app2.cell
+        def cache_2(args, mo):
+            with mo.persistent_cache("cache_bug") as cache:
+                output = args.value
+            assert cache.cache_type == "ExecutionPath"
+
+        @app2.cell
+        def _(value):
+            class Unhashable:
+                def __eq__(self, other):
+                    return isinstance(other, Unhashable)
+
+                __hash__ = None  # Makes instances unhashable
+
+            args = Unhashable()
+            args.value = value
+            return (args,)
+
+        _, defs1 = app1.run()
+        _, defs2 = app2.run()
+
+        assert defs1["cache"]._cache.hash != defs2["cache"]._cache.hash
+        assert defs1["output"] != defs2["output"]
 
     @staticmethod
     def test_function_ui_content_hash(app) -> None:
@@ -839,6 +894,54 @@ def two(MockLoader, persistent_cache, expected_hash, pl) -> tuple[int]:
             return (two,)
 
 
+# Skip for now, as the local branch is cache busting
+@pytest.mark.skipif(True, reason="Cache busting")
+class TestDynamicHash:
+    @staticmethod
+    async def test_transitive_state_hash(
+        k: Kernel, exec_req: ExecReqProvider, tmp_path
+    ) -> None:
+        await k.run(
+            [
+                exec_req.get("import marimo as mo; from pathlib import Path"),
+                exec_req.get("value, set_value = mo.state(False)"),
+                exec_req.get("""
+                class Unhashable:
+                    def __eq__(self, other):
+                        return isinstance(other, Unhashable)
+
+                    __hash__ = None  # Makes instances unhashable
+
+                args = Unhashable()
+                args.value = value
+                """),
+                exec_req.get(f"""
+                with mo.persistent_cache("cache", save_path=Path("{tmp_path.as_posix()}")) as cache:
+                    output = args.value
+                """),
+            ]
+        )
+        assert not k.errors
+        assert not k.stdout.messages, k.stdout
+        assert not k.stderr.messages, k.stderr
+        assert not k.globals["cache"]._cache.hit
+
+        hash_1 = k.globals["cache"]._cache.hash
+        output_1 = k.globals["output"]
+
+        await k.run([exec_req.get("set_value(True)")])
+        assert not k.errors
+        assert not k.stdout.messages, k.stdout
+        assert not k.stderr.messages, k.stderr
+        assert not k.globals["cache"]._cache.hit
+
+        hash_2 = k.globals["cache"]._cache.hash
+        output_2 = k.globals["output"]
+
+        assert hash_1 != hash_2
+        assert output_1 != output_2
+
+
 class TestSideEffects:
     @staticmethod
     async def test_side_effect_cache_different(


Reviews:
user: login: mscolnick
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T14:41:23Z
user: login: akshayka
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T22:36:41Z
user: login: dmadisetti
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T22:39:17Z
user: login: akshayka
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T22:49:33Z

Review comments:
diff_hunk: @@ -120,11 +120,17 @@ def __enter__(self) -> None:
             )
         self._frame = with_frame
         self._previous = {**with_frame.f_locals}
+        # Python 3.13+ has different behavior for f_locals
+        if not hasattr(self._frame.f_locals, "clear"):
+            # Since we statically parse this is fine, but may cause issues in
+            # script runs.
+            return
         # A reference to the key app must be maintained in the frame.
         # This may be a python quirk, so just remove refs to explicit defs
-        for var in self._cell.defs:
-            if var in self._previous:
-                del self._frame.f_locals[var]
+        self._frame.f_locals.clear()
+        for p in self._previous:
+            if p not in self._cell.defs:
+                self._previous[p] = self._previous[p]
path: marimo/_ast/app.py
user: login: akshayka
body: Sorry, not sure if I am reading this wrong — why assign `self._previous[p]` to itself?
created_at: 2025-05-14T22:36:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,11 +120,17 @@ def __enter__(self) -> None:
             )
         self._frame = with_frame
         self._previous = {**with_frame.f_locals}
+        # Python 3.13+ has different behavior for f_locals
+        if not hasattr(self._frame.f_locals, "clear"):
+            # Since we statically parse this is fine, but may cause issues in
+            # script runs.
+            return
         # A reference to the key app must be maintained in the frame.
         # This may be a python quirk, so just remove refs to explicit defs
-        for var in self._cell.defs:
-            if var in self._previous:
-                del self._frame.f_locals[var]
+        self._frame.f_locals.clear()
+        for p in self._previous:
+            if p not in self._cell.defs:
+                self._previous[p] = self._previous[p]
path: marimo/_ast/app.py
user: login: dmadisetti
body: You are right. Reverting and breaking this out into a separate issue .
created_at: 2025-05-14T22:39:17Z
author_association: COLLABORATOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-14T06:02:44Z
body: [vc]: #joTpsMxYF0MTHwxXVIqGW5EMz2rnlU78N3Lj/1/P6NI=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJtYXJpbW8tZG9jcyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJtYXJpbW8tZG9jcy1naXQtZG0tY2FjaGUtZml4dXAtbWFyaW1vLnZlcmNlbC5hcHAifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL21hcmltby9tYXJpbW8tZG9jcy80ZWpONUdRWXI4UG1xRzU5YUtWRUdXWVpiaWtwIiwicHJldmlld1VybCI6Im1hcmltby1kb2NzLWdpdC1kbS1jYWNoZS1maXh1cC1tYXJpbW8udmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsInJvb3REaXJlY3RvcnkiOiJkb2NzIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **marimo-docs** | ✅ Ready ([Inspect](https://vercel.com/marimo/marimo-docs/4ejN5GQYr8PmqG59aKVEGWYZbikp)) | [Visit Preview](https://marimo-docs-git-dm-cache-fixup-marimo.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/marimo-docs-git-dm-cache-fixup-marimo.vercel.app?via=pr-comment-feedback-link) | May 14, 2025 10:41pm |


author_association: NONE
user: login: dmadisetti
created_at: 2025-05-14T14:49:53Z
body: Moving back to draft to indicate transitive state isn't working 100%, and the test should be redone, because the line number hack is silly
author_association: COLLABORATOR
user: login: dmadisetti
created_at: 2025-05-14T21:52:23Z
body: This does not fix stateful execution path hashes. A more substantial fix cache busts in a big way- so I think this is a good stop gap for now
author_association: COLLABORATOR
user: login: akshayka
created_at: 2025-05-14T21:58:25Z
body: @dmadisetti does that mean this would still yield a false positive cache hit on the embedding example I shared?
author_association: CONTRIBUTOR
user: login: dmadisetti
created_at: 2025-05-14T22:08:10Z
body: It'll break if it uses an execution path that requires state / ui- writing up a little plan rn
author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-05-14T22:54:30Z
body: 🚀 Development release published. You may be able to view the changes at https://marimo.app?v=0.13.9-dev6
author_association: NONE