Skip to content

add return_stale_on_timeout parameter for stale-while-revalidate caching #297

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions demo_return_stale_on_timeout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Demonstration of the new return_stale_on_timeout feature."""

import time
import threading
from datetime import timedelta

import cachier


def demo_return_stale_on_timeout():
"""Demonstrate the return_stale_on_timeout feature."""

print("🎯 Cachier return_stale_on_timeout Feature Demo")
print("=" * 50)

@cachier.cachier(
backend="memory",
stale_after=timedelta(seconds=2), # Fresh for 2 seconds
wait_for_calc_timeout=3, # Wait up to 3 seconds for calculation
return_stale_on_timeout=True, # Return stale value if timeout
next_time=False, # Don't return stale immediately
)
def expensive_api_call(query):
"""Simulate an expensive API call that takes 5 seconds."""
print(f" 🔄 Making expensive API call for '{query}'...")
time.sleep(5) # Simulates network request
return f"Result for {query}: {len(query)} chars"

expensive_api_call.clear_cache()

# 1. First call - will cache the result
print("\n1️⃣ First call (cold cache):")
result1 = expensive_api_call("hello world")
print(f" ✅ Got: {result1}")

# 2. Second call while fresh - returns cached result immediately
print("\n2️⃣ Second call (fresh cache):")
start_time = time.time()
result2 = expensive_api_call("hello world")
elapsed = time.time() - start_time
print(f" ✅ Got: {result2} (took {elapsed:.2f}s)")

# 3. Wait for cache to become stale
print("\n⏰ Waiting for cache to become stale (2+ seconds)...")
time.sleep(2.5)

# 4. Start a background calculation
print("\n3️⃣ Starting background calculation...")
def background_refresh():
expensive_api_call("hello world")

thread = threading.Thread(target=background_refresh)
thread.start()
time.sleep(0.5) # Let background thread start

# 5. This call will wait up to 3 seconds, then return stale value
print("\n4️⃣ Main call (should return stale value after 3s timeout):")
start_time = time.time()
result3 = expensive_api_call("hello world")
elapsed = time.time() - start_time
print(f" ✅ Got: {result3} (took {elapsed:.2f}s)")

if elapsed < 4:
print(" 🎉 SUCCESS! Returned stale value instead of waiting 5 seconds!")
else:
print(" ❌ Something went wrong - took too long")

# Wait for background thread to complete
thread.join()

print("\n📋 Summary:")
print(" • Fresh values returned immediately")
print(" • Stale values trigger background refresh")
print(" • If refresh takes too long, return stale value")
print(" • This keeps your application responsive!")


def demo_comparison():
"""Compare with and without return_stale_on_timeout."""

print("\n\n🔄 Comparison Demo")
print("=" * 50)

# Without return_stale_on_timeout (default behavior)
@cachier.cachier(
backend="memory",
stale_after=timedelta(seconds=1),
wait_for_calc_timeout=2,
return_stale_on_timeout=False, # Default
)
def slow_func_old(x):
time.sleep(3)
return x * 2

# With return_stale_on_timeout
@cachier.cachier(
backend="memory",
stale_after=timedelta(seconds=1),
wait_for_calc_timeout=2,
return_stale_on_timeout=True, # New feature
)
def slow_func_new(x):
time.sleep(3)
return x * 2

slow_func_old.clear_cache()
slow_func_new.clear_cache()

# Cache initial values
print("Caching initial values...")
slow_func_old(10)
slow_func_new(10)

# Wait for stale
time.sleep(1.5)

# Start background calculations
def bg_old():
slow_func_old(10)
def bg_new():
slow_func_new(10)

threading.Thread(target=bg_old).start()
threading.Thread(target=bg_new).start()
time.sleep(0.5)

print("\nTesting behavior when calculation times out:")

# Test old behavior
print("📊 OLD behavior (return_stale_on_timeout=False):")
start = time.time()
result_old = slow_func_old(10) # Will wait, then start new calculation
elapsed_old = time.time() - start
print(f" Result: {result_old}, Time: {elapsed_old:.2f}s")

time.sleep(0.5) # Brief pause

# Test new behavior
print("🆕 NEW behavior (return_stale_on_timeout=True):")
start = time.time()
result_new = slow_func_new(10) # Will return stale value after timeout
elapsed_new = time.time() - start
print(f" Result: {result_new}, Time: {elapsed_new:.2f}s")

print(f"\n🏆 Time saved: {elapsed_old - elapsed_new:.2f} seconds!")


if __name__ == "__main__":
demo_return_stale_on_timeout()
demo_comparison()
8 changes: 5 additions & 3 deletions src/cachier/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class Params:
pickle_reload: bool = True
separate_files: bool = False
wait_for_calc_timeout: int = 0
return_stale_on_timeout: bool = False
allow_none: bool = False
cleanup_stale: bool = False
cleanup_interval: timedelta = timedelta(days=1)
Expand Down Expand Up @@ -118,9 +119,10 @@ def set_global_params(**params: Any) -> None:
Parameters given directly to a decorator take precedence over any values
set by this function.

Only 'stale_after', 'next_time', and 'wait_for_calc_timeout' can be changed
after the memoization decorator has been applied. Other parameters will
only have an effect on decorators applied after this function is run.
Only 'stale_after', 'next_time', 'wait_for_calc_timeout', and
'return_stale_on_timeout' can be changed after the memoization decorator
has been applied. Other parameters will only have an effect on decorators
applied after this function is run.

"""
import cachier
Expand Down
22 changes: 20 additions & 2 deletions src/cachier/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def cachier(
pickle_reload: Optional[bool] = None,
separate_files: Optional[bool] = None,
wait_for_calc_timeout: Optional[int] = None,
return_stale_on_timeout: Optional[bool] = None,
allow_none: Optional[bool] = None,
cleanup_stale: Optional[bool] = None,
cleanup_interval: Optional[timedelta] = None,
Expand Down Expand Up @@ -177,12 +178,16 @@ def cachier(
Instead of a single cache file per-function, each function's cache is
split between several files, one for each argument set. This can help
if you per-function cache files become too large.
wait_for_calc_timeout: int, optional, for MongoDB only
wait_for_calc_timeout: int, optional
The maximum time to wait for an ongoing calculation. When a
process started to calculate the value setting being_calculated to
True, any process trying to read the same entry will wait a maximum of
seconds specified in this parameter. 0 means wait forever.
Once the timeout expires the calculation will be triggered.
return_stale_on_timeout: bool, optional
If True, when wait_for_calc_timeout expires, return the existing stale
value instead of triggering a new calculation. Only applies when there
is a stale value available. Defaults to False.
allow_none: bool, optional
Allows storing None values in the cache. If False, functions returning
None will not be cached and are recalculated every call.
Expand Down Expand Up @@ -215,28 +220,32 @@ def cachier(
cache_dir=cache_dir,
separate_files=separate_files,
wait_for_calc_timeout=wait_for_calc_timeout,
return_stale_on_timeout=return_stale_on_timeout,
)
elif backend == "mongo":
core = _MongoCore(
hash_func=hash_func,
mongetter=mongetter,
wait_for_calc_timeout=wait_for_calc_timeout,
return_stale_on_timeout=return_stale_on_timeout,
)
elif backend == "memory":
core = _MemoryCore(
hash_func=hash_func, wait_for_calc_timeout=wait_for_calc_timeout
hash_func=hash_func, wait_for_calc_timeout=wait_for_calc_timeout, return_stale_on_timeout=return_stale_on_timeout
)
elif backend == "sql":
core = _SQLCore(
hash_func=hash_func,
sql_engine=sql_engine,
wait_for_calc_timeout=wait_for_calc_timeout,
return_stale_on_timeout=return_stale_on_timeout,
)
elif backend == "redis":
core = _RedisCore(
hash_func=hash_func,
redis_client=redis_client,
wait_for_calc_timeout=wait_for_calc_timeout,
return_stale_on_timeout=return_stale_on_timeout,
)
else:
raise ValueError("specified an invalid core: %s" % backend)
Expand Down Expand Up @@ -291,6 +300,9 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds):
stale_after, "stale_after", kwds
)
_next_time = _update_with_defaults(next_time, "next_time", kwds)
_return_stale_on_timeout = _update_with_defaults(
return_stale_on_timeout, "return_stale_on_timeout", kwds
)
_cleanup_flag = _update_with_defaults(
cleanup_stale, "cleanup_stale", kwds
)
Expand Down Expand Up @@ -362,6 +374,9 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds):
try:
return core.wait_on_entry_calc(key)
except RecalculationNeeded:
if _return_stale_on_timeout and entry and entry.value is not None:
_print("Timeout reached, returning stale value.")
return entry.value
return _calc_entry(core, key, func, args, kwds)
if _next_time:
_print("Async calc and return stale")
Expand All @@ -380,6 +395,9 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds):
try:
return core.wait_on_entry_calc(key)
except RecalculationNeeded:
if _return_stale_on_timeout and entry and entry.value is not None:
_print("Timeout reached, returning stale value.")
return entry.value
return _calc_entry(core, key, func, args, kwds)
_print("No entry found. No current calc. Calling like a boss.")
return _calc_entry(core, key, func, args, kwds)
Expand Down
2 changes: 2 additions & 0 deletions src/cachier/cores/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ def __init__(
self,
hash_func: Optional[HashFunc],
wait_for_calc_timeout: Optional[int],
return_stale_on_timeout: Optional[bool] = None,
):
self.hash_func = _update_with_defaults(hash_func, "hash_func")
self.wait_for_calc_timeout = wait_for_calc_timeout
self.return_stale_on_timeout = return_stale_on_timeout
self.lock = threading.RLock()

