Skip to content

Commit 7cb7243

Browse files
addenergyxclaude
andcommitted
Invalidate cached ContinuationIterable when its continuation fails
GlobalCachingStateHandler caches partially materialized bag state (ContinuationIterable) across bundles under the user state cache token. The iterable's lazy remainder holds the continuation token minted by the read that created it. If that token becomes permanently invalid (for example the runner revoked it together with the work item that created it), the cached iterable fails on every subsequent read of that state and the cache never recovers: each retry is served the same poisoned object and replays the dead token instead of re-fetching. On Dataflow this manifests as an unbounded 'work token no longer valid' retry storm that stalls the stage. Drop the cache entry when iterating the continuation fails so the next read loads fresh state. The invalidation is identity-guarded via a new StateCache.invalidate_if_value so a stale iterable failing again later cannot evict a newer value cached under the same key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0643150 commit 7cb7243

4 files changed

Lines changed: 208 additions & 7 deletions

File tree

sdks/python/apache_beam/runners/worker/sdk_worker.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,7 +1227,7 @@ def blocking_get(
12271227
cache_state_key = self._convert_to_cache_key(state_key)
12281228
return self._state_cache.get(
12291229
(cache_state_key, cache_token),
1230-
lambda key: self._partially_cached_iterable(state_key, coder))
1230+
lambda key: self._partially_cached_iterable(state_key, coder, key))
12311231

12321232
def extend(
12331233
self,
@@ -1354,7 +1354,8 @@ def _get_cache_token(self, state_key):
13541354
def _partially_cached_iterable(
13551355
self,
13561356
state_key, # type: beam_fn_api_pb2.StateKey
1357-
coder # type: coder_impl.CoderImpl
1357+
coder, # type: coder_impl.CoderImpl
1358+
cache_key # type: Any
13581359
):
13591360
# type: (...) -> Iterable[Any]
13601361

@@ -1372,20 +1373,45 @@ def _partially_cached_iterable(
13721373
return self.ContinuationIterable(
13731374
head,
13741375
functools.partial(
1375-
self._lazy_iterator, state_key, coder, continuation_token))
1376+
self._lazy_iterator, state_key, coder, continuation_token),
1377+
on_failure=functools.partial(
1378+
self._state_cache.invalidate_if_value, cache_key))
13761379

13771380
class ContinuationIterable(Generic[T], CacheAware):
1378-
def __init__(self, head, continue_iterator_fn):
1379-
# type: (Iterable[T], Callable[[], Iterable[T]]) -> None
1381+
def __init__(
1382+
self,
1383+
head, # type: Iterable[T]
1384+
continue_iterator_fn, # type: Callable[[], Iterable[T]]
1385+
on_failure=None # type: Optional[Callable[[Any], None]]
1386+
):
1387+
# type: (...) -> None
13801388
self.head = head
13811389
self.continue_iterator_fn = continue_iterator_fn
1390+
self.on_failure = on_failure
13821391

13831392
def __iter__(self):
13841393
# type: () -> Iterator[T]
13851394
for item in self.head:
13861395
yield item
1387-
for item in self.continue_iterator_fn():
1388-
yield item
1396+
try:
1397+
for item in self.continue_iterator_fn():
1398+
yield item
1399+
except Exception:
1400+
# The continuation token bound into this iterable may be permanently
1401+
# invalid (e.g. the runner revoked it along with the work item that
1402+
# created it). If this instance stays cached it will fail every
1403+
# subsequent read of this state, so drop it from the cache (only if
1404+
# it is still the cached value) and let the next read fetch fresh
1405+
# state.
1406+
if self.on_failure is not None:
1407+
try:
1408+
self.on_failure(self)
1409+
except Exception:
1410+
_LOGGER.warning(
1411+
'Failed to invalidate cached state after continuation '
1412+
'failure.',
1413+
exc_info=True)
1414+
raise
13891415

13901416
def get_referents_for_cache(self):
13911417
# type: () -> List[Any]

sdks/python/apache_beam/runners/worker/sdk_worker_test.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,31 @@ def clear(self, *args):
520520
def process_instruction_id(self, bundle_id):
521521
yield
522522

