staleflight 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
staleflight/__init__.py
ADDED
staleflight/core.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Serve-stale singleflight caching for expensive zero-argument callables.
|
|
2
|
+
|
|
3
|
+
This module implements the semantics of RFC 5861 (``stale-while-revalidate``
|
|
4
|
+
and ``stale-if-error``) for plain Python callables:
|
|
5
|
+
|
|
6
|
+
fresh
|
|
7
|
+
The cached snapshot is served; no work happens.
|
|
8
|
+
stale
|
|
9
|
+
Exactly one caller recomputes. Every caller that arrives while the
|
|
10
|
+
recomputation is in flight is served the previous snapshot immediately
|
|
11
|
+
instead of waiting (``stale-while-revalidate``).
|
|
12
|
+
refresh failure
|
|
13
|
+
The previous snapshot keeps being served and keeps aging
|
|
14
|
+
(``stale-if-error``). The error is recorded and optionally reported.
|
|
15
|
+
empty
|
|
16
|
+
The very first caller computes; callers that arrive during that first
|
|
17
|
+
computation receive ``None`` and decide for themselves what "not yet"
|
|
18
|
+
means in their domain.
|
|
19
|
+
|
|
20
|
+
The design is intentionally the opposite of a blocking dogpile lock: under
|
|
21
|
+
load, no caller is ever parked behind another caller's slow recomputation.
|
|
22
|
+
That trade -- bounded staleness in exchange for bounded latency -- is the
|
|
23
|
+
right one for readiness gates, dashboards, and feature-flag style lookups,
|
|
24
|
+
and the wrong one when every caller must observe the newest value.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Generic, TypeVar
|
|
34
|
+
|
|
35
|
+
__all__ = ["SWRCache", "Snapshot", "swr"]
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
Clock = Callable[[], float]
|
|
40
|
+
ErrorCallback = Callable[[BaseException], None]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Snapshot(Generic[T]):
|
|
45
|
+
"""An immutable value paired with the instant it was computed.
|
|
46
|
+
|
|
47
|
+
``created_at`` is expressed on the cache's clock (monotonic by
|
|
48
|
+
default), so it is suitable for measuring age, not for display.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
value: T
|
|
52
|
+
created_at: float
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SWRCache(Generic[T]):
|
|
56
|
+
"""A serve-stale singleflight cache around one zero-argument callable.
|
|
57
|
+
|
|
58
|
+
One instance guards one value. Publication is a single reference
|
|
59
|
+
assignment of an immutable :class:`Snapshot`, so readers in other
|
|
60
|
+
threads always observe either the previous snapshot or the new one,
|
|
61
|
+
never a partially built value.
|
|
62
|
+
|
|
63
|
+
Thread-safety: safe under CPython's GIL, which every current supported
|
|
64
|
+
runtime has. A free-threaded (PEP 703) deployment should wrap
|
|
65
|
+
``get``/``peek`` in a lock of its own; reference-swap atomicity is
|
|
66
|
+
implementation behaviour there, not a documented guarantee.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
source: The expensive callable producing the value.
|
|
70
|
+
ttl: Seconds a snapshot is considered fresh. Must be positive.
|
|
71
|
+
on_error: Called with the exception when a refresh triggered by
|
|
72
|
+
:meth:`get` fails. Failures never propagate out of ``get``;
|
|
73
|
+
they surface here and on :attr:`last_error`.
|
|
74
|
+
clock: Monotonic time source; injectable for tests.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
source: Callable[[], T],
|
|
80
|
+
*,
|
|
81
|
+
ttl: float,
|
|
82
|
+
on_error: ErrorCallback | None = None,
|
|
83
|
+
clock: Clock = time.monotonic,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""See the class docstring for parameter semantics."""
|
|
86
|
+
if ttl <= 0:
|
|
87
|
+
raise ValueError(f"ttl must be positive, got {ttl!r}")
|
|
88
|
+
self._source = source
|
|
89
|
+
self._ttl = float(ttl)
|
|
90
|
+
self._on_error = on_error
|
|
91
|
+
self._clock = clock
|
|
92
|
+
self._snapshot: Snapshot[T] | None = None
|
|
93
|
+
self._refresh_lock = threading.Lock()
|
|
94
|
+
#: The exception from the most recent failed refresh, cleared by the
|
|
95
|
+
#: next successful one. Advisory: read it for diagnostics, not logic.
|
|
96
|
+
self.last_error: BaseException | None = None
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def ttl(self) -> float:
|
|
100
|
+
"""Seconds a snapshot is served without triggering a refresh."""
|
|
101
|
+
return self._ttl
|
|
102
|
+
|
|
103
|
+
def get(self) -> Snapshot[T] | None:
|
|
104
|
+
"""Return a snapshot, refreshing at most once per staleness.
|
|
105
|
+
|
|
106
|
+
Never blocks on another caller's refresh and never raises on a
|
|
107
|
+
failed refresh; see the module docstring for the full semantics.
|
|
108
|
+
Returns ``None`` only while the very first computation has not yet
|
|
109
|
+
completed.
|
|
110
|
+
"""
|
|
111
|
+
snapshot = self._snapshot
|
|
112
|
+
if snapshot is not None and self._clock() - snapshot.created_at < self._ttl:
|
|
113
|
+
return snapshot
|
|
114
|
+
if self._refresh_lock.acquire(blocking=False):
|
|
115
|
+
try:
|
|
116
|
+
return self._refresh_locked()
|
|
117
|
+
# Broad on purpose: stale-if-error is the contract.
|
|
118
|
+
except Exception as error:
|
|
119
|
+
self.last_error = error
|
|
120
|
+
if self._on_error is not None:
|
|
121
|
+
self._on_error(error)
|
|
122
|
+
finally:
|
|
123
|
+
self._refresh_lock.release()
|
|
124
|
+
# Lock was busy (another caller is refreshing) or the refresh
|
|
125
|
+
# failed: the previous snapshot -- possibly None -- is the answer.
|
|
126
|
+
return self._snapshot
|
|
127
|
+
|
|
128
|
+
def refresh(self) -> Snapshot[T]:
|
|
129
|
+
"""Recompute synchronously and publish, regardless of freshness.
|
|
130
|
+
|
|
131
|
+
Unlike :meth:`get`, a failure here propagates: an explicit refresh
|
|
132
|
+
is a command, not a read. Explicit refreshes serialize on the
|
|
133
|
+
refresh lock, so a slow older computation can never overwrite a
|
|
134
|
+
newer snapshot.
|
|
135
|
+
"""
|
|
136
|
+
with self._refresh_lock:
|
|
137
|
+
return self._refresh_locked()
|
|
138
|
+
|
|
139
|
+
def _refresh_locked(self) -> Snapshot[T]:
|
|
140
|
+
snapshot = Snapshot(value=self._source(), created_at=self._clock())
|
|
141
|
+
self._snapshot = snapshot
|
|
142
|
+
self.last_error = None
|
|
143
|
+
return snapshot
|
|
144
|
+
|
|
145
|
+
def peek(self) -> Snapshot[T] | None:
|
|
146
|
+
"""Return the current snapshot, fresh or stale, without any work."""
|
|
147
|
+
return self._snapshot
|
|
148
|
+
|
|
149
|
+
def age(self) -> float:
|
|
150
|
+
"""Seconds since the current snapshot was computed.
|
|
151
|
+
|
|
152
|
+
Returns ``float('inf')`` when nothing has been computed yet, so the
|
|
153
|
+
result is always comparable against a TTL or staleness budget.
|
|
154
|
+
"""
|
|
155
|
+
snapshot = self._snapshot
|
|
156
|
+
if snapshot is None:
|
|
157
|
+
return float("inf")
|
|
158
|
+
return self._clock() - snapshot.created_at
|
|
159
|
+
|
|
160
|
+
def invalidate(self) -> None:
|
|
161
|
+
"""Drop the snapshot; the next :meth:`get` recomputes from empty."""
|
|
162
|
+
self._snapshot = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def swr(
|
|
166
|
+
*,
|
|
167
|
+
ttl: float,
|
|
168
|
+
on_error: ErrorCallback | None = None,
|
|
169
|
+
clock: Clock = time.monotonic,
|
|
170
|
+
) -> Callable[[Callable[[], T]], SWRCache[T]]:
|
|
171
|
+
"""Build an :class:`SWRCache` in decorator position.
|
|
172
|
+
|
|
173
|
+
The decorated name is *replaced by the cache instance*, which makes the
|
|
174
|
+
call-site semantics explicit -- ``config.get()`` returns a
|
|
175
|
+
:class:`Snapshot`, not a bare value::
|
|
176
|
+
|
|
177
|
+
@swr(ttl=5.0)
|
|
178
|
+
def mesh_health() -> dict[str, bool]:
|
|
179
|
+
return probe_everything()
|
|
180
|
+
|
|
181
|
+
snapshot = mesh_health.get()
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def wrap(source: Callable[[], T]) -> SWRCache[T]:
|
|
185
|
+
return SWRCache(source, ttl=ttl, on_error=on_error, clock=clock)
|
|
186
|
+
|
|
187
|
+
return wrap
|
staleflight/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: staleflight
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Serve-stale singleflight caching: RFC 5861 stale-while-revalidate and stale-if-error for plain Python callables.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jaeger123/staleflight
|
|
6
|
+
Project-URL: Repository, https://github.com/jaeger123/staleflight
|
|
7
|
+
Project-URL: Changelog, https://github.com/jaeger123/staleflight/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/jaeger123/staleflight/issues
|
|
9
|
+
Author: Akarsh Sharma
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cache,dogpile,rfc5861,singleflight,stale-if-error,stale-while-revalidate,swr,ttl
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# staleflight
|
|
26
|
+
|
|
27
|
+
**Serve-stale singleflight caching for Python — RFC 5861's `stale-while-revalidate` and `stale-if-error`, for plain callables instead of HTTP.**
|
|
28
|
+
|
|
29
|
+
Zero dependencies. Fully typed (PEP 561). Python 3.11+.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from staleflight import swr
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@swr(ttl=5.0)
|
|
36
|
+
def mesh_health() -> dict:
|
|
37
|
+
return probe_all_the_things() # expensive: network calls, big queries…
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
snapshot = mesh_health.get() # Snapshot(value=..., created_at=...) or None
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The semantics, precisely
|
|
44
|
+
|
|
45
|
+
| State | What a caller gets |
|
|
46
|
+
|---|---|
|
|
47
|
+
| **fresh** (age < ttl) | the cached snapshot, ~0 cost |
|
|
48
|
+
| **stale** | exactly **one** caller recomputes; every concurrent caller is served the **previous snapshot immediately** — nobody waits behind the refresh |
|
|
49
|
+
| **refresh fails** | the previous snapshot keeps being served and keeps aging; the error lands on `cache.last_error` and your `on_error` callback |
|
|
50
|
+
| **empty** (first ever call) | the first caller computes; concurrent callers get `None` and decide what "not yet" means for them |
|
|
51
|
+
|
|
52
|
+
This is the **non-blocking** flavor of stampede protection. Most caching libraries offer the blocking flavor — losers park behind the winner's lock until the new value exists. That is the right choice when every reader must see the newest value, and the wrong one when bounded *latency* matters more than bounded *staleness*: readiness gates, dashboards, feature flags, config lookups. staleflight is for the second family.
|
|
53
|
+
|
|
54
|
+
## Why not …
|
|
55
|
+
|
|
56
|
+
*(Survey of 17 caching libraries, 2026. Corrections welcome.)*
|
|
57
|
+
|
|
58
|
+
- **dogpile.cache** — the one mature library with this exact serve-stale semantic (`get_or_create`). Adopt it if you want regions, backends and invalidation strategies; staleflight is for when you want the 150-line version with no dependencies and stale-if-error built in (dogpile propagates creator failures to the winning caller).
|
|
59
|
+
- **cachetools** — TTL only; its stampede options make concurrent callers *wait*. No stale-serving.
|
|
60
|
+
- **PyPI `singleflight` ports** — deduplicate concurrent calls but block the losers, and cache nothing.
|
|
61
|
+
- **cachier / diskcache / requests-cache** — respectively: immature memory backend; probabilistic *early* refresh that still blocks after full expiry; SWR for HTTP responses only.
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from staleflight import SWRCache, Snapshot, swr
|
|
67
|
+
|
|
68
|
+
cache = SWRCache(source, ttl=5.0, on_error=log_it, clock=time.monotonic)
|
|
69
|
+
|
|
70
|
+
cache.get() # Snapshot | None — refreshes at most singleflight-once, never raises
|
|
71
|
+
cache.peek() # Snapshot | None — never triggers work
|
|
72
|
+
cache.refresh() # force recompute now; raises on failure (a command, not a read)
|
|
73
|
+
cache.age() # seconds since compute; float('inf') when empty
|
|
74
|
+
cache.invalidate() # drop the snapshot
|
|
75
|
+
cache.last_error # BaseException | None from the most recent failed get()-refresh
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`Snapshot` is a frozen dataclass `(value, created_at)`; `created_at` is on the cache's (monotonic) clock. The `clock` parameter makes every behavior testable without sleeps — see the test suite.
|
|
79
|
+
|
|
80
|
+
## Thread-safety
|
|
81
|
+
|
|
82
|
+
Publication is a single reference assignment of an immutable `Snapshot`: readers observe the old snapshot or the new one, never a torn value. Safe under CPython's GIL (every currently supported default build). On free-threaded builds (PEP 703) reference-swap visibility is implementation behavior, not contract — wrap access in your own lock there.
|
|
83
|
+
|
|
84
|
+
## Non-goals
|
|
85
|
+
|
|
86
|
+
Keyed/memoizing caches, async, eviction policies, external backends, background refresh threads. One value, one callable, one file. If you need more, you want dogpile.cache.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
staleflight/__init__.py,sha256=FVg1fVYf1LT_Un0RAwqam-5bzPmmihr_ZzTUHaQPcpk,197
|
|
2
|
+
staleflight/core.py,sha256=pBTY6JrMfr5kv7ZWca2z4YyFoTurmzKMaDQVNtrpY00,6888
|
|
3
|
+
staleflight/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
staleflight-0.1.0.dist-info/METADATA,sha256=qgP96vLNNu6H6bkFOKykgsbFdLifoyPWoMKree-LR2w,4714
|
|
5
|
+
staleflight-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
staleflight-0.1.0.dist-info/licenses/LICENSE,sha256=K9kfzteXibjTOhXPG7bWZH6qKxbnHwVKF3mLhcv2O1o,1070
|
|
7
|
+
staleflight-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akarsh Sharma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|