def set_func(self, func):
Expand Down
26 changes: 21 additions & 5 deletions src/cachier/cores/memory.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""A memory-based caching core for cachier."""

import threading
import time
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Tuple

Expand All @@ -16,8 +17,9 @@ def __init__(
self,
hash_func: Optional[HashFunc],
wait_for_calc_timeout: Optional[int],
return_stale_on_timeout: Optional[bool] = None,
):
super().__init__(hash_func, wait_for_calc_timeout)
super().__init__(hash_func, wait_for_calc_timeout, return_stale_on_timeout)
self.cache: Dict[str, CacheEntry] = {}

def _hash_func_key(self, key: str) -> str:
Expand Down Expand Up @@ -89,10 +91,24 @@ def wait_on_entry_calc(self, key: str) -> Any:
return entry.value
if entry._condition is None:
raise RuntimeError("No condition set for entry")
entry._condition.acquire()
entry._condition.wait()
entry._condition.release()
return self.cache[hash_key].value

# Wait with timeout checking similar to other cores
time_spent = 0
while True:
entry._condition.acquire()
# Wait for 1 second at a time to allow timeout checking
signaled = entry._condition.wait(timeout=1.0)
entry._condition.release()

# Check if the calculation completed
with self.lock:
if hash_key in self.cache and not self.cache[hash_key]._processing:
return self.cache[hash_key].value

# If we weren't signaled and the entry is still processing, check timeout
if not signaled:
time_spent += 1
self.check_calc_timeout(time_spent)

def clear_cache(self) -> None:
with self.lock:
Expand Down
3 changes: 2 additions & 1 deletion src/cachier/cores/mongo.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def __init__(
hash_func: Optional[HashFunc],
mongetter: Optional[Mongetter],
wait_for_calc_timeout: Optional[int],
return_stale_on_timeout: Optional[bool] = None,
):
if "pymongo" not in sys.modules:
warnings.warn(
Expand All @@ -49,7 +50,7 @@ def __init__(
) # pragma: no cover

super().__init__(
hash_func=hash_func, wait_for_calc_timeout=wait_for_calc_timeout
hash_func=hash_func, wait_for_calc_timeout=wait_for_calc_timeout, return_stale_on_timeout=return_stale_on_timeout
)
if mongetter is None:
raise MissingMongetter(
Expand Down
3 changes: 2 additions & 1 deletion src/cachier/cores/pickle.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ def __init__(
cache_dir: Optional[Union[str, os.PathLike]],
separate_files: Optional[bool],
wait_for_calc_timeout: Optional[int],
return_stale_on_timeout: Optional[bool] = None,
):
super().__init__(hash_func, wait_for_calc_timeout)
super().__init__(hash_func, wait_for_calc_timeout, return_stale_on_timeout)
self._cache_dict: Dict[str, CacheEntry] = {}
self.reload = _update_with_defaults(pickle_reload, "pickle_reload")
self.cache_dir = os.path.expanduser(
Expand Down
3 changes: 2 additions & 1 deletion src/cachier/cores/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def __init__(
Union["redis.Redis", Callable[[], "redis.Redis"]]
],
wait_for_calc_timeout: Optional[int] = None,
return_stale_on_timeout: Optional[bool] = None,
key_prefix: str = "cachier",
):
if not REDIS_AVAILABLE:
Expand All @@ -45,7 +46,7 @@ def __init__(
)

super().__init__(
hash_func=hash_func, wait_for_calc_timeout=wait_for_calc_timeout
hash_func=hash_func, wait_for_calc_timeout=wait_for_calc_timeout, return_stale_on_timeout=return_stale_on_timeout
)
if redis_client is None:
raise MissingRedisClient(
Expand Down
3 changes: 2 additions & 1 deletion src/cachier/cores/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,15 @@ def __init__(
hash_func: Optional[HashFunc],
sql_engine: Optional[Union[str, "Engine", Callable[[], "Engine"]]],
wait_for_calc_timeout: Optional[int] = None,
return_stale_on_timeout: Optional[bool] = None,
):
if not SQLALCHEMY_AVAILABLE:
raise ImportError(
"SQLAlchemy is required for the SQL core. "
"Install with `pip install SQLAlchemy`."
)
super().__init__(
hash_func=hash_func, wait_for_calc_timeout=wait_for_calc_timeout
hash_func=hash_func, wait_for_calc_timeout=wait_for_calc_timeout, return_stale_on_timeout=return_stale_on_timeout
)
self._engine = self._resolve_engine(sql_engine)
self._Session = sessionmaker(bind=self._engine)
Expand Down
Loading
Loading