Skip to content

Commit 0b720c9

Browse files
Allow to override lock duration (by allowing additional configurability) (#36)
* Allow an UpdateStatuses instance to be passed in to wrapper The `UpdateStatuses` class parameterizes lock duration; however, there is currently no way for this parameter to be set. Additionally, debugging or instrumentation may call for inspecting state of tasks responsible for updating cache keys; an externally-instantiated `UpdateStatuses` instance makes this more feasible. * Preserve update_status_tracker through partial call * * Reworked ability to configure `UpdateStatuses` (for instance to update lock duration) * Split default implementation into an interface and a default implementation * Added docs & updated version --------- Co-authored-by: Charles Duffy <[email protected]> Co-authored-by: Michał Żmuda <[email protected]>
1 parent 3ccc9bb commit 0b720c9

File tree

7 files changed

+64
-24
lines changed

7 files changed

+64
-24
lines changed

CHANGELOG.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
3.1.0
2+
-----
3+
4+
* Added ability to configure `UpdateStatuses` (for instance to update lock duration)
5+
* Split default implementation into an interface and a default implementation
6+
17
3.0.0
28
-----
39

README.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ Example how to customize default config (everything gets overridden):
160160
.set_eviction_strategy(LeastRecentlyUpdatedEvictionStrategy(capacity=2048))
161161
.set_key_extractor(EncodedMethodNameAndArgsKeyExtractor(skip_first_arg_as_self=False))
162162
.set_storage(LocalInMemoryCacheStorage())
163-
.set_postprocessing(DeepcopyPostprocessing())
163+
.set_postprocessing(DeepcopyPostprocessing()),
164+
update_statuses=InMemoryLocks(update_lock_timeout=timedelta(minutes=5))
164165
)
165166
async def cached():
166167
return 'dummy'

examples/configuration/custom_configuration.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from memoize.eviction import LeastRecentlyUpdatedEvictionStrategy
66
from memoize.key import EncodedMethodNameAndArgsKeyExtractor
77
from memoize.postprocessing import DeepcopyPostprocessing
8+
from memoize.statuses import InMemoryLocks
89
from memoize.storage import LocalInMemoryCacheStorage
910
from memoize.wrapper import memoize
1011

@@ -18,7 +19,8 @@
1819
.set_eviction_strategy(LeastRecentlyUpdatedEvictionStrategy(capacity=2048))
1920
.set_key_extractor(EncodedMethodNameAndArgsKeyExtractor(skip_first_arg_as_self=False))
2021
.set_storage(LocalInMemoryCacheStorage())
21-
.set_postprocessing(DeepcopyPostprocessing())
22+
.set_postprocessing(DeepcopyPostprocessing()),
23+
update_statuses=InMemoryLocks(update_lock_timeout=timedelta(minutes=5))
2224
)
2325
async def cached():
2426
return 'dummy'

memoize/statuses.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,61 @@
11
"""
2-
[Internal use only] Encapsulates update state management.
2+
[API] Encapsulates update state management.
33
"""
44
import asyncio
55
import datetime
66
import logging
7+
from abc import ABCMeta, abstractmethod
78
from asyncio import Future
89
from typing import Dict, Awaitable, Union
910

1011
from memoize.entry import CacheKey, CacheEntry
1112

1213

13-
class UpdateStatuses:
14+
class UpdateStatuses(metaclass=ABCMeta):
15+
@abstractmethod
16+
def is_being_updated(self, key: CacheKey) -> bool:
17+
"""Checks if update for given key is in progress. Obtained info is valid until control gets back to IO-loop."""
18+
raise NotImplementedError()
19+
20+
@abstractmethod
21+
def mark_being_updated(self, key: CacheKey) -> None:
22+
"""Informs that update has been started.
23+
Should be called only if 'is_being_updated' returned False (and since then IO-loop has not been lost)..
24+
Calls to 'is_being_updated' will return True until 'mark_updated' will be called."""
25+
raise NotImplementedError()
26+
27+
def mark_updated(self, key: CacheKey, entry: CacheEntry) -> None:
28+
"""Informs that update has been finished.
29+
Calls to 'is_being_updated' will return False until 'mark_being_updated' will be called."""
30+
raise NotImplementedError()
31+
32+
@abstractmethod
33+
def mark_update_aborted(self, key: CacheKey, exception: Exception) -> None:
34+
"""Informs that update failed to complete.
35+
Calls to 'is_being_updated' will return False until 'mark_being_updated' will be called.
36+
Accepts exception to propagate it across all clients awaiting an update."""
37+
raise NotImplementedError()
38+
39+
@abstractmethod
40+
def await_updated(self, key: CacheKey) -> Awaitable[Union[CacheEntry, Exception]]:
41+
"""Waits (asynchronously) until update in progress has benn finished.
42+
Returns awaitable with the updated entry
43+
(or awaitable with an exception if update failed/timed-out).
44+
Should be called only if 'is_being_updated' returned True (and since then IO-loop has not been lost)."""
45+
raise NotImplementedError()
46+
47+
48+
class InMemoryLocks(UpdateStatuses):
49+
"""Manages in-memory locks (for each updated key) to prevent dog-piling. """
1450
def __init__(self, update_lock_timeout: datetime.timedelta = datetime.timedelta(minutes=5)) -> None:
1551
self.logger = logging.getLogger(__name__)
1652
self._update_lock_timeout = update_lock_timeout
1753
self._updates_in_progress: Dict[CacheKey, Future] = {}
1854

