Skip to content

Commit 604ff1b

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 0c151d8 commit 604ff1b

4 files changed

Lines changed: 190 additions & 7 deletions

File tree

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

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,7 +1235,7 @@ def blocking_get(
12351235
cache_state_key = self._convert_to_cache_key(state_key)
12361236
return self._state_cache.get(
12371237
(cache_state_key, cache_token),
1238-
lambda key: self._partially_cached_iterable(state_key, coder))
1238+
lambda key: self._partially_cached_iterable(state_key, coder, key))
12391239

12401240
def extend(
12411241
self,
@@ -1362,7 +1362,8 @@ def _get_cache_token(self, state_key):
13621362
def _partially_cached_iterable(
13631363
self,
13641364
state_key, # type: beam_fn_api_pb2.StateKey
1365-
coder # type: coder_impl.CoderImpl
1365+
coder, # type: coder_impl.CoderImpl
1366+
cache_key # type: Any
13661367
):
13671368
# type: (...) -> Iterable[Any]
13681369

@@ -1380,20 +1381,42 @@ def _partially_cached_iterable(
13801381
return self.ContinuationIterable(
13811382
head,
13821383
functools.partial(
1383-
self._lazy_iterator, state_key, coder, continuation_token))
1384+
self._lazy_iterator, state_key, coder, continuation_token),
1385+
on_failure=functools.partial(
1386+
self._state_cache.invalidate_if_value, cache_key))
13841387

13851388
class ContinuationIterable(Generic[T], CacheAware):
1386-
def __init__(self, head, continue_iterator_fn):
1387-
# type: (Iterable[T], Callable[[], Iterable[T]]) -> None
1389+
def __init__(
1390+
self,
1391+
head, # type: Iterable[T]
1392+
continue_iterator_fn, # type: Callable[[], Iterable[T]]
1393+
on_failure=None # type: Optional[Callable[[Any], None]]
1394+
):
1395+
# type: (...) -> None
13881396
self.head = head
13891397
self.continue_iterator_fn = continue_iterator_fn
1398+
self.on_failure = on_failure
13901399

13911400
def __iter__(self):
13921401
# type: () -> Iterator[T]
13931402
for item in self.head:
13941403
yield item
1395-
for item in self.continue_iterator_fn():
1396-
yield item
1404+
try:
1405+
for item in self.continue_iterator_fn():
1406+
yield item
1407+
except Exception:
1408+
# The continuation token bound into this iterable may be permanently
1409+
# invalid; drop this instance from the cache so the next read
1410+
# fetches fresh state instead of replaying a dead token forever.
1411+
if self.on_failure is not None:
1412+
try:
1413+
self.on_failure(self)
1414+
except Exception:
1415+
_LOGGER.warning(
1416+
'Failed to invalidate cached state after continuation '
1417+
'failure.',
1418+
exc_info=True)
1419+
raise
13971420

13981421
def get_referents_for_cache(self):
13991422
# type: () -> List[Any]

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

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,31 @@ def clear(self, *args):
558558
def process_instruction_id(self, bundle_id):
559559
yield
560560

