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.
- staleflight-0.1.0/.github/workflows/ci.yml +27 -0
- staleflight-0.1.0/.github/workflows/release.yml +33 -0
- staleflight-0.1.0/.gitignore +8 -0
- staleflight-0.1.0/CHANGELOG.md +7 -0
- staleflight-0.1.0/LICENSE +21 -0
- staleflight-0.1.0/PKG-INFO +90 -0
- staleflight-0.1.0/README.md +66 -0
- staleflight-0.1.0/pyproject.toml +83 -0
- staleflight-0.1.0/src/staleflight/__init__.py +6 -0
- staleflight-0.1.0/src/staleflight/core.py +187 -0
- staleflight-0.1.0/src/staleflight/py.typed +0 -0
- staleflight-0.1.0/tests/test_staleflight.py +239 -0
|
@@ -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,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,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
|