Repository navigation
Invalidate rx nodes at write time inside a batch - #1229
Merged
Merged
Conversation
The weak invalidators that mark rx nodes dirty were queued by batch() like any user watcher. A read inside the batch answered the value from before the write, and a reference resolved while flushing could run before its cell's invalidator and keep the stale value. Marking a node dirty is bookkeeping, so these invalidators now run immediately; every other watcher is still deferred. Assisted-by: Claude Code:claude-opus-5-5
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1229 +/- ##
==========================================
+ Coverage 89.40% 89.42% +0.01%
==========================================
Files 9 9
Lines 6577 6588 +11
==========================================
+ Hits 5880 5891 +11
Misses 697 697 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
hoxbro
reviewed
Oct 29, 2026
Member
There was a problem hiding this comment.
One comments from me and two fixes from an AI review.
AI Review
## #1229: Invalidate `rx` nodes at write time inside a batch
**Ready to merge?** With fixes
**Reasoning:** The change is small and correct. It fixes both reported bugs, and notifications are still deferred until the batch closes. But nothing tests the `_invalidate_current` half, and override refs still return a stale value inside a batch. Making the override invalidator unbatched too is a one-line change, and the suite still passes with it.
### What works
- The fix removes the cause instead of adding more ordering tricks to `_flush_batch`: one line in `_call_watcher` and a flag on `_WeakInvalidator`. Bug 2 happened because `_sync_refs` and the invalidators share precedence -1, so a stable sort kept walk order. Now the invalidators are never queued, so nothing depends on that order.
- Notifications are still deferred. `.rx.watch`, `_sync_refs` and user watchers each fire once at flush. A chain read twice inside a batch delivers `[8]` once, and `when(trigger)` holds its value inside a batch (ran).
- Because of the `and` short-circuit, `_is_batched` is never called for invalidators. An object whose only watchers are invalidators therefore never enters `touched`, which is harmless because it has nothing to flush.
- `_unbatched` is read from `watcher.fn`, which is always the `_WeakInvalidator` instance itself. `functools.partial`, bound methods and `WeakMethod` never come into it, and every other callable defaults to `False`.
- Side benefits:
- A read inside a batch now raises the correct `error_state` instead of returning the stale value.
- After `discard_events`, `batch_call_watchers` or `.param.update()`, cached nodes follow their source instead of staying stale until the next change.
- Re-entrancy is fine. A node whose function writes another parameter, read inside the batch, still has that write's watcher deferred to the flush: `[]` inside the batch, `[3]` after (ran).
- Async is fine. An async node read or written inside a batch settles to the right value with a single watch call, including when a task is already in flight. `updating()` gives `[True, False]`, the same as on base (ran).
### Should fix
1. **No test covers the `_invalidate_current` half** (`param/reactive.py:3263-3265`). Every new test goes through `rx(...)` roots, which use `_invalidate_obj`. These cases depend on `_invalidate_current` and go stale if it is reverted (ran):
```python
import param
from param import rx
x, y = rx(1), rx(10)
s = x + y
s.rx.value
with param.batch():
y.rx.value = 20
print(s.rx.value)
# b2cdc65: 21
# b2cdc65 without unbatched=True: 11
```
`rx(1) * p.param.v` with `p.v = 3` written in the batch behaves the same way: 3 on the PR head, 1 if reverted.
Fix: add one of these as a test.
2. **Overridden nodes still return a stale value inside a batch** (`param/reactive.py:3481`, `_watch_override`). This is bug 1 again, for override refs, so plain refs and overrides now behave differently (ran):
```python
import param
from param import rx
c = rx(1)
b = rx(10) * rx(0)
b.rx.overrides[1] = c
with param.batch():
c.rx.value = 2
print(b.rx.value)
# b2cdc65: 10
# b2cdc65 + unbatched=True at line 3481: 20
```
The PR leaves `_invalidate_overrides` deferred because it "triggers the override channels". But `channel.param.trigger('value')` is a parameter event on a Parameterized, so the batch defers it anyway. The only part that would run eagerly is `_notify_graph_change()`, which updates the weak listeners behind `.rx.updating()`. That is bookkeeping too.
Fix: pass `unbatched=True` there as well and add the test. If running `_notify_graph_change` eagerly is a concern, separate marking the node dirty from notifying.
### Nice to have
3. **Tests are in the wrong class** (`tests/testreactive.py:6630-6667`). They are in `TestSkipSiblingArguments`, which is about skipped sibling arguments. A batch-specific class in `testreactive.py` would fit better. `TestBatch` in `tests/testwatch.py` has no `rx` usage.
4. **The PR description says `batch_call_watchers`, `.param.update()` and `discard_events` changed, but no test covers them.** For `discard_events`: on the PR head, setting `p.x = 7` inside `discard_events` makes `(p.param.x.rx() * 2).rx.value` read 14 (ran). On base it reads 2 and stays 2 until the next change. In both cases the watch is not called. One test would pin this down.
5. **Docs and changelog.** The `batch()` docstring (`param/parameterized.py` ~595) only talks about deferring notifications. One sentence saying that `rx` values read inside the block include writes already made would help. The `discard_events` change is visible to users, so it deserves a release note.
6. The `_unbatched` comment (`param/reactive.py:2454-2457`) describes what the flag does more than why. Optional.
### Set aside
- Reading through a parameter that follows a ref (`F(v=a)`, then `f.param.v.rx()`) inside a batch still gives the pre-write value. `_sync_refs` is a notification, and the PR deliberately keeps notifications deferred.
- An `.rx.watch` fires `[2]` after a value goes 1→2→1 within one batch. Base behaves the same, so this diff didn't cause it.
- In the probe, async nodes showed `UNDEFINED` before their first read, on both base and head. This comes from how the probe ran the executor, not from the PR.
- Whether `discard_events` should leave `rx` caches stale on purpose. The review assumes that following the source is what users expect, and only asks for a release note (item 5).… type Override refs went stale inside a batch just like plain refs did, so _invalidate_overrides is now unbatched too; the channel trigger it fires is still deferred by the batch. _WeakInvalidator moves to parameterized.py so _call_watcher can check isinstance instead of trusting an attribute on an arbitrary watcher callable. Tests move to a TestBatch class and cover _invalidate_current, overrides and discard_events. Assisted-by: Claude Code:claude-opus-5-5
Member
Author
|
Thanks! Addressed in f1b7576:
I left the release note for when the release notes are written, since |
Only dirtying the node now happens at write time; notifying the override channels is an ordinary watcher again, so .param.update(), batch_call_watchers and discard_events no longer let a consumer on another object see a new override combined with an input not yet flushed. Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Claude Code:claude-opus-5-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
An
rxnode is marked dirty by a weak invalidation watcher registered on its source parameters. Insideparam.batch()that watcher was queued like any user callback, which caused two problems.A read inside a batch answered the value from before the write:
A parameter following several cells as references could keep a stale value after the batch.
_flush_batchmerges the owner's single_sync_refswatcher into one group, created when the walk reaches the first touched cell. Any later cell's invalidator group is created after it, so_sync_refsresolved those references before their cells were marked dirty:Marking a node dirty is bookkeeping rather than a notification, so the invalidators behind
_invalidate_current,_invalidate_objand_invalidate_overridesnow run at write time even when the owner is batched._call_watcherskips the batch for a_WeakInvalidatorcreated withunbatched=True; the class moved fromreactive.pytoparameterized.pyso that check is a plainisinstancewith no import._invalidate_overridesalso triggers the override channels, but those are parameter events on a Parameterized, so the batch still defers them. All other watchers are still deferred, including.rx.watchcallbacks. The same applies tobatch_call_watchers,.param.update()anddiscard_events: a cached node now follows the parameter it reads even while its notifications are held back or dropped. Thebatch()docstring now says that only notifications are deferred.Tests live in a new
TestBatchclass intests/testreactive.py. They cover references following every cell written in one batch, a read inside a batch answering the latest write (throughpipe, an operand, a parameter and an override),.rx.watchcallbacks (including on an overridden node) still being deferred until the batch closes, and a node following its parameter afterdiscard_events. The full suite passes on 3.14.AI Disclosure
Tool & Model: Claude Code + Claude Opus 5.5
Usage: Investigated the ordering in
_flush_batch, implemented the fix and wrote the tests, under my direction and review.Checklist