561+
class RevokableStateHandler(UnderlyingStateHandler):
562+
"""State handler whose continuation tokens can be permanently revoked,
563+
as happens when a runner loses the work item that created them.
564+
"""
565+
def __init__(self):
566+
super().__init__()
567+
self._token_epoch = 0
568+
self.initial_read_count = 0
569+
570+
def revoke_outstanding_tokens(self):
571+
self._token_epoch += 1
572+
573+
def get_raw(self, _state_key, continuation_token=None):
574+
if continuation_token:
575+
epoch, token = continuation_token.split(':')
576+
if int(epoch) != self._token_epoch:
577+
raise RuntimeError('continuation token no longer valid')
578+
continuation_token = token
579+
else:
580+
self.initial_read_count += 1
581+
data, next_token = super().get_raw(_state_key, continuation_token)
582+
if next_token is not None:
583+
next_token = '%d:%s' % (self._token_epoch, next_token)
584+
return data, next_token
585+
561586
def test_append_clear_with_preexisting_state(self):
562587
state = beam_fn_api_pb2.StateKey(
563588
bag_user_state=beam_fn_api_pb2.StateKey.BagUserState(
@@ -649,6 +674,89 @@ def clear():
649674
self.assertEqual(get_type(), list)
650675
self.assertEqual(get(), [i for i in range(1000)])
651676

677+
def _make_revokable_handler(self):
678+
underlying_state_handler = self.RevokableStateHandler()
679+
state_cache = statecache.StateCache(100 << 20)
680+
handler = GlobalCachingStateHandler(state_cache, underlying_state_handler)
681+
682+
coder = VarIntCoder()
683+
684+
state = beam_fn_api_pb2.StateKey(
685+
bag_user_state=beam_fn_api_pb2.StateKey.BagUserState(
686+
user_state_id='state1'))
687+
688+
cache_token = beam_fn_api_pb2.ProcessBundleRequest.CacheToken(
689+
token=b'state_token1',
690+
user_state=beam_fn_api_pb2.ProcessBundleRequest.CacheToken.UserState())
691+
692+
underlying_state_handler.set_continuations(True)
693+
underlying_state_handler.set_values([45, 46, 47], coder)
694+
return underlying_state_handler, handler, coder, state, cache_token
695+
696+
def test_failed_continuation_invalidates_cached_iterable(self):
697+
(underlying_state_handler, handler, coder, state,
698+
cache_token) = self._make_revokable_handler()
699+
700+
with handler.process_instruction_id('bundle1', [cache_token]):
701+
iterable = handler.blocking_get(state, coder.get_impl())
702+
self.assertIsInstance(
703+
iterable, GlobalCachingStateHandler.ContinuationIterable)
704+
underlying_state_handler.revoke_outstanding_tokens()
705+
with self.assertRaises(RuntimeError):
706+
list(iterable)
707+
708+
with handler.process_instruction_id('bundle2', [cache_token]):
709+
self.assertEqual([45, 46, 47],
710+
list(handler.blocking_get(state, coder.get_impl())))
711+
712+
def test_failed_continuation_invalidates_after_partial_yield(self):
713+
(underlying_state_handler, handler, coder, state,
714+
cache_token) = self._make_revokable_handler()
715+
716+
with handler.process_instruction_id('bundle1', [cache_token]):
717+
iterator = iter(handler.blocking_get(state, coder.get_impl()))
718+
self.assertEqual(45, next(iterator))
719+
self.assertEqual(46, next(iterator))
720+
underlying_state_handler.revoke_outstanding_tokens()
721+
with self.assertRaises(RuntimeError):
722+
next(iterator)
723+
724+
with handler.process_instruction_id('bundle2', [cache_token]):
725+
self.assertEqual([45, 46, 47],
726+
list(handler.blocking_get(state, coder.get_impl())))
727+
728+
def test_stale_failed_iterable_does_not_evict_replacement(self):
729+
(underlying_state_handler, handler, coder, state,
730+
cache_token) = self._make_revokable_handler()
731+
732+
with handler.process_instruction_id('bundle1', [cache_token]):
733+
stale = handler.blocking_get(state, coder.get_impl())
734+
underlying_state_handler.revoke_outstanding_tokens()
735+
with self.assertRaises(RuntimeError):
736+
list(stale)
737+
738+
with handler.process_instruction_id('bundle2', [cache_token]):
739+
replacement = handler.blocking_get(state, coder.get_impl())
740+
self.assertEqual([45, 46, 47], list(replacement))
741+
# Failing the stale iterable again must not evict the replacement.
742+
with self.assertRaises(RuntimeError):
743+
list(stale)
744+
self.assertIs(replacement, handler.blocking_get(state, coder.get_impl()))
745+
self.assertEqual(2, underlying_state_handler.initial_read_count)
746+
747+
def test_invalidation_failure_does_not_mask_continuation_error(self):
748+
def failing_continuation():
749+
raise RuntimeError('continuation token no longer valid')
750+
751+
def failing_invalidation(_value):
752+
raise ValueError('invalidation failed')
753+
754+
iterable = GlobalCachingStateHandler.ContinuationIterable(
755+
[45, 46], failing_continuation, on_failure=failing_invalidation)
756+
# The continuation error must propagate despite the failing callback.
757+
with self.assertRaises(RuntimeError):
758+
list(iterable)
759+
652760

653761
class ShortIdCacheTest(unittest.TestCase):
654762
def testShortIdAssignment(self):

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,22 @@ 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+
Lets callers that discover a previously returned value is unusable drop
345+
it without evicting a newer value cached under the same key.
346+
"""
347+
assert self.is_cache_enabled()
348+
with self._lock:
349+
weighted_value = self._cache.get(key, None)
350+
if weighted_value is None or _safe_isinstance(weighted_value,
351+
_LoadingValue):
352+
return
353+
if weighted_value.value() is not expected_value:
354+
return
355+
self.invalidate(key)
356+
341357
def invalidate_all(self) -> None:
342358
with self._lock:
343359
self._cache.clear()

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,42 @@ 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+
# Equal but not identical: must not be removed.
166+
cache.invalidate_if_value("key", ['value'])
167+
self.assertEqual(cache.peek("key"), value)
168+
cache.invalidate_if_value("key2", value)
169+
cache.invalidate_if_value("key", value)
170+
self.assertEqual(cache.peek("key"), None)
171+
self.assertEqual(cache.size(), 0)
172+
173+
def test_invalidate_if_value_ignores_in_flight_load(self):
174+
cache = StateCache(5 << 20)
175+
value = 'value'
176+
load_started = threading.Event()
177+
finish_load = threading.Event()
178+
179+
def load(_key):
180+
load_started.set()
181+
finish_load.wait()
182+
return value
183+
184+
result = []
185+
loader = threading.Thread(
186+
target=lambda: result.append(cache.get("key", load)))
187+
loader.start()
188+
load_started.wait()
189+
# Must neither block on nor remove the in-flight load.
190+
cache.invalidate_if_value("key", value)
191+
self.assertEqual(cache.size(), 1)
192+
finish_load.set()
193+
loader.join()
194+
self.assertEqual(result, [value])
195+
self.assertEqual(cache.peek("key"), value)
196+
161197
def test_invalidate_all(self):
162198
cache = StateCache(5 << 20)
163199
cache.put("key", WeightedValue("value", 1 << 20))

0 commit comments

Comments
 (0)