66import stat
77import warnings
88from collections .abc import Callable , Generator , Iterable , Iterator
9+ from contextlib import AbstractContextManager
910from dataclasses import dataclass
10- from enum import Enum , auto
1111from pathlib import Path
12+ from types import TracebackType
1213from typing import (
1314 Any ,
1415 Literal ,
1516 Protocol ,
16- assert_never ,
17+ Self ,
1718 cast ,
1819 overload ,
20+ override ,
1921)
2022
2123try :
@@ -416,30 +418,20 @@ def composite_hash(self, *args, **kwargs) -> dict[str, Any]:
416418 return hash_parts
417419
418420
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 ()
421+ type _SessionPhase = Literal ['setup' , 'streaming' , 'completed' ]
436422
437423
438424@dataclass
439- class CacheSession [ItemT ]:
425+ class CacheSession [ItemT ]( AbstractContextManager ) :
440426 """
441- Per-call state for an open backend transaction.
442- This keeps read/write generator helpers out of cachew_wrapper while preserving the direct recursive fallback path.
427+ Per-call cache/backend lifetime.
428+
429+ Allowed phase transitions:
430+ setup -> streaming when cachew starts yielding cached or source items.
431+ streaming -> completed after all requested items have been yielded.
432+ streaming -> streaming is allowed because synthetic cache reads can happen after source streaming has already started.
433+ completed is terminal; cleanup/finalize errors may be logged or raised, but must never trigger fallback.
434+ setup is the only phase where wrapper-level fallback is safe because no user-visible items have been yielded.
443435 """
444436
445437 backend : AbstractBackend
@@ -449,6 +441,46 @@ class CacheSession[ItemT]:
449441 new_hash : SourceHash
450442 chunk_by : int
451443 logger : logging .Logger
444+ phase : _SessionPhase
445+
446+ @override
447+ def __enter__ (self ) -> Self :
448+ self .backend .__enter__ ()
449+ return self
450+
451+ @override
452+ def __exit__ (
453+ self ,
454+ exc_type : type [BaseException ] | None ,
455+ exc : BaseException | None ,
456+ tb : TracebackType | None ,
457+ ) -> bool | None :
458+ try :
459+ self .backend .__exit__ (exc_type , exc , tb )
460+ except Exception as e :
461+ # Work around known SQLAlchemy/sqlite shutdown noise; do not suppress other cleanup errors.
462+ # See test_early_exit_shutdown.
463+ if exc_type is GeneratorExit and 'Cannot operate on a closed database' in str (e ):
464+ # Swallow only the cleanup noise; returning None lets the original GeneratorExit keep closing the generator.
465+ return None
466+ raise
467+ return None
468+
469+ def mark_streaming (self ) -> None :
470+ assert self .phase in ('setup' , 'streaming' ), self .phase
471+ self .phase = 'streaming'
472+
473+ def mark_completed (self ) -> None :
474+ assert self .phase == 'streaming' , self .phase
475+ self .phase = 'completed'
476+
477+ @property
478+ def fallback_allowed (self ) -> bool :
479+ return self .phase == 'setup'
480+
481+ @property
482+ def completed (self ) -> bool :
483+ return self .phase == 'completed'
452484
453485 def cached_items (self ) -> Iterator [ItemT ]:
454486 total_cached = self .backend .cached_blobs_total ()
@@ -457,25 +489,40 @@ def cached_items(self) -> Iterator[ItemT]:
457489 f'loading { total_cached_s } objects from cachew ({ self .backend_name } :{ self .resolved_cache_path } )'
458490 )
459491
492+ self .mark_streaming ()
460493 try :
461494 for blob in self .backend .cached_blobs ():
462495 j = orjson_loads (blob )
463496 obj = self .marshall .load (j )
464497 yield obj
465498 except Exception as e :
499+ # Preserve the original exception as __cause__, but surface an actionable cachew-level error.
466500 raise CacheReadError (
467501 f'failed to read cachew cache ({ self .backend_name } :{ self .resolved_cache_path } ); remove the cache and try again'
468502 ) from e
469503
470- def write_items_to_cache (self , datas : Iterable [ItemT ]) -> Generator [ItemT , None , int ]:
471- if isinstance (self .backend , FileBackend ):
472- # FIXME uhhh.. this is a bit crap
473- # but in sqlite mode we don't want to publish new hash before we write new items
474- # maybe should use tmp table for hashes as well?
475- self .backend .write_new_hash (self .new_hash )
476- else :
477- # happens later for sqlite
478- pass
504+ def write_items_to_cache (self , datas : Iterable [ItemT ]) -> Generator [ItemT , None , int | None ]:
505+ data_iter = iter (datas )
506+
507+ def cache_write_error (e : Exception ) -> None :
508+ msg = f'failed to write cachew cache ({ self .backend_name } :{ self .resolved_cache_path } )'
509+ cache_error = CacheWriteError (msg )
510+ cache_error .__cause__ = e
511+ cachew_error (cache_error , logger = self .logger )
512+
513+ try :
514+ if isinstance (self .backend , FileBackend ):
515+ # FIXME uhhh.. this is a bit crap
516+ # but in sqlite mode we don't want to publish new hash before we write new items
517+ # maybe should use tmp table for hashes as well?
518+ self .backend .write_new_hash (self .new_hash )
519+ else :
520+ # happens later for sqlite
521+ pass
522+ except Exception as e :
523+ cache_write_error (e )
524+ yield from data_iter
525+ return None
479526
480527 flush_blobs = self .backend .flush_blobs
481528
@@ -488,20 +535,25 @@ def flush() -> None:
488535 chunk = []
489536
490537 total_objects = 0
491- for obj in datas :
538+ for obj in data_iter :
492539 total_objects += 1
493540 yield obj
494541
495542 try :
496543 dct = self .marshall .dump (obj )
497544 blob = orjson_dumps (dct )
545+ chunk .append (blob )
546+ if len (chunk ) >= self .chunk_by :
547+ flush ()
498548 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
501- chunk .append (blob )
502- if len (chunk ) >= self .chunk_by :
503- flush ()
504- flush ()
549+ cache_write_error (e )
550+ yield from data_iter
551+ return None
552+ try :
553+ flush ()
554+ except Exception as e :
555+ cache_write_error (e )
556+ return None
505557 return total_objects
506558
507559 def finalize_cache (self , * , total_objects : int ) -> None :
@@ -545,111 +597,81 @@ def cachew_wrapper[**P, ItemT](
545597
546598 synthetic_key = C .synthetic_key
547599
548- # NOTE: annoyingly huge try/catch ahead...
549- # but it lets us save a function call, hence a stack frame
550- # see test_recursive*
551- early_exit = False
552- stream_state = _StreamState .SETUP
600+ session : CacheSession [ItemT ] | None = None
553601 try :
554602 BackendCls = BACKENDS [C .backend ]
555-
556603 new_hash_d = C .composite_hash (* args , ** kwargs )
557604 new_hash : SourceHash = json .dumps (new_hash_d )
558605 logger .debug (f'new hash: { new_hash } ' )
559-
560606 marshall : CachewMarshall [ItemT ] = CachewMarshall (Type_ = C .cls_ )
561607
562- with BackendCls (cache_path = resolved_cache_path , logger = logger ) as backend :
563- session = CacheSession (
564- backend = backend ,
565- backend_name = C .backend ,
566- resolved_cache_path = resolved_cache_path ,
567- marshall = marshall ,
568- new_hash = new_hash ,
569- chunk_by = C .chunk_by ,
570- logger = logger ,
571- )
608+ backend = BackendCls (cache_path = resolved_cache_path , logger = logger )
609+ session = CacheSession (
610+ backend = backend ,
611+ backend_name = C .backend ,
612+ resolved_cache_path = resolved_cache_path ,
613+ marshall = marshall ,
614+ new_hash = new_hash ,
615+ chunk_by = C .chunk_by ,
616+ logger = logger ,
617+ phase = 'setup' ,
618+ )
572619
620+ with session :
573621 old_hash = backend .get_old_hash ()
574622 logger .debug (f'old hash: { old_hash } ' )
575623
576624 if new_hash == old_hash :
577625 logger .debug ('hash matched: loading from cache' )
578626 yield from session .cached_items ()
579- stream_state = _StreamState . FINISHED
627+ session . mark_completed ()
580628 return
581629
582630 logger .debug ('hash mismatch: computing data and writing to db' )
583631
584632 got_write = backend .get_exclusive_write ()
585633 if not got_write :
586- # NOTE: this is the bit we really have to watch out for and not put in a helper function
587- # otherwise it's causing an extra stack frame on every call
588- # the rest (reading from cachew or writing to cachew) happens once per function call? so not a huge deal
589- stream_state = _StreamState .SOURCE
634+ # NOTE: this is the bit we really have to watch out for and not put in a helper function.
635+ # Otherwise it's causing an extra stack frame on every recursive call.
636+ session .mark_streaming ()
590637 yield from func (* args , ** kwargs )
591- stream_state = _StreamState . FINISHED
638+ session . mark_completed ()
592639 return
593640
641+ source_kwargs : Any = kwargs
594642 if synthetic_key is not None :
595643 missing_synthetic_values = _synthetic .missing_synthetic_key_values_for_hashes (
596644 old_hash = old_hash ,
597645 new_hash_d = new_hash_d ,
598646 )
599647 if missing_synthetic_values is not None :
600- # can reuse cache
601- kwargs [_synthetic .CACHEW_CACHED ] = session .cached_items () # ty: ignore[invalid-assignment]
602- kwargs [synthetic_key ] = missing_synthetic_values # ty: ignore[invalid-assignment]
648+ synthetic_kwargs = dict (kwargs )
649+ synthetic_kwargs [_synthetic .CACHEW_CACHED ] = session .cached_items ()
650+ synthetic_kwargs [synthetic_key ] = missing_synthetic_values
651+ source_kwargs = synthetic_kwargs
603652
604- # at this point we're guaranteed to have an exclusive write transaction
605- fit = iter (func (* args , ** kwargs ))
606- try :
607- stream_state = _StreamState .CACHE_WRITE
608- total_objects = yield from session .write_items_to_cache (fit )
609- except GeneratorExit :
610- # GeneratorExit itself is not caught below, but SQLAlchemy cleanup during interpreter shutdown can raise a normal Exception while unwinding.
611- early_exit = True
612- 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
653+ session .mark_streaming ()
654+ fit = iter (func (* args , ** source_kwargs ))
655+ total_objects = yield from session .write_items_to_cache (fit )
656+ session .mark_completed ()
657+
658+ if total_objects is None :
620659 return
621- stream_state = _StreamState . FINISHED
660+
622661 session .finalize_cache (total_objects = total_objects )
623- except CacheReadError :
624- # Cache read failures bypass THROW_ON_ERROR because fallback can duplicate already-yielded cached items.
625- # This can be thrown from session.cached_items()
626- raise
627662 except Exception as e :
628- if stream_state is _StreamState .SOURCE :
629- # SOURCE means the wrapped function is already streaming uncached, so its exceptions must propagate unchanged.
630- raise
631-
632- # Work around known SQLAlchemy/sqlite shutdown noise; do not suppress other cleanup errors.
633- # See test_early_exit_shutdown.
634- if early_exit and 'Cannot operate on a closed database' in str (e ):
635- return
636-
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-
641- cachew_error (e , logger = logger )
642-
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.
663+ if session is not None and session .completed :
664+ # All requested items were emitted, so only report the cache cleanup/finalize error.
665+ cachew_error (e , logger = logger )
645666 return
646667
647- if stream_state is _StreamState .SETUP :
648- # SETUP means no user-visible items have been yielded, so fallback is safe.
668+ if session is None or session .fallback_allowed :
669+ # No user-visible items were emitted yet, so a full uncached fallback cannot duplicate output.
670+ cachew_error (e , logger = logger )
649671 yield from func (* args , ** kwargs )
650672 return
651673
652- assert_never ( stream_state )
674+ raise
653675
654676
655677__all__ = [
0 commit comments