Skip to content

Commit 799609d

Browse files
committed
core: refactor error handling/state transitions
should be way more straightforward now
1 parent 0374478 commit 799609d

2 files changed

Lines changed: 153 additions & 106 deletions

File tree

src/cachew/__init__.py

Lines changed: 127 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@
66
import stat
77
import warnings
88
from collections.abc import Callable, Generator, Iterable, Iterator
9+
from contextlib import AbstractContextManager
910
from dataclasses import dataclass
10-
from enum import Enum, auto
1111
from pathlib import Path
12+
from types import TracebackType
1213
from typing import (
1314
Any,
1415
Literal,
1516
Protocol,
16-
assert_never,
17+
Self,
1718
cast,
1819
overload,
20+
override,
1921
)
2022

2123
try:
@@ -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__ = [

src/cachew/tests/test_cachew.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -926,7 +926,7 @@ def fuzz_cachew_impl():
926926
from .. import cachew_wrapper
927927

928928
patch = '''\
929-
@@ -189,6 +189,11 @@
929+
@@ -76,6 +76,11 @@
930930
old_hash = backend.get_old_hash()
931931
logger.debug(f'old hash: {old_hash}')
932932
@@ -1101,6 +1101,31 @@ def fun() -> Iterator[int]:
11011101
assert calls == 1
11021102

11031103

1104+
def test_write_source_call_error_propagates_without_retry(
1105+
tmp_path: Path,
1106+
restore_settings,
1107+
) -> None:
1108+
"""
1109+
If the wrapped function fails before returning an iterable, cachew must treat it as a source error.
1110+
"""
1111+
settings.THROW_ON_ERROR = False
1112+
1113+
class UserError(Exception):
1114+
pass
1115+
1116+
calls = 0
1117+
1118+
@cachew(tmp_path, cls=int)
1119+
def fun() -> list[int]:
1120+
nonlocal calls
1121+
calls += 1
1122+
raise UserError('boom')
1123+
1124+
with pytest.raises(UserError, match='boom'):
1125+
list(fun())
1126+
assert calls == 1
1127+
1128+
11041129
def test_defensive_read_error_after_yield_raises_cache_read_error(
11051130
tmp_path: Path,
11061131
restore_settings,

0 commit comments

Comments
 (0)