summaryrefslogtreecommitdiff
path: root/persistent.py
blob: 0ba931521a8841968068a6c7d97eb78edd86f78c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
#!/usr/bin/env python3

from abc import ABC, abstractmethod
import atexit
import datetime
import enum
import functools
import logging
from typing import Callable, Optional

import dill

import file_utils

logger = logging.getLogger(__name__)


class Persistent(ABC):
    """
    A base class of an object with a load/save method.
    """
    @abstractmethod
    def save(self):
        pass

    @abstractmethod
    def load(self):
        pass


def reuse_if_mtime_is_today() -> Callable[[datetime.datetime], bool]:
    """
    A helper that returns a lambda appropriate for use in the
    persistent_autoloaded_singleton decorator's may_reuse_persisted
    parameter that allows persisted state to be reused as long as it
    was persisted on the same day as the load.

    """
    now = datetime.datetime.now()
    return lambda dt: (
        dt.month == now.month and
        dt.day == now.day and
        dt.year == now.year
    )


def reuse_if_mtime_less_than_limit_sec(
        limit_seconds: int
) -> Callable[[datetime.datetime], bool]:
    """
    A helper that returns a lambda appropriate for use in the
    persistent_autoloaded_singleton decorator's may_reuse_persisted
    parameter that allows persisted state to be reused as long as it
    was persisted within the past limit_seconds.

    """
    now = datetime.datetime.now()
    return lambda dt: (now - dt).total_seconds() <= limit_seconds


def dont_reuse_persisted_state_force_refresh(
) -> Callable[[datetime.datetime], bool]:
    return lambda dt: False


class PersistAtShutdown(enum.Enum):
    """
    An enum to describe the conditions under which state is persisted
    to disk.  See details below.

    """
    NEVER = 0,
    IF_NOT_INITIALIZED_FROM_DISK = 1,
    ALWAYS = 2,


class persistent_autoloaded_singleton(Persistent):
    """This class is meant to be used as a decorator around a class that:

        1. Is a singleton; one global instance per python program.
        2. Has a complex state that is initialized fully by __init__()
        3. Would benefit from caching said state on disk and reloading
           it on future invokations rather than recomputing and
           reinitializing.

    Here's and example usage pattern:

        @persistent_autoloaded_singleton(
            filename = "my_cache_file.bin",
            may_reuse_persisted = reuse_if_mtime_less_than_limit_sec(60),
            persist_at_shutdown = PersistAtShutdown.IF_NOT_INITIALIZED_FROM_DISK,
        )
        class MyComplexObject(object):
            def __init__(self, ...):
                # do a bunch of work to fully initialize this instance

            def another_method(self, ...):
                # use the state stored in this instance to do some work

    What does this do, exactly?

        1. Anytime you attempt to instantiate MyComplexObject you will
           get the same instance.  This class is now a singleton.
        2. The first time you attempt to instantiate MyComplexObject
           the wrapper scaffolding will check "my_cache_file.bin".  If
           it exists and any may_reuse_persisted predicate indicates
           that reusing persisted state is allowed, we will skip the
           call to __init__ and return an unpickled instance read from
           the disk file.  In the example above the predicate allows
           reuse of saved state if it is <= 60s old.
        3. If the file doesn't exist or the predicate indicates that
           the persisted state cannot be reused (e.g. too stale),
           MyComplexObject's __init__ will be invoked and will be
           expected to fully initialize the instance.
        4. At program exit time, depending on the value of the
           persist_at_shutdown parameter, the state of MyComplexObject
           will be written to disk using the same filename so that
           future instances may potentially reuse saved state.  Note
           that the state that is persisted is the state at program
           exit time.  In the example above this parameter indicates
           that we should persist state so long as we were not
           initialized from cached state on disk.

    """
    def __init__(
            self,
            filename: str,
            *,
            may_reuse_persisted: Optional[Callable[[datetime.datetime], bool]] = None,
            persist_at_shutdown: PersistAtShutdown = PersistAtShutdown.NEVER):
        self.filename = filename
        self.may_reuse_persisted = may_reuse_persisted
        self.persist_at_shutdown = persist_at_shutdown
        self.instance = None

    def __call__(self, cls):
        @functools.wraps(cls)
        def _load(*args, **kwargs):

            # If class has already been loaded, act like a singleton
            # and return a reference to the one and only instance in
            # memory.
            if self.instance is not None:
                logger.debug(
                    f'Returning already instantiated singleton instance of {cls.__name__}.'
                )
                return self.instance

            was_loaded_from_disk = False
            if file_utils.does_file_exist(self.filename):
                cache_mtime_dt = file_utils.get_file_mtime_as_datetime(
                    self.filename
                )
                now = datetime.datetime.now()
                if (
                        self.may_reuse_persisted is not None and
                        self.may_reuse_persisted(cache_mtime_dt)
                ):
                    logger.debug(
                        f'Attempting to load from persisted cache (mtime={cache_mtime_dt}, {now-cache_mtime_dt} ago)')
                    if not self.load():
                        logger.warning('Loading from cache failed?!')
                        assert self.instance is None
                    else:
                        assert self.instance is not None
                        was_loaded_from_disk = True

            if self.instance is None:
                logger.debug(
                    f'Attempting to instantiate {cls.__name__} directly.'
                )
                self.instance = cls(*args, **kwargs)
                was_loaded_from_disk = False

            assert self.instance is not None
            if (
                    self.persist_at_shutdown is PersistAtShutdown.ALWAYS or
                    (
                        not was_loaded_from_disk and
                        self.persist_at_shutdown is PersistAtShutdown.IF_NOT_INITIALIZED_FROM_DISK
                    )
            ):
                atexit.register(self.save)
            return self.instance
        return _load

    def load(self) -> bool:
        try:
            with open(self.filename, 'rb') as f:
                self.instance = dill.load(f)
                return True
        except Exception:
            self.instance = None
            return False
        return False

    def save(self) -> bool:
        if self.instance is not None:
            logger.debug(
                f'Attempting to save {type(self.instance).__name__} to file {self.filename}'
            )
            try:
                with open(self.filename, 'wb') as f:
                    dill.dump(self.instance, f, dill.HIGHEST_PROTOCOL)
                return True
            except Exception:
                return False
        return False