55import logging
66import stat
77import warnings
8- from collections .abc import Callable , Iterable , Iterator
8+ from collections .abc import Callable , Generator , Iterable , Iterator
99from dataclasses import dataclass
10+ from enum import Enum , auto
1011from pathlib import Path
1112from 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]
3739from .backend .common import AbstractBackend
3840from .backend .file import FileBackend
3941from .backend .sqlite import SqliteBackend
40- from .common import DEPENDENCIES , CacheReadError , CachewException , SourceHash
42+ from .common import DEPENDENCIES , CacheReadError , CachewException , CacheWriteError , SourceHash
4143from .logging_helper import make_logger
4244from .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
418439class 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__ = [
0 commit comments