523+
class RevokableStateHandler(UnderlyingStateHandler):
524+
"""State handler whose continuation tokens can be permanently revoked,
525+
as happens when a runner loses the work item that created them.
526+
"""
527+
def __init__(self):
528+
super().__init__()
529+
self._token_epoch = 0
530+
self.initial_read_count = 0
531+
532+
def revoke_outstanding_tokens(self):
533+
self._token_epoch += 1
534+
535+
def get_raw(self, _state_key, continuation_token=None):
536+
if continuation_token:
537+
epoch, token = continuation_token.split(':')
538+
if int(epoch) != self._token_epoch:
539+
raise RuntimeError('continuation token no longer valid')
540+
continuation_token = token
541+
else:
542+
self.initial_read_count += 1
543+
data, next_token = super().get_raw(_state_key, continuation_token)
544+
if next_token is not None:
545+
next_token = '%d:%s' % (self._token_epoch, next_token)
546+
return data, next_token
547+
523548
def test_append_clear_with_preexisting_state(self):
524549
state = beam_fn_api_pb2.StateKey(
525550
bag_user_state=beam_fn_api_pb2.StateKey.BagUserState(
@@ -611,6 +636,98 @@ def clear():
611636
self.assertEqual(get_type(), list)
612637
self.assertEqual(get(), [i for i in range(1000)])
613638

639+
def _make_revokable_handler(self):
640+
underlying_state_handler = self.RevokableStateHandler()
641+
state_cache = statecache.StateCache(100 << 20)
642+
handler = GlobalCachingStateHandler(state_cache, underlying_state_handler)
643+
644+
coder = VarIntCoder()
645+
646+
state = beam_fn_api_pb2.StateKey(
647+
bag_user_state=beam_fn_api_pb2.StateKey.BagUserState(
648+
user_state_id='state1'))
649+
650+
cache_token = beam_fn_api_pb2.ProcessBundleRequest.CacheToken(
651+
token=b'state_token1',
652+
user_state=beam_fn_api_pb2.ProcessBundleRequest.CacheToken.UserState())
653+
654+
underlying_state_handler.set_continuations(True)
655+
underlying_state_handler.set_values([45, 46, 47], coder)
656+
return underlying_state_handler, handler, coder, state, cache_token
657+
658+
def test_failed_continuation_invalidates_cached_iterable(self):
659+
(underlying_state_handler, handler, coder, state,
660+
cache_token) = self._make_revokable_handler()
661+
662+
with handler.process_instruction_id('bundle1', [cache_token]):
663+
# blocking_get caches a partially materialized iterable whose lazy
664+
# remainder holds the continuation token minted for this read.
665+
iterable = handler.blocking_get(state, coder.get_impl())
666+
self.assertIsInstance(
667+
iterable, GlobalCachingStateHandler.ContinuationIterable)
668+
underlying_state_handler.revoke_outstanding_tokens()
669+
with self.assertRaises(RuntimeError):
670+
list(iterable)
671+
672+
# The failed iterable must not remain cached: a read in a later bundle
673+
# must fetch fresh state instead of replaying the dead continuation
674+
# token forever.
675+
with handler.process_instruction_id('bundle2', [cache_token]):
676+
self.assertEqual([45, 46, 47],
677+
list(handler.blocking_get(state, coder.get_impl())))
678+
679+
def test_failed_continuation_invalidates_after_partial_yield(self):
680+
(underlying_state_handler, handler, coder, state,
681+
cache_token) = self._make_revokable_handler()
682+
683+
with handler.process_instruction_id('bundle1', [cache_token]):
684+
iterator = iter(handler.blocking_get(state, coder.get_impl()))
685+
# The head page and the first continuation page succeed before the
686+
# remaining tokens are revoked mid-iteration.
687+
self.assertEqual(45, next(iterator))
688+
self.assertEqual(46, next(iterator))
689+
underlying_state_handler.revoke_outstanding_tokens()
690+
with self.assertRaises(RuntimeError):
691+
next(iterator)
692+
693+
with handler.process_instruction_id('bundle2', [cache_token]):
694+
self.assertEqual([45, 46, 47],
695+
list(handler.blocking_get(state, coder.get_impl())))
696+
697+
def test_stale_failed_iterable_does_not_evict_replacement(self):
698+
(underlying_state_handler, handler, coder, state,
699+
cache_token) = self._make_revokable_handler()
700+
701+
with handler.process_instruction_id('bundle1', [cache_token]):
702+
stale = handler.blocking_get(state, coder.get_impl())
703+
underlying_state_handler.revoke_outstanding_tokens()
704+
with self.assertRaises(RuntimeError):
705+
list(stale)
706+
707+
with handler.process_instruction_id('bundle2', [cache_token]):
708+
replacement = handler.blocking_get(state, coder.get_impl())
709+
self.assertEqual([45, 46, 47], list(replacement))
710+
# Failing the stale iterable again must not evict the freshly cached
711+
# replacement.
712+
with self.assertRaises(RuntimeError):
713+
list(stale)
714+
self.assertIs(replacement, handler.blocking_get(state, coder.get_impl()))
715+
self.assertEqual(2, underlying_state_handler.initial_read_count)
716+
717+
def test_invalidation_failure_does_not_mask_continuation_error(self):
718+
def failing_continuation():
719+
raise RuntimeError('continuation token no longer valid')
720+
721+
def failing_invalidation(_value):
722+
raise ValueError('invalidation failed')
723+
724+
iterable = GlobalCachingStateHandler.ContinuationIterable(
725+
[45, 46], failing_continuation, on_failure=failing_invalidation)
726+
# The original continuation error must propagate even if the
727+
# invalidation callback itself fails.
728+
with self.assertRaises(RuntimeError):
729+
list(iterable)
730+
614731

615732
class ShortIdCacheTest(unittest.TestCase):
616733
def testShortIdAssignment(self):

sdks/python/apache_beam/runners/worker/statecache.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,24 @@ def invalidate(self, key: Any) -> None:
338338
if weighted_value is not None:
339339
self._current_weight -= weighted_value.weight()
340340

341+
def invalidate_if_value(self, key: Any, expected_value: Any) -> None:
342+
"""Removes the entry for key only if it still holds expected_value.
343+
344+
Used by callers that discover a previously returned value is unusable
345+
(e.g. it fails during iteration). The entry must only be removed if it
346+
has not been replaced by a newer value in the meantime, which the stale
347+
value's failure says nothing about.
348+
"""
349+
assert self.is_cache_enabled()
350+
with self._lock:
351+
weighted_value = self._cache.get(key, None)
352+
if weighted_value is None or _safe_isinstance(weighted_value,
353+
_LoadingValue):
354+
return
355+
if weighted_value.value() is not expected_value:
356+
return
357+
self.invalidate(key)
358+
341359
def invalidate_all(self) -> None:
342360
with self._lock:
343361
self._cache.clear()

sdks/python/apache_beam/runners/worker/statecache_test.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,46 @@ def test_max_size(self):
158158
'used/max 2/2 MB, hit 100.00%, lookups 0, '
159159
'avg load time 0 ns, loads 0, evictions 1'))
160160

