Skip to content

Commit 9f6aa0a

Browse files
committed
core: fix potential dupes when cache write fails
1 parent 47a6b78 commit 9f6aa0a

3 files changed

Lines changed: 99 additions & 19 deletions

File tree

src/cachew/__init__.py

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import logging
66
import stat
77
import warnings
8-
from collections.abc import Callable, Iterable, Iterator
8+
from collections.abc import Callable, Generator, Iterable, Iterator
99
from dataclasses import dataclass
10+
from enum import Enum, auto
1011
from pathlib import Path
1112
from typing import (
1213
Any,
1314
Literal,
1415
Protocol,
16+
assert_never,
1517
cast,
1618
overload,
1719
)
@@ -37,7 +39,7 @@ def orjson_dumps(*args: Any, **kwargs: Any) -> bytes: # type: ignore[misc]
3739
from .backend.common import AbstractBackend
3840
from .backend.file import FileBackend
3941
from .backend.sqlite import SqliteBackend
40-
from .common import DEPENDENCIES, CacheReadError, CachewException, SourceHash
42+
from .common import DEPENDENCIES, CacheReadError, CachewException, CacheWriteError, SourceHash
4143
from .logging_helper import make_logger
4244
from .marshall.cachew import CachewMarshall
4345

@@ -414,6 +416,25 @@ def composite_hash(self, *args, **kwargs) -> dict[str, Any]:
414416
return hash_parts
415417

416418

