staleflight 0.1.0__tar.gz

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,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version: ["3.11", "3.12", "3.13"]
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ - run: pip install pytest ruff
24
+ - run: ruff check .
25
+ - run: ruff format --check .
26
+ - run: pip install -e .
27
+ - run: pytest
@@ -0,0 +1,33 @@
1
+ name: Release to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - run: pip install build
16
+ - run: python -m build
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write # PyPI Trusted Publishing (OIDC) -- no API token stored
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ *.egg-info/
5
+ .venv/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .tools/
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-09-02)
4
+
5
+ - Initial release: `SWRCache`, `Snapshot`, `swr` — TTL + non-blocking
6
+ singleflight + stale-while-revalidate + stale-if-error for zero-argument
7
+ callables. Zero dependencies, typed, Python 3.11+.
@@ -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.
@@ -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,66 @@
1
+ # staleflight
2
+
3
+ **Serve-stale singleflight caching for Python — RFC 5861's `stale-while-revalidate` and `stale-if-error`, for plain callables instead of HTTP.**
4
+
5
+ Zero dependencies. Fully typed (PEP 561). Python 3.11+.
6
+
7
+ ```python
8
+ from staleflight import swr
9
+
10
+
11
+ @swr(ttl=5.0)
12
+ def mesh_health() -> dict:
13
+ return probe_all_the_things() # expensive: network calls, big queries…
14
+
15
+
16
+ snapshot = mesh_health.get() # Snapshot(value=..., created_at=...) or None
17
+ ```
18
+
19
+ ## The semantics, precisely
20
+
21
+ | State | What a caller gets |
22
+ |---|---|
23
+ | **fresh** (age < ttl) | the cached snapshot, ~0 cost |
24
+ | **stale** | exactly **one** caller recomputes; every concurrent caller is served the **previous snapshot immediately** — nobody waits behind the refresh |
25
+ | **refresh fails** | the previous snapshot keeps being served and keeps aging; the error lands on `cache.last_error` and your `on_error` callback |
26
+ | **empty** (first ever call) | the first caller computes; concurrent callers get `None` and decide what "not yet" means for them |
27
+
28
+ 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.
29
+
30
+ ## Why not …
31
+
32
+ *(Survey of 17 caching libraries, 2026. Corrections welcome.)*
33
+
34
+ - **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).
35
+ - **cachetools** — TTL only; its stampede options make concurrent callers *wait*. No stale-serving.
36
+ - **PyPI `singleflight` ports** — deduplicate concurrent calls but block the losers, and cache nothing.
37
+ - **cachier / diskcache / requests-cache** — respectively: immature memory backend; probabilistic *early* refresh that still blocks after full expiry; SWR for HTTP responses only.
38
+
39
+ ## API
40
+
41
+ ```python
42
+ from staleflight import SWRCache, Snapshot, swr
43
+
44
+ cache = SWRCache(source, ttl=5.0, on_error=log_it, clock=time.monotonic)
45
+
46
+ cache.get() # Snapshot | None — refreshes at most singleflight-once, never raises
47
+ cache.peek() # Snapshot | None — never triggers work
48
+ cache.refresh() # force recompute now; raises on failure (a command, not a read)
49
+ cache.age() # seconds since compute; float('inf') when empty
50
+ cache.invalidate() # drop the snapshot
51
+ cache.last_error # BaseException | None from the most recent failed get()-refresh
52
+ ```
53
+
54
+ `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.
55
+
56
+ ## Thread-safety
57
+
58
+ 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.
59
+
60
+ ## Non-goals
61
+
62
+ 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.
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,83 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "staleflight"
7
+ version = "0.1.0"
8
+ description = "Serve-stale singleflight caching: RFC 5861 stale-while-revalidate and stale-if-error for plain Python callables."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Akarsh Sharma" }]
13
+ keywords = [
14
+ "cache",
15
+ "singleflight",
16
+ "stale-while-revalidate",
17
+ "stale-if-error",
18
+ "swr",
19
+ "ttl",
20
+ "dogpile",
21
+ "rfc5861",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Intended Audience :: Developers",
26
+ "Operating System :: OS Independent",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Topic :: Software Development :: Libraries :: Python Modules",
32
+ "Typing :: Typed",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/jaeger123/staleflight"
37
+ Repository = "https://github.com/jaeger123/staleflight"
38
+ Changelog = "https://github.com/jaeger123/staleflight/blob/main/CHANGELOG.md"
39
+ Issues = "https://github.com/jaeger123/staleflight/issues"
40
+
41
+ [dependency-groups]
42
+ dev = ["pytest>=8", "ruff>=0.6"]
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/staleflight"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ addopts = "-q"
50
+
51
+ [tool.ruff]
52
+ line-length = 88
53
+ extend-exclude = [".tools"]
54
+ target-version = "py311"
55
+
56
+ [tool.ruff.lint]
57
+ select = [
58
+ "E", # pycodestyle errors
59
+ "W", # pycodestyle warnings
60
+ "F", # pyflakes
61
+ "I", # isort
62
+ "N", # pep8-naming
63
+ "D", # pydocstyle
64
+ "UP", # pyupgrade
65
+ "B", # bugbear
66
+ "SIM", # simplify
67
+ "RUF", # ruff-specific
68
+ ]
69
+ ignore = [
70
+ "D203", # incompatible with D211
71
+ "D213", # incompatible with D212
72
+ ]
73
+
74
+ [tool.ruff.lint.isort]
75
+ case-sensitive = true
76
+ # Match the local formatter, which groups this package with third-party.
77
+ known-third-party = ["staleflight"]
78
+
79
+ [tool.ruff.lint.per-file-ignores]
80
+ "tests/*" = ["D"] # tests document themselves through their names
81
+
82
+ [tool.ruff.lint.pydocstyle]
83
+ convention = "google"
@@ -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"
@@ -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
File without changes
@@ -0,0 +1,239 @@
1
+ import threading
2
+
3
+ import pytest
4
+ from staleflight import SWRCache, Snapshot, swr
5
+
6
+
7
+ class FakeClock:
8
+ def __init__(self) -> None:
9
+ self.now = 100.0
10
+
11
+ def __call__(self) -> float:
12
+ return self.now
13
+
14
+ def advance(self, seconds: float) -> None:
15
+ self.now += seconds
16
+
17
+
18
+ @pytest.fixture()
19
+ def clock() -> FakeClock:
20
+ return FakeClock()
21
+
22
+
23
+ def test_first_call_computes_and_returns_snapshot(clock):
24
+ cache = SWRCache(lambda: "v1", ttl=5.0, clock=clock)
25
+
26
+ snapshot = cache.get()
27
+
28
+ assert isinstance(snapshot, Snapshot)
29
+ assert snapshot.value == "v1"
30
+ assert snapshot.created_at == clock.now
31
+
32
+
33
+ def test_fresh_snapshot_is_served_without_recompute(clock):
34
+ calls = []
35
+ cache = SWRCache(lambda: calls.append(1) or len(calls), ttl=5.0, clock=clock)
36
+
37
+ first = cache.get()
38
+ clock.advance(4.9)
39
+ second = cache.get()
40
+
41
+ assert calls == [1]
42
+ assert second is first
43
+
44
+
45
+ def test_stale_snapshot_triggers_exactly_one_recompute(clock):
46
+ calls = []
47
+ cache = SWRCache(lambda: calls.append(1) or len(calls), ttl=5.0, clock=clock)
48
+
49
+ cache.get()
50
+ clock.advance(5.0)
51
+ refreshed = cache.get()
52
+
53
+ assert calls == [1, 1]
54
+ assert refreshed is not None and refreshed.value == 2
55
+
56
+
57
+ def test_caller_during_refresh_gets_previous_snapshot_immediately(clock):
58
+ """The defining semantic: losers are served stale, never parked."""
59
+ in_refresh = threading.Event()
60
+ release = threading.Event()
61
+ values = iter(["old", "new"])
62
+
63
+ def slow_source():
64
+ value = next(values)
65
+ if value == "new":
66
+ in_refresh.set()
67
+ assert release.wait(timeout=5)
68
+ return value
69
+
70
+ cache = SWRCache(slow_source, ttl=5.0, clock=clock)
71
+ cache.get() # publish "old"
72
+ clock.advance(5.0)
73
+
74
+ winner_result = {}
75
+ winner = threading.Thread(target=lambda: winner_result.update(snap=cache.get()))
76
+ winner.start()
77
+ assert in_refresh.wait(timeout=5)
78
+
79
+ loser_snapshot = cache.get() # winner still inside slow_source
80
+ assert loser_snapshot is not None
81
+ assert loser_snapshot.value == "old"
82
+
83
+ release.set()
84
+ winner.join(timeout=5)
85
+ assert winner_result["snap"].value == "new"
86
+ assert cache.get().value == "new"
87
+
88
+
89
+ def test_caller_during_first_ever_compute_gets_none(clock):
90
+ cache = SWRCache(lambda: "never", ttl=5.0, clock=clock)
91
+
92
+ with cache._refresh_lock: # simulate the first computation in flight
93
+ assert cache.get() is None
94
+
95
+
96
+ def test_failed_refresh_serves_stale_and_records_error(clock):
97
+ errors = []
98
+ state = {"fail": False}
99
+
100
+ def source():
101
+ if state["fail"]:
102
+ raise RuntimeError("boom")
103
+ return "good"
104
+
105
+ cache = SWRCache(source, ttl=5.0, on_error=errors.append, clock=clock)
106
+ cache.get()
107
+ state["fail"] = True
108
+ clock.advance(5.0)
109
+
110
+ snapshot = cache.get()
111
+
112
+ assert snapshot is not None and snapshot.value == "good"
113
+ assert cache.age() >= 5.0 # still aging: staleness stays observable
114
+ assert isinstance(cache.last_error, RuntimeError)
115
+ assert errors and errors[0] is cache.last_error
116
+
117
+
118
+ def test_successful_refresh_clears_last_error(clock):
119
+ state = {"fail": True}
120
+
121
+ def source():
122
+ if state["fail"]:
123
+ raise RuntimeError("boom")
124
+ return "recovered"
125
+
126
+ cache = SWRCache(source, ttl=5.0, clock=clock)
127
+ assert cache.get() is None
128
+ assert cache.last_error is not None
129
+
130
+ state["fail"] = False
131
+ snapshot = cache.get()
132
+
133
+ assert snapshot is not None and snapshot.value == "recovered"
134
+ assert cache.last_error is None
135
+
136
+
137
+ def test_explicit_refresh_raises_and_keeps_previous_snapshot(clock):
138
+ state = {"fail": False}
139
+
140
+ def source():
141
+ if state["fail"]:
142
+ raise RuntimeError("boom")
143
+ return "v1"
144
+
145
+ cache = SWRCache(source, ttl=5.0, clock=clock)
146
+ cache.get()
147
+ state["fail"] = True
148
+
149
+ with pytest.raises(RuntimeError):
150
+ cache.refresh()
151
+ assert cache.peek() is not None and cache.peek().value == "v1"
152
+
153
+
154
+ def test_peek_never_computes(clock):
155
+ cache = SWRCache(lambda: pytest.fail("peek must not compute"), ttl=5.0, clock=clock)
156
+
157
+ assert cache.peek() is None
158
+
159
+
160
+ def test_invalidate_forces_recompute_from_empty(clock):
161
+ calls = []
162
+ cache = SWRCache(lambda: calls.append(1) or len(calls), ttl=5.0, clock=clock)
163
+ cache.get()
164
+
165
+ cache.invalidate()
166
+
167
+ assert cache.peek() is None
168
+ assert cache.age() == float("inf")
169
+ assert cache.get().value == 2
170
+
171
+
172
+ def test_age_uses_injected_clock(clock):
173
+ cache = SWRCache(lambda: "v", ttl=5.0, clock=clock)
174
+ cache.get()
175
+ clock.advance(2.5)
176
+
177
+ assert cache.age() == pytest.approx(2.5)
178
+
179
+
180
+ def test_ttl_must_be_positive():
181
+ with pytest.raises(ValueError, match="ttl must be positive"):
182
+ SWRCache(lambda: 1, ttl=0)
183
+
184
+
185
+ def test_swr_decorator_returns_a_cache(clock):
186
+ @swr(ttl=5.0, clock=clock)
187
+ def value() -> str:
188
+ return "decorated"
189
+
190
+ assert isinstance(value, SWRCache)
191
+ assert value.get().value == "decorated"
192
+
193
+
194
+ def test_freshness_is_judged_on_the_returned_snapshot(clock):
195
+ """invalidate() between capture and age check must not corrupt get()."""
196
+ cache = SWRCache(lambda: "v", ttl=5.0, clock=clock)
197
+ first = cache.get()
198
+
199
+ # Regression guard for the capture race: age is computed from the same
200
+ # snapshot object that is returned, never re-read from the instance.
201
+ assert cache.get() is first
202
+
203
+
204
+ def test_explicit_refreshes_serialize_on_the_lock(clock):
205
+ order = []
206
+
207
+ def source():
208
+ order.append("run")
209
+ return len(order)
210
+
211
+ cache = SWRCache(source, ttl=5.0, clock=clock)
212
+
213
+ slow_started = threading.Event()
214
+ release = threading.Event()
215
+
216
+ def slow_source():
217
+ slow_started.set()
218
+ assert release.wait(timeout=5)
219
+ order.append("slow")
220
+ return "slow"
221
+
222
+ cache._source = slow_source
223
+ slow = threading.Thread(target=cache.refresh)
224
+ slow.start()
225
+ assert slow_started.wait(timeout=5)
226
+
227
+ cache._source = source
228
+ fast = threading.Thread(target=cache.refresh)
229
+ fast.start()
230
+
231
+ release.set()
232
+ slow.join(timeout=5)
233
+ fast.join(timeout=5)
234
+
235
+ # The fast refresh could not start until the slow one finished, so the
236
+ # final publication is the later-started computation (which ran second
237
+ # and saw order == ["slow", "run"]), never a stale overwrite.
238
+ assert order == ["slow", "run"]
239
+ assert cache.peek().value == 2