Skip to content

Commit 7067491

Browse files
authored
Merge pull request #95 from walison17/refresh-escape
Add CACHEBACK_VALIDATE_JOB_REFRESH_NEEDED setting to skip already-fresh refreshes
2 parents c44b96e + 947f2ed commit 7067491

4 files changed

Lines changed: 109 additions & 1 deletion

File tree

cacheback/base.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,27 @@ def refresh(self, *args, **kwargs):
314314
self.store(self.key(*args, **kwargs), self.expiry(*args, **kwargs), result)
315315
return result
316316

317+
def should_refresh(self, *args, **kwargs):
318+
"""
319+
Verify if the cache should be refreshed.
320+
321+
When ``CACHEBACK_VALIDATE_JOB_REFRESH_NEEDED`` is enabled, the refresh
322+
is skipped if the cached value is still fresh (i.e. its ``lifetime``
323+
has not yet been exceeded). Otherwise the refresh always runs.
324+
"""
325+
if not getattr(settings, 'CACHEBACK_VALIDATE_JOB_REFRESH_NEEDED', False):
326+
return True
327+
328+
expiry, data = self.cache.get(self.key(*args, **kwargs), (None, None))
329+
if data is None:
330+
return True
331+
332+
delta = expiry - time.time() - self.refresh_timeout
333+
if delta > 0:
334+
return False
335+
336+
return True
337+
317338
def async_refresh(self, *args, **kwargs):
318339
"""
319340
Trigger an asynchronous job to refresh the cache
@@ -481,10 +502,16 @@ def perform_async_refresh(cls, klass_str, obj_args, obj_kwargs, call_args, call_
481502
logger.info(
482503
"Using %s with constructor args %r and kwargs %r", klass_str, obj_args, obj_kwargs
483504
)
505+
506+
job = klass(*obj_args, **obj_kwargs)
507+
if not job.should_refresh(*call_args, **call_kwargs):
508+
logger.info('Refresh escaped, cache is already fresh.')
509+
return
510+
484511
logger.info("Calling refresh with args %r and kwargs %r", call_args, call_kwargs)
485512
start = time.time()
486513
try:
487-
klass(*obj_args, **obj_kwargs).refresh(*call_args, **call_kwargs)
514+
job.refresh(*call_args, **call_kwargs)
488515
except Exception as e:
489516
logger.exception("Error running job: '%s'", e)
490517
else:

docs/settings.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,21 @@ Make sure that the corresponding task queue is configured too.
2828

2929
This specifies whether to ignore the result of the ``refresh_cache`` task
3030
and prevent Celery/RQ from storing it into its results backend.
31+
32+
33+
``CACHEBACK_VALIDATE_JOB_REFRESH_NEEDED``
34+
-----------------------------------------
35+
36+
When enabled, the async refresh task verifies the cached value's expiry
37+
before running and skips the refresh if the cached value still has more
38+
than ``refresh_timeout`` seconds remaining before its expiry. This avoids
39+
redundant work when refresh tasks for the same key pile up in a slow
40+
worker queue: once one of them refreshes the cache, the remaining tasks
41+
short-circuit instead of re-fetching the same data.
42+
43+
The ``refresh_timeout`` margin ensures that tasks are still allowed to
44+
run when the cache is about to expire, so a stale value isn't served
45+
because the next refresh was skipped too eagerly.
46+
47+
Defaults to ``False`` to preserve the previous behavior of always
48+
refreshing when a task is picked up.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ pytest-isort = ">=4.0"
4949
pytest-flake8 = ">=1.3"
5050
flake8 = ">=7"
5151
pytest-black =">=0.6"
52+
black = ">=25.0,<26"
5253
freezegun = ">=1.5"
5354
coverage = {version = ">=7.0", extras = ["toml"]}
5455
celery = ">=5.0"

tests/test_base_job.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,44 @@ def test_should_stale_item_be_fetched_synchronously_reached(self):
199199
def test_should_stale_item_be_fetched_synchronously_not_reached(self):
200200
assert StaleDummyJob().should_stale_item_be_fetched_synchronously(300) is False
201201

202+
def test_should_refresh_setting_disabled(self, settings):
203+
settings.CACHEBACK_VALIDATE_JOB_REFRESH_NEEDED = False
204+
assert DummyJob().should_refresh('foo') is True
205+
206+
def test_should_refresh_cache_empty(self, settings):
207+
settings.CACHEBACK_VALIDATE_JOB_REFRESH_NEEDED = True
208+
assert DummyJob().should_refresh('foo') is True
209+
210+
def test_should_refresh_cache_fresh_with_margin(self, settings):
211+
settings.CACHEBACK_VALIDATE_JOB_REFRESH_NEEDED = True
212+
with freeze_time('2016-03-20 14:00'):
213+
job = DummyJob()
214+
job.refresh('foo')
215+
216+
# lifetime=600, refresh_timeout=60 -> skip while delta > 0
217+
# at +539s remaining time is 61s, which is > refresh_timeout=60s
218+
with freeze_time('2016-03-20 14:08:59'):
219+
assert job.should_refresh('foo') is False
220+
221+
def test_should_refresh_cache_within_refresh_timeout(self, settings):
222+
settings.CACHEBACK_VALIDATE_JOB_REFRESH_NEEDED = True
223+
with freeze_time('2016-03-20 14:00'):
224+
job = DummyJob()
225+
job.refresh('foo')
226+
227+
# at +540s the remaining time equals refresh_timeout -> must refresh
228+
with freeze_time('2016-03-20 14:09:00'):
229+
assert job.should_refresh('foo') is True
230+
231+
def test_should_refresh_cache_expired(self, settings):
232+
settings.CACHEBACK_VALIDATE_JOB_REFRESH_NEEDED = True
233+
with freeze_time('2016-03-20 14:00'):
234+
job = DummyJob()
235+
job.refresh('foo')
236+
237+
with freeze_time('2016-03-20 14:11'):
238+
assert job.should_refresh('foo') is True
239+
202240
def test_key_no_args_no_kwargs(self):
203241
assert DummyJob().key() == 'tests.test_base_job.DummyJob'
204242

@@ -291,3 +329,27 @@ def test_job_refresh_perform_error(self, logger_mock):
291329
def test_job_refresh(self):
292330
Job.perform_async_refresh('tests.test_base_job.EmptyDummyJob', (), {}, ('foo',), {})
293331
assert EmptyDummyJob().get('foo') is not None
332+
333+
@pytest.mark.redis_required
334+
@mock.patch('tests.test_base_job.EmptyDummyJob.fetch', return_value='bar')
335+
@pytest.mark.parametrize(
336+
'validate_refresh_enabled, refresh_count',
337+
[
338+
(True, 1),
339+
(False, 2),
340+
],
341+
)
342+
def test_validate_job_refresh_needed(
343+
self, fetch_mock, rq_burst, settings, validate_refresh_enabled, refresh_count
344+
):
345+
settings.CACHEBACK_VALIDATE_JOB_REFRESH_NEEDED = validate_refresh_enabled
346+
347+
job = EmptyDummyJob()
348+
job.task_options = {'is_async': False}
349+
350+
job.async_refresh('foo')
351+
job.async_refresh('foo')
352+
353+
rq_burst()
354+
355+
assert fetch_mock.call_count == refresh_count

0 commit comments

Comments
 (0)