161+
def test_invalidate_if_value(self):
162+
cache = StateCache(5 << 20)
163+
value = ['value']
164+
cache.put("key", WeightedValue(value, 1 << 20))
165+
# An equal but distinct value must not cause removal; the comparison is
166+
# by identity.
167+
cache.invalidate_if_value("key", ['value'])
168+
self.assertEqual(cache.peek("key"), value)
169+
# A missing key is a no-op.
170+
cache.invalidate_if_value("key2", value)
171+
# The identical value is removed.
172+
cache.invalidate_if_value("key", value)
173+
self.assertEqual(cache.peek("key"), None)
174+
self.assertEqual(cache.size(), 0)
175+
176+
def test_invalidate_if_value_ignores_in_flight_load(self):
177+
cache = StateCache(5 << 20)
178+
value = 'value'
179+
load_started = threading.Event()
180+
finish_load = threading.Event()
181+
182+
def load(_key):
183+
load_started.set()
184+
finish_load.wait()
185+
return value
186+
187+
result = []
188+
loader = threading.Thread(
189+
target=lambda: result.append(cache.get("key", load)))
190+
loader.start()
191+
load_started.wait()
192+
# The entry is still loading: invalidate_if_value must neither block on
193+
# nor remove the in-flight placeholder.
194+
cache.invalidate_if_value("key", value)
195+
self.assertEqual(cache.size(), 1)
196+
finish_load.set()
197+
loader.join()
198+
self.assertEqual(result, [value])
199+
self.assertEqual(cache.peek("key"), value)
200+
161201
def test_invalidate_all(self):
162202
cache = StateCache(5 << 20)
163203
cache.put("key", WeightedValue("value", 1 << 20))

0 commit comments

Comments
 (0)