419+
class _StreamState(Enum):
420+
"""
421+
Streaming lifecycle for cachew_wrapper.
422+
Allowed transitions:
423+
SETUP -> SOURCE when cachew deliberately yields from the wrapped function without caching.
424+
SETUP -> CACHE_WRITE after cachew gets an exclusive write transaction.
425+
SETUP -> FINISHED after a complete cache hit.
426+
CACHE_WRITE -> SOURCE after a recoverable cache write failure; the same source iterator resumes uncached.
427+
CACHE_WRITE -> FINISHED after the source iterator is exhausted and all items have been yielded.
428+
SOURCE -> FINISHED after uncached streaming completes.
429+
FINISHED is terminal; cleanup errors may be logged or raised, but must not trigger fallback that emits more items.
430+
"""
431+
432+
SETUP = auto()
433+
SOURCE = auto()
434+
CACHE_WRITE = auto()
435+
FINISHED = auto()
436+
437+
417438
@dataclass
418439
class CacheSession[ItemT]:
419440
"""
@@ -446,7 +467,7 @@ def cached_items(self) -> Iterator[ItemT]:
446467
f'failed to read cachew cache ({self.backend_name}:{self.resolved_cache_path}); remove the cache and try again'
447468
) from e
448469

449-
def write_to_cache(self, datas: Iterable[ItemT]) -> Iterator[ItemT]:
470+
def write_items_to_cache(self, datas: Iterable[ItemT]) -> Generator[ItemT, None, int]:
450471
if isinstance(self.backend, FileBackend):
451472
# FIXME uhhh.. this is a bit crap
452473
# but in sqlite mode we don't want to publish new hash before we write new items
@@ -471,13 +492,19 @@ def flush() -> None:
471492
total_objects += 1
472493
yield obj
473494

474-
dct = self.marshall.dump(obj)
475-
blob = orjson_dumps(dct)
495+
try:
496+
dct = self.marshall.dump(obj)
497+
blob = orjson_dumps(dct)
498+
except Exception as e:
499+
msg = f'failed to write cachew cache ({self.backend_name}:{self.resolved_cache_path})'
500+
raise CacheWriteError(msg) from e
476501
chunk.append(blob)
477502
if len(chunk) >= self.chunk_by:
478503
flush()
479504
flush()
505+
return total_objects
480506

507+
def finalize_cache(self, *, total_objects: int) -> None:
481508
self.backend.finalize(self.new_hash)
482509
self.logger.info(
483510
f'wrote {total_objects} objects to cachew ({self.backend_name}:{self.resolved_cache_path})'
@@ -522,8 +549,7 @@ def cachew_wrapper[**P, ItemT](
522549
# but it lets us save a function call, hence a stack frame
523550
# see test_recursive*
524551
early_exit = False
525-
running_uncached = False
526-
served_from_cache = False
552+
stream_state = _StreamState.SETUP
527553
try:
528554
BackendCls = BACKENDS[C.backend]
529555

@@ -550,7 +576,7 @@ def cachew_wrapper[**P, ItemT](
550576
if new_hash == old_hash:
551577
logger.debug('hash matched: loading from cache')
552578
yield from session.cached_items()
553-
served_from_cache = True
579+
stream_state = _StreamState.FINISHED
554580
return
555581

556582
logger.debug('hash mismatch: computing data and writing to db')
@@ -560,9 +586,9 @@ def cachew_wrapper[**P, ItemT](
560586
# NOTE: this is the bit we really have to watch out for and not put in a helper function
561587
# otherwise it's causing an extra stack frame on every call
562588
# the rest (reading from cachew or writing to cachew) happens once per function call? so not a huge deal
563-
running_uncached = True
589+
stream_state = _StreamState.SOURCE
564590
yield from func(*args, **kwargs)
565-
running_uncached = False
591+
stream_state = _StreamState.FINISHED
566592
return
567593

568594
if synthetic_key is not None:
@@ -576,35 +602,54 @@ def cachew_wrapper[**P, ItemT](
576602
kwargs[synthetic_key] = missing_synthetic_values # ty: ignore[invalid-assignment]
577603

578604
# at this point we're guaranteed to have an exclusive write transaction
605+
fit = iter(func(*args, **kwargs))
579606
try:
580-
yield from session.write_to_cache(func(*args, **kwargs))
607+
stream_state = _StreamState.CACHE_WRITE
608+
total_objects = yield from session.write_items_to_cache(fit)
581609
except GeneratorExit:
582610
# GeneratorExit itself is not caught below, but SQLAlchemy cleanup during interpreter shutdown can raise a normal Exception while unwinding.
583611
early_exit = True
584612
raise
613+
except CacheWriteError as e:
614+
# If there is an error during marshalling, etc, we can't just reemit func(*args, **kwargs), we might end up with dupes
615+
# so we try to switch back to the original iterator (fit) -- note it's reused/mutated iin write_items_to_cache
616+
cachew_error(e, logger=logger)
617+
stream_state = _StreamState.SOURCE
618+
yield from fit
619+
stream_state = _StreamState.FINISHED
620+
return
621+
stream_state = _StreamState.FINISHED
622+
session.finalize_cache(total_objects=total_objects)
585623
except CacheReadError:
586624
# Cache read failures bypass THROW_ON_ERROR because fallback can duplicate already-yielded cached items.
587625
# This can be thrown from session.cached_items()
588626
raise
589627
except Exception as e:
590-
if running_uncached:
628+
if stream_state is _StreamState.SOURCE:
629+
# SOURCE means the wrapped function is already streaming uncached, so its exceptions must propagate unchanged.
591630
raise
592631

593632
# Work around known SQLAlchemy/sqlite shutdown noise; do not suppress other cleanup errors.
594633
# See test_early_exit_shutdown.
595634
if early_exit and 'Cannot operate on a closed database' in str(e):
596635
return
597636

637+
if stream_state is _StreamState.CACHE_WRITE:
638+
# CACHE_WRITE may have already yielded source items, so fallback could duplicate emitted output.
639+
raise
640+
598641
cachew_error(e, logger=logger)
599642

600-
if served_from_cache:
601-
# this can happen if we fully read from the cache, but hit some error while shutting backend down
602-
# - we're past reading, so we emitted all items user wanted from cache
603-
# - we don't want to yield any items from original func
604-
# so it's safe to simply return
643+
if stream_state is _StreamState.FINISHED:
644+
# FINISHED means all requested items were emitted; cachew_error handles THROW_ON_ERROR, but fallback must not emit more data.
605645
return
606646

607-
yield from func(*args, **kwargs)
647+
if stream_state is _StreamState.SETUP:
648+
# SETUP means no user-visible items have been yielded, so fallback is safe.
649+
yield from func(*args, **kwargs)
650+
return
651+
652+
assert_never(stream_state)
608653

609654

610655
__all__ = [

src/cachew/common.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ class CacheReadError(CachewException):
2020
pass
2121

2222

23+
class CacheWriteError(CachewException):
24+
"""
25+
Internal signal for defensive cache write fallback.
26+
"""
27+
28+
pass
29+
30+
2331
@dataclass
2432
class TypeNotSupported(CachewException):
2533
type_: type

src/cachew/tests/test_cachew.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1046,7 +1046,6 @@ def orig2():
10461046
assert list(fun()) == [123]
10471047

10481048

1049-
@pytest.mark.xfail(reason='cache write errors after yielding currently restart the source iterator', strict=True)
10501049
def test_defensive_write_error_after_yield_does_not_duplicate(
10511050
tmp_path: Path,
10521051
restore_settings,
@@ -1074,6 +1073,34 @@ def fun() -> Iterator[BB]:
10741073
assert calls == 1
10751074

10761075

1076+
def test_write_source_error_after_yield_propagates_without_retry(
1077+
tmp_path: Path,
1078+
restore_settings,
1079+
) -> None:
1080+
"""
1081+
If the wrapped iterator fails while cachew is writing, the source error must not trigger defensive retry.
1082+
"""
1083+
settings.THROW_ON_ERROR = False
1084+
1085+
class UserError(Exception):
1086+
pass
1087+
1088+
calls = 0
1089+
1090+
@cachew(tmp_path)
1091+
def fun() -> Iterator[int]:
1092+
nonlocal calls
1093+
calls += 1
1094+
yield 1
1095+
raise UserError('boom')
1096+
1097+
it = iter(fun())
1098+
assert next(it) == 1
1099+
with pytest.raises(UserError, match='boom'):
1100+
next(it)
1101+
assert calls == 1
1102+
1103+
10771104
def test_defensive_read_error_after_yield_raises_cache_read_error(
10781105
tmp_path: Path,
10791106
restore_settings,

0 commit comments

Comments
 (0)