Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 69 additions & 16 deletions param/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -1411,6 +1411,27 @@ def _remove_watcher(
pass


async def _close_stale(obj):
"""
Discard an awaitable or async generator whose result is no longer needed.

Closing a coroutine that was never awaited also suppresses the warning
Python emits when it is garbage collected.
"""
try:
if inspect.isasyncgen(obj):
await obj.aclose()
elif inspect.iscoroutine(obj):
obj.close()
except (StopAsyncIteration, GeneratorExit):
pass
except Exception:
logger.debug(
"Ignoring close error for stale reactive task.",
exc_info=True,
)


# When we only support python >= 3.11 we should exchange 'rx' with Self type annotation below.
# See https://peps.python.org/pep-0673/

Expand Down Expand Up @@ -1576,6 +1597,8 @@ def __init__(
self._dirty_obj = False
self._current_task = None
self._resolve_generation = 0
self._finished_generation = 0
self._skipped = False
self._error_state = None
self._current_ = _current
# _shared is used for branching rx pipelines where we clone the input.
Expand Down Expand Up @@ -1679,6 +1702,14 @@ def _is_async(self) -> bool:
inspect.isgeneratorfunction(fn)
)

@property
def _awaiting(self) -> bool:
"""
Whether an asynchronous resolution is in flight that has not yet
produced a value for the current generation.
"""
return self._resolve_generation != self._finished_generation

@property
def _current(self):
if self._error_state:
Expand Down Expand Up @@ -1794,14 +1825,23 @@ def _invalidate_obj(self, *events):
t.cast('t.Any', self._root)._dirty_obj = True
self._error_state = None

async def _resolve_async(self, obj=None, generation=None):
async def _resolve_async(self, obj=None, generation: int = 0):
import asyncio
self._current_task = task = asyncio.current_task()
trigger = self._trigger

def stale():
return generation != self._resolve_generation

if stale():
# A newer resolution was requested before this task was scheduled,
# so nothing has awaited obj yet and the operation has not begun.
# Close it instead of computing a result that is already superseded.
# This must happen before _current_task is claimed below, otherwise
# the finally clause would clear the genuinely current task and hide
# it from the next _lazy_resolve.
await _close_stale(obj)
return
self._current_task = task = asyncio.current_task()
trigger = self._trigger
try:
if trigger is None:
return
Expand All @@ -1814,27 +1854,22 @@ def stale():
if stale():
return
self._current_ = shared.rx.value
self._finished_generation = generation
trigger.param.trigger('value')
elif inspect.isasyncgen(obj):
async for val in obj:
if stale():
try:
await obj.aclose()
except (StopAsyncIteration, GeneratorExit):
pass
except Exception:
logger.debug(
"Ignoring async generator close error for stale reactive task.",
exc_info=True,
)
await _close_stale(obj)
break
self._current_ = val
self._finished_generation = generation
trigger.param.trigger('value')
else:
value = await obj
if stale():
return
self._current_ = value
self._finished_generation = generation
trigger.param.trigger('value')
except asyncio.CancelledError:
return
Expand Down Expand Up @@ -1862,6 +1897,8 @@ def _resolve(self):
if obj is Skip or obj is Undefined:
self._current_ = Undefined
raise Skip
elif self._prev is not None and self._prev._skipped:
raise Skip
elif (
self._shared is not None and
self._method is None and
Expand All @@ -1873,9 +1910,14 @@ def _resolve(self):
if self._is_async:
self._shared.rx.value # trigger async resolve
self._lazy_resolve()
else:
self._current_ = self._shared.rx.value
raise Skip
raise Skip
# Returns instead of raising Skip because this path does
# resolve to a value, so it must mirror the shared node's
# skip state rather than be marked skipped by the handler.
self._current_ = self._shared.rx.value
self._skipped = self._shared._skipped
self._dirty = False
return self._current_
operation = self._operation
if operation:
obj = self._eval_operation(obj, operation)
Expand All @@ -1886,13 +1928,19 @@ def _resolve(self):
raise Skip
except Skip:
self._dirty = False
self._skipped = True
return self._current_
except Exception as e:
self._error_state = e
raise e
self._current_ = current = obj
self._skipped = False
else:
current = self._current_
# A node awaiting an asynchronous result still holds the value it
# computed from the previous inputs; report it as skipped so it is
# not propagated as if it were current.
self._skipped = self._awaiting
self._dirty = False
if self._method:
# E.g. `pi = dfi.A` leads to `pi._method` equal to `'A'`.
Expand Down Expand Up @@ -2232,6 +2280,11 @@ def __setattr__(self, name, value):
def _rx_transform(obj):
if not isinstance(obj, rx):
return obj
return bind(lambda *_: obj.rx.value, *obj._params)
def resolve(*_):
value = obj.rx.value
if obj._skipped or value is Skip or value is Undefined:
raise Skip
return value
return bind(resolve, *obj._params)

register_reference_transform(_rx_transform)
203 changes: 203 additions & 0 deletions tests/testreactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,209 @@ async def gen(value, i):
await async_wait_until(lambda: rxgen.rx.value == 10, interval=10)
await async_wait_until(lambda: rxgen.rx.value == 11)

async def mul_slowly(value):
await asyncio.sleep(0.02)
return value*2

async def test_reactive_async_watcher_not_notified_while_awaiting():
irx = rx(1)
async_rx = irx.rx.pipe(mul_slowly)
items = []
async_rx.rx.watch(items.append)
assert async_rx.rx.value is param.Undefined
await async_wait_until(lambda: items == [2])
irx.rx.value = 2

# The awaited value has not resolved yet, so the watcher must not be
# notified with the value computed from the previous input.
assert items == [2]

await async_wait_until(lambda: items == [2, 4])

async def test_reactive_async_downstream_watcher_not_notified_while_awaiting():
irx = rx(1)
downstream = irx.rx.pipe(mul_slowly) + 10
items = []
downstream.rx.watch(items.append)
assert downstream.rx.value is param.Undefined
await async_wait_until(lambda: items == [12])
irx.rx.value = 2
assert items == [12]
await async_wait_until(lambda: items == [12, 14])

async def test_reactive_async_downstream_not_computed_while_awaiting():
computed = []
def add(value):
computed.append(value)
return value+10

irx = rx(1)
downstream = irx.rx.pipe(mul_slowly).rx.pipe(add)
downstream.rx.watch()
downstream.rx.value
await async_wait_until(lambda: computed == [2])
irx.rx.value = 2

# The downstream operation must not be applied to the superseded value.
assert computed == [2]

await async_wait_until(lambda: computed == [2, 4])

async def test_reactive_async_rapid_updates_notify_once():
irx = rx(1)
async_rx = irx.rx.pipe(mul_slowly)
items = []
async_rx.rx.watch(items.append)
async_rx.rx.value
await async_wait_until(lambda: items == [2])
for value in (2, 3, 4):
irx.rx.value = value
await async_wait_until(lambda: items == [2, 8])
assert items == [2, 8]

def test_reactive_skip_value_does_not_notify_watcher():
P = Parameters(integer=1)

def skip_values(v):
if v > 2:
raise Skip
return v+1

i = rx(P.param.integer).rx.pipe(skip_values)
items = []
i.rx.watch(items.append)
P.integer = 2
assert items == [3]

# The operation skipped, so the watcher must not be notified with the
# value computed from the previous input.
P.integer = 3
assert items == [3]
assert i.rx.value == 3

async def test_reactive_async_ref_not_synced_while_awaiting():
class Ref(param.Parameterized):
value = param.Integer(default=0, allow_refs=True)

irx = rx(1)
p = Ref(value=irx.rx.pipe(mul_slowly))
await async_wait_until(lambda: p.value == 2)
irx.rx.value = 2
assert p.value == 2
await async_wait_until(lambda: p.value == 4)

async def test_reactive_async_superseded_updates_not_computed():
started, finished = [], []

async def mul(value):
started.append(value)
await asyncio.sleep(0.02)
finished.append(value)
return value*2

irx = rx(1)
async_rx = irx.rx.pipe(mul)
items = []
async_rx.rx.watch(items.append)
async_rx.rx.value
await async_wait_until(lambda: items == [2])
assert started == [1]

# The updates arrive without yielding to the event loop, so the tasks they
# schedule have not started by the time the next one supersedes them.
for value in (2, 3, 4, 5):
irx.rx.value = value

await async_wait_until(lambda: items == [2, 10])

# Only the final update was computed; the superseded coroutines were closed
# before their bodies began.
assert started == [1, 5]
assert finished == [1, 5]

async def test_reactive_async_gen_superseded_updates_not_computed():
started = []

async def gen(value):
started.append(value)
yield value*2

irx = rx(1)
async_rx = irx.rx.pipe(gen)
async_rx.rx.watch()
async_rx.rx.value
await async_wait_until(lambda: async_rx.rx.value == 2)
assert started == [1]

for value in (2, 3, 4, 5):
irx.rx.value = value

await async_wait_until(lambda: async_rx.rx.value == 10)

# A superseded async generator is closed before it is first iterated, so
# its body never runs.
assert started == [1, 5]

async def test_reactive_async_running_task_is_cancelled():
cancelled = []

async def mul(value):
try:
await asyncio.sleep(0.1)
except asyncio.CancelledError:
cancelled.append(value)
raise
return value*2

irx = rx(1)
async_rx = irx.rx.pipe(mul)
async_rx.rx.watch()
async_rx.rx.value

# Yield to the event loop so the body is actually suspended on its await;
# a task in that state cannot be skipped, it has to be cancelled.
await asyncio.sleep(0.01)
irx.rx.value = 2

await async_wait_until(lambda: async_rx.rx.value == 4)
assert cancelled == [1]

@pytest.mark.parametrize('lazy', [False, True])
async def test_async_shared_rx_superseded_updates_computed_once(lazy):
call_count = 0

class Model(param.Parameterized):
a = param.Number(1.0)

model = Model()

async def expensive_compute(a):
nonlocal call_count
call_count += 1
await asyncio.sleep(0.02)
return {"x": a + 1, "y": a * 2}

shared = rx(model.param.a, lazy=lazy).rx.pipe(expensive_compute)
x_rx = shared.rx.pipe(lambda d: d["x"])
y_rx = shared.rx.pipe(lambda d: d["y"])

x_rx.rx.value
y_rx.rx.value
await async_wait_until(lambda: call_count == 1)

for value in (2.0, 3.0, 4.0):
model.a = value

x_rx.rx.value
y_rx.rx.value
await async_wait_until(
lambda: x_rx.rx.value == 5 and y_rx.rx.value == 8
)

# The branches resolve through the shared node rather than recomputing, and
# the superseded updates are not computed at all.
assert call_count == 2

@pytest.mark.parametrize('lazy', [False, True])
def test_root_invalidation(lazy):
arx = rx('a', lazy=lazy)
Expand Down