1955
def is_being_updated(self, key: CacheKey) -> bool:
20-
"""Checks if update for given key is in progress. Obtained info is valid until control gets back to IO-loop."""
2156
return key in self._updates_in_progress
2257

2358
def mark_being_updated(self, key: CacheKey) -> None:
24-
"""Informs that update has been started.
25-
Should be called only if 'is_being_updated' returned False (and since then IO-loop has not been lost)..
26-
Calls to 'is_being_updated' will return True until 'mark_updated' will be called."""
2759
if key in self._updates_in_progress:
2860
raise ValueError('Key {} is already being updated'.format(key))
2961

@@ -42,27 +74,18 @@ def complete_on_timeout_passed():
4274
callback=complete_on_timeout_passed)
4375

4476
def mark_updated(self, key: CacheKey, entry: CacheEntry) -> None:
45-
"""Informs that update has been finished.
46-
Calls to 'is_being_updated' will return False until 'mark_being_updated' will be called."""
4777
if key not in self._updates_in_progress:
4878
raise ValueError('Key {} is not being updated'.format(key))
4979
update = self._updates_in_progress.pop(key)
5080
update.set_result(entry)
5181

5282
def mark_update_aborted(self, key: CacheKey, exception: Exception) -> None:
53-
"""Informs that update failed to complete.
54-
Calls to 'is_being_updated' will return False until 'mark_being_updated' will be called.
55-
Accepts exception to propagate it across all clients awaiting an update."""
5683
if key not in self._updates_in_progress:
5784
raise ValueError('Key {} is not being updated'.format(key))
5885
update = self._updates_in_progress.pop(key)
5986
update.set_result(exception)
6087

6188
def await_updated(self, key: CacheKey) -> Awaitable[Union[CacheEntry, Exception]]:
62-
"""Waits (asynchronously) until update in progress has benn finished.
63-
Returns awaitable with the updated entry
64-
(or awaitable with an exception if update failed/timed-out).
65-
Should be called only if 'is_being_updated' returned True (and since then IO-loop has not been lost)."""
6689
if not self.is_being_updated(key):
6790
raise ValueError('Key {} is not being updated'.format(key))
6891
return self._updates_in_progress[key]

memoize/wrapper.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
from memoize.entry import CacheKey, CacheEntry
1515
from memoize.exceptions import CachedMethodFailedException
1616
from memoize.invalidation import InvalidationSupport
17-
from memoize.statuses import UpdateStatuses
17+
from memoize.statuses import UpdateStatuses, InMemoryLocks
1818

1919

2020
def memoize(method: Optional[Callable] = None, configuration: CacheConfiguration = None,
21-
invalidation: InvalidationSupport = None):
21+
invalidation: InvalidationSupport = None, update_statuses: UpdateStatuses = None):
2222
"""Wraps function with memoization.
2323
2424
If entry reaches time it should be updated, refresh is performed in background,
@@ -41,6 +41,8 @@ def memoize(method: Optional[Callable] = None, configuration: CacheConfiguration
4141
:param function method: function to be decorated
4242
:param CacheConfiguration configuration: cache configuration; default: DefaultInMemoryCacheConfiguration
4343
:param InvalidationSupport invalidation: pass created instance of InvalidationSupport to have it configured
44+
:param UpdateStatuses update_statuses: allows to override how cache updates are tracked (e.g. lock config);
45+
default: InMemoryStatuses
4446
4547
:raises: CachedMethodFailedException upon call: if cached method timed-out or thrown an exception
4648
:raises: NotConfiguredCacheCalledException upon call: if provided configuration is not ready
@@ -49,15 +51,21 @@ def memoize(method: Optional[Callable] = None, configuration: CacheConfiguration
4951
if method is None:
5052
if configuration is None:
5153
configuration = DefaultInMemoryCacheConfiguration()
52-
return functools.partial(memoize, configuration=configuration, invalidation=invalidation)
54+
return functools.partial(
55+
memoize,
56+
configuration=configuration,
57+
invalidation=invalidation,
58+
update_statuses=update_statuses
59+
)
5360

5461
if invalidation is not None and not invalidation._initialized() and configuration is not None:
5562
invalidation._initialize(configuration.storage(), configuration.key_extractor(), method)
5663

5764
logger = logging.getLogger('{}@{}'.format(memoize.__name__, method.__name__))
5865
logger.debug('wrapping %s with memoization - configuration: %s', method.__name__, configuration)
5966

60-
update_statuses = UpdateStatuses()
67+
if update_statuses is None:
68+
update_statuses = InMemoryLocks()
6169

6270
async def try_release(key: CacheKey, configuration_snapshot: CacheConfiguration) -> bool:
6371
if update_statuses.is_being_updated(key):

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def prepare_description():
1111

1212
setup(
1313
name='py-memoize',
14-
version='3.0.0',
14+
version='3.1.0',
1515
author='Michal Zmuda',
1616
author_email='[email protected]',
1717
url='https://github.com/DreamLab/memoize',

tests/unit/test_statuses.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66

77
from datetime import timedelta
88

9-
from memoize.statuses import UpdateStatuses
9+
from memoize.statuses import InMemoryLocks, UpdateStatuses
1010

1111

1212
@pytest.mark.asyncio(scope="class")
1313
class TestStatuses:
1414

1515
def setup_method(self):
16-
self.update_statuses = UpdateStatuses()
16+
self.update_statuses: UpdateStatuses = InMemoryLocks()
1717

1818
async def test_should_not_be_updating(self):
1919
# given/when/then

0 commit comments

Comments
 (0)