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.
@@ -0,0 +1,6 @@
1
+ """staleflight: serve-stale singleflight caching (RFC 5861 for callables)."""
2
+
3
+ from staleflight.core import SWRCache, Snapshot, swr
4
+
5
+ __all__ = ["SWRCache", "Snapshot", "swr"]
6
+ __version__ = "0.1.0"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.