Skip to content

Commit 23ff8d2

Browse files
captainpacketclaude
andcommitted
feat(snapshots): add a lifetime scope, because a TTL straddles two moments
snapshot_cache_ttl shipped in 0.1.5 taking only seconds, and seconds are the wrong shape for the case it was added for. A run that has pinned one point in time does not want that answer to change underneath it, and a TTL expiring mid-run lets the next resolution return a different snapshot. Nothing raises, and the data is real on both sides of the expiry; it is simply from two different times. That is the same failure this area keeps producing, an answer that looks entirely plausible. "lifetime" resolves once and holds for the life of the client, which matches the invariant a sync actually relies on: it pinned one snapshot, and snapshots are immutable. It still yields to an upload through the same client, because that is an event that makes the answer wrong rather than merely old, so lifetime means "until something makes it wrong". Seconds stay right for repeated independent reads, and the docs now say which shape suits which caller instead of leaving it to be discovered. Suggested by the Nautobot integration, whose own snapshot cache is lifetime-scoped for exactly this reason and who pointed out that a TTL quietly breaks the invariant while looking like a performance knob. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Kbxab7ihjPntGAmnsZtqG
1 parent ccec1a2 commit 23ff8d2

10 files changed

Lines changed: 195 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@
33
All notable changes to this project are documented here. The format follows
44
[Keep a Changelog](https://keepachangelog.com/) and the project uses SemVer.
55

6+
## [Unreleased]
7+
8+
### Added
9+
10+
- `snapshot_cache_ttl="lifetime"`, which resolves a snapshot once and keeps that
11+
answer for the life of the client. A number of seconds was the wrong shape for
12+
the case the setting was added for: a run that has pinned one point in time
13+
does not want the answer to change underneath it, and a TTL expiring mid-run
14+
lets the next resolution return a different snapshot, so the run straddles two
15+
moments with nothing raising and the data real on both sides.
16+
17+
Seconds remain right for repeated independent reads, and the documentation now
18+
says which shape suits which caller rather than leaving it to be discovered.
19+
`"lifetime"` still yields to an upload through the same client, because that is
20+
an event that makes the answer wrong rather than merely old. Suggested by the
21+
Nautobot integration, whose own cache is lifetime-scoped for this reason.
22+
623
## [0.1.5] - 2026-09-09
724

825
### Changed

‎docs/configuration.md‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,21 +145,40 @@ that itself. Nothing failed and no test noticed. It showed up only as request
145145
volume against a live account.
146146

147147
If you know an invariant the SDK cannot assume, tell it, with
148-
`snapshot_cache_ttl`. It is off by default and takes a number of seconds:
148+
`snapshot_cache_ttl`. It is off by default and takes two shapes, and which one
149+
you want follows from what your run is doing.
150+
151+
**A run that holds one point in time wants `"lifetime"`.** It resolves once and
152+
keeps that answer for as long as the client lives:
153+
154+
```python
155+
# This sync pins one snapshot for its whole run, and snapshots are immutable,
156+
# so the answer must not change underneath it.
157+
client = ForwardClient.from_env(snapshot_cache_ttl="lifetime")
158+
```
159+
160+
**Repeated independent reads want a number of seconds.** A dashboard, a
161+
long-lived service answering unrelated questions, anything where "latest" should
162+
eventually mean something newer:
149163

150164
```python
151-
# This sync pins one point in time for its whole run, so resolving "latest"
152-
# once is right here even though it would not be in general.
153-
client = ForwardClient.from_env(snapshot_cache_ttl=600)
165+
client = ForwardClient.from_env(snapshot_cache_ttl=60)
154166
```
155167

168+
Do not reach for the number when you meant the first thing. A TTL that expires
169+
mid-run lets the next resolution return a different snapshot, so the run
170+
straddles two moments. Nothing raises, and the data is real on both sides of the
171+
expiry; it is simply from two different times. That is the same failure this
172+
whole area keeps producing, an answer that looks entirely plausible.
173+
156174
That covers `latest_processed` and `latest_collected_id`, keyed by the exact
157175
question asked, so two different tag scopes stay two different answers. A
158176
network with no processed snapshot is cached as an answer too, since re-asking
159177
would spend the budget the setting exists to save.
160178

161179
Uploading a snapshot through the same client clears it, because that is the
162-
event that makes a cached answer wrong. For anything the SDK cannot see, such
180+
event that makes a cached answer wrong. `"lifetime"` means "until something
181+
makes it wrong", not "forever", so it yields to an upload too. For anything the SDK cannot see, such
163182
as an upload from another process or a snapshot that finished while you were
164183
running, call `client.snapshots.clear_cache()`.
165184

‎src/forward_sdk/_async/client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def __init__(
7676
snapshot_id: str | None = None,
7777
user_agent: str | None = None,
7878
cache_ttl: float = 60.0,
79-
snapshot_cache_ttl: float = 0.0,
79+
snapshot_cache_ttl: float | Literal["lifetime"] = 0.0,
8080
proxy: str | None = None,
8181
trust_env: bool = True,
8282
transport: httpx.AsyncBaseTransport | None = None,

‎src/forward_sdk/_async/services/snapshots.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@
5252
"""
5353

5454

55+
#: Reuse a resolved snapshot for as long as the client lives.
56+
LIFETIME = "lifetime"
57+
58+
5559
class _Miss:
5660
"""Sentinel for "not cached", distinct from a cached ``None``."""
5761

@@ -86,32 +90,41 @@ def clear_cache(self) -> None:
8690
"""
8791
self._resolved.clear()
8892

93+
def _caching(self) -> bool:
94+
ttl = self._config.snapshot_cache_ttl
95+
return bool(ttl == LIFETIME or ttl > 0)
96+
8997
def _cached(self, key: tuple[Any, ...]) -> Any:
9098
"""A previously resolved answer to exactly this question, or a miss.
9199
92100
Returns ``_MISS`` rather than ``None`` because ``None`` is a real
93101
answer: a network with no processed snapshot resolves to nothing, and
94102
re-asking every time would spend the budget the cache exists to save.
95103
104+
``"lifetime"`` never expires. That is the point of it: a run holding one
105+
point in time must not have the answer change underneath it, and a TTL
106+
expiring mid-run would let the next resolution return a different
107+
snapshot, straddling two moments with nothing raising.
108+
96109
A race between two threads costs a duplicate resolution, never a wrong
97110
answer, since both compute the same thing from the same server state.
98111
"""
99112
ttl = self._config.snapshot_cache_ttl
100-
if ttl <= 0:
113+
if not self._caching():
101114
return _MISS
102115
entry = self._resolved.get(key)
103116
if entry is None:
104117
self._transport.counters.increment("cache_misses")
105118
return _MISS
106119
fetched, value = entry
107-
if (time.monotonic() - fetched) >= ttl:
120+
if ttl != LIFETIME and (time.monotonic() - fetched) >= ttl:
108121
self._transport.counters.increment("cache_misses")
109122
return _MISS
110123
self._transport.counters.increment("cache_hits")
111124
return value
112125

113126
def _remember(self, key: tuple[Any, ...], value: Any) -> Any:
114-
if self._config.snapshot_cache_ttl > 0:
127+
if self._caching():
115128
self._resolved[key] = (time.monotonic(), value)
116129
return value
117130

‎src/forward_sdk/_sync/client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def __init__(
7979
snapshot_id: str | None = None,
8080
user_agent: str | None = None,
8181
cache_ttl: float = 60.0,
82-
snapshot_cache_ttl: float = 0.0,
82+
snapshot_cache_ttl: float | Literal["lifetime"] = 0.0,
8383
proxy: str | None = None,
8484
trust_env: bool = True,
8585
transport: httpx.BaseTransport | None = None,

‎src/forward_sdk/_sync/services/snapshots.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@
5454
"""
5555

5656

57+
#: Reuse a resolved snapshot for as long as the client lives.
58+
LIFETIME = "lifetime"
59+
60+
5761
class _Miss:
5862
"""Sentinel for "not cached", distinct from a cached ``None``."""
5963

@@ -88,32 +92,41 @@ def clear_cache(self) -> None:
8892
"""
8993
self._resolved.clear()
9094

95+
def _caching(self) -> bool:
96+
ttl = self._config.snapshot_cache_ttl
97+
return bool(ttl == LIFETIME or ttl > 0)
98+
9199
def _cached(self, key: tuple[Any, ...]) -> Any:
92100
"""A previously resolved answer to exactly this question, or a miss.
93101
94102
Returns ``_MISS`` rather than ``None`` because ``None`` is a real
95103
answer: a network with no processed snapshot resolves to nothing, and
96104
re-asking every time would spend the budget the cache exists to save.
97105
106+
``"lifetime"`` never expires. That is the point of it: a run holding one
107+
point in time must not have the answer change underneath it, and a TTL
108+
expiring mid-run would let the next resolution return a different
109+
snapshot, straddling two moments with nothing raising.
110+
98111
A race between two threads costs a duplicate resolution, never a wrong
99112
answer, since both compute the same thing from the same server state.
100113
"""
101114
ttl = self._config.snapshot_cache_ttl
102-
if ttl <= 0:
115+
if not self._caching():
103116
return _MISS
104117
entry = self._resolved.get(key)
105118
if entry is None:
106119
self._transport.counters.increment("cache_misses")
107120
return _MISS
108121
fetched, value = entry
109-
if (time.monotonic() - fetched) >= ttl:
122+
if ttl != LIFETIME and (time.monotonic() - fetched) >= ttl:
110123
self._transport.counters.increment("cache_misses")
111124
return _MISS
112125
self._transport.counters.increment("cache_hits")
113126
return value
114127

115128
def _remember(self, key: tuple[Any, ...], value: Any) -> Any:
116-
if self._config.snapshot_cache_ttl > 0:
129+
if self._caching():
117130
self._resolved[key] = (time.monotonic(), value)
118131
return value
119132

‎src/forward_sdk/config.py‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,13 @@ class ClientConfig:
9999
Forward uses the network's latest processed snapshot.
100100
user_agent: Extra token identifying the calling application.
101101
cache_ttl: Seconds to reuse cached reads, or ``0`` to disable.
102-
snapshot_cache_ttl: Seconds to reuse a resolved snapshot, or ``0``, the
103-
default, to resolve every time. Off by default because only the
104-
caller knows how long "latest" should stay true, and a stale
105-
snapshot fails silently: it returns real data from the wrong moment.
106-
Set it when a run pins one point in time, which is the usual case
107-
for a sync.
102+
snapshot_cache_ttl: How long to reuse a resolved snapshot. ``0``, the
103+
default, resolves every time. ``"lifetime"`` resolves once and
104+
keeps that answer for as long as the client lives, which is what a
105+
run holding one point in time wants. A number of seconds suits
106+
repeated independent reads, and is the wrong shape for a pinned
107+
run: expiring mid-run lets the next resolution return a different
108+
snapshot, so the run straddles two moments with nothing raising.
108109
"""
109110

110111
base_url: str
@@ -128,7 +129,7 @@ class ClientConfig:
128129
snapshot_id: str | None = None
129130
user_agent: str | None = None
130131
cache_ttl: float = 60.0
131-
snapshot_cache_ttl: float = 0.0
132+
snapshot_cache_ttl: float | Literal["lifetime"] = 0.0
132133
proxy: str | None = None
133134
trust_env: bool = True
134135

@@ -248,7 +249,9 @@ def config_from_env(environ: dict[str, str] | None = None, **overrides: Any) ->
248249
settings["retries"] = int(retries)
249250
snapshot_ttl = _env("SNAPSHOT_CACHE_TTL", environ)
250251
if snapshot_ttl:
251-
settings["snapshot_cache_ttl"] = float(snapshot_ttl)
252+
settings["snapshot_cache_ttl"] = (
253+
"lifetime" if snapshot_ttl.lower() == "lifetime" else float(snapshot_ttl)
254+
)
252255

253256
settings.update(overrides)
254257
return settings
@@ -267,7 +270,7 @@ def build_config(
267270
snapshot_id: str | None = None,
268271
user_agent: str | None = None,
269272
cache_ttl: float = 60.0,
270-
snapshot_cache_ttl: float = 0.0,
273+
snapshot_cache_ttl: float | Literal["lifetime"] = 0.0,
271274
proxy: str | None = None,
272275
trust_env: bool = True,
273276
stream_read_timeout: float = DEFAULT_STREAM_READ_TIMEOUT,
@@ -297,7 +300,9 @@ def build_config(
297300
snapshot_id=snapshot_id,
298301
user_agent=user_agent,
299302
cache_ttl=max(0.0, cache_ttl),
300-
snapshot_cache_ttl=max(0.0, snapshot_cache_ttl),
303+
snapshot_cache_ttl=(
304+
"lifetime" if snapshot_cache_ttl == "lifetime" else max(0.0, float(snapshot_cache_ttl))
305+
),
301306
proxy=proxy,
302307
trust_env=trust_env,
303308
)

‎tests/_async/test_core_services.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,49 @@ async def test_uploading_invalidates_it(self, recorder: Recorder, tmp_path: Path
386386
assert before is not None and before.id == "9"
387387
assert after is not None and after.id == "10"
388388

389+
async def test_lifetime_never_expires(
390+
self, recorder: Recorder, monkeypatch: pytest.MonkeyPatch
391+
) -> None:
392+
"""A run holding one point in time must not have it change underneath.
393+
394+
A TTL is the wrong shape for that: expiring mid-run lets the next
395+
resolution return a newer snapshot, so the run straddles two moments
396+
with nothing raising and the data real on both sides. "lifetime" is the
397+
shape that matches the invariant a sync actually relies on, which is
398+
that it pinned one snapshot and snapshots are immutable.
399+
"""
400+
now = [1000.0]
401+
monkeypatch.setattr("forward_sdk._async.services.snapshots.time.monotonic", lambda: now[0])
402+
recorder.add("GET", SNAPSHOTS, json_response({"snapshots": [snapshot("9")]}))
403+
async with make_client(recorder, snapshot_cache_ttl="lifetime") as client:
404+
first = await client.snapshots.latest_processed()
405+
now[0] += 86_400
406+
second = await client.snapshots.latest_processed()
407+
408+
assert first is not None and second is not None
409+
assert first.id == second.id == "9"
410+
assert recorder.count("GET", SNAPSHOTS) == 1
411+
412+
async def test_lifetime_still_yields_to_an_upload(
413+
self, recorder: Recorder, tmp_path: Path
414+
) -> None:
415+
"""Never expiring is not the same as never being wrong.
416+
417+
The client that uploaded a snapshot knows the answer changed, so
418+
"lifetime" means "until something makes it wrong", not "forever".
419+
"""
420+
archive = tmp_path / "snap.zip"
421+
archive.write_bytes(b"zip")
422+
recorder.add("GET", SNAPSHOTS, json_response({"snapshots": [snapshot("9")]}))
423+
recorder.add("POST", SNAPSHOTS, json_response(snapshot("10")))
424+
recorder.add("GET", SNAPSHOTS, json_response({"snapshots": [snapshot("10")]}))
425+
async with make_client(recorder, snapshot_cache_ttl="lifetime") as client:
426+
await client.snapshots.latest_processed()
427+
await client.snapshots.upload([str(archive)])
428+
after = await client.snapshots.latest_processed()
429+
430+
assert after is not None and after.id == "10"
431+
389432
async def test_clear_cache_forces_a_fresh_resolution(self, recorder: Recorder) -> None:
390433
recorder.add("GET", SNAPSHOTS, json_response({"snapshots": [snapshot("9")]}))
391434
recorder.add("GET", SNAPSHOTS, json_response({"snapshots": [snapshot("11")]}))

‎tests/_sync/test_core_services.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,47 @@ def test_uploading_invalidates_it(self, recorder: Recorder, tmp_path: Path) -> N
385385
assert before is not None and before.id == "9"
386386
assert after is not None and after.id == "10"
387387

388+
def test_lifetime_never_expires(
389+
self, recorder: Recorder, monkeypatch: pytest.MonkeyPatch
390+
) -> None:
391+
"""A run holding one point in time must not have it change underneath.
392+
393+
A TTL is the wrong shape for that: expiring mid-run lets the next
394+
resolution return a newer snapshot, so the run straddles two moments
395+
with nothing raising and the data real on both sides. "lifetime" is the
396+
shape that matches the invariant a sync actually relies on, which is
397+
that it pinned one snapshot and snapshots are immutable.
398+
"""
399+
now = [1000.0]
400+
monkeypatch.setattr("forward_sdk._sync.services.snapshots.time.monotonic", lambda: now[0])
401+
recorder.add("GET", SNAPSHOTS, json_response({"snapshots": [snapshot("9")]}))
402+
with make_client(recorder, snapshot_cache_ttl="lifetime") as client:
403+
first = client.snapshots.latest_processed()
404+
now[0] += 86_400
405+
second = client.snapshots.latest_processed()
406+
407+
assert first is not None and second is not None
408+
assert first.id == second.id == "9"
409+
assert recorder.count("GET", SNAPSHOTS) == 1
410+
411+
def test_lifetime_still_yields_to_an_upload(self, recorder: Recorder, tmp_path: Path) -> None:
412+
"""Never expiring is not the same as never being wrong.
413+
414+
The client that uploaded a snapshot knows the answer changed, so
415+
"lifetime" means "until something makes it wrong", not "forever".
416+
"""
417+
archive = tmp_path / "snap.zip"
418+
archive.write_bytes(b"zip")
419+
recorder.add("GET", SNAPSHOTS, json_response({"snapshots": [snapshot("9")]}))
420+
recorder.add("POST", SNAPSHOTS, json_response(snapshot("10")))
421+
recorder.add("GET", SNAPSHOTS, json_response({"snapshots": [snapshot("10")]}))
422+
with make_client(recorder, snapshot_cache_ttl="lifetime") as client:
423+
client.snapshots.latest_processed()
424+
client.snapshots.upload([str(archive)])
425+
after = client.snapshots.latest_processed()
426+
427+
assert after is not None and after.id == "10"
428+
388429
def test_clear_cache_forces_a_fresh_resolution(self, recorder: Recorder) -> None:
389430
recorder.add("GET", SNAPSHOTS, json_response({"snapshots": [snapshot("9")]}))
390431
recorder.add("GET", SNAPSHOTS, json_response({"snapshots": [snapshot("11")]}))

‎tests/unit/test_config.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,24 @@ def test_user_agent_identifies_the_sdk_and_the_application() -> None:
156156
assert agent.startswith("forward-sdk/")
157157
assert "python-httpx/" in agent
158158
assert agent.endswith("my-app/2.0")
159+
160+
161+
class TestSnapshotCacheTtl:
162+
def test_off_by_default(self) -> None:
163+
assert build_config("https://forward.test").snapshot_cache_ttl == 0.0
164+
165+
def test_lifetime_survives_config_building(self) -> None:
166+
config = build_config("https://forward.test", snapshot_cache_ttl="lifetime")
167+
assert config.snapshot_cache_ttl == "lifetime"
168+
169+
def test_a_negative_ttl_is_off_rather_than_an_error(self) -> None:
170+
assert build_config("https://forward.test", snapshot_cache_ttl=-5).snapshot_cache_ttl == 0.0
171+
172+
def test_read_from_the_environment(self) -> None:
173+
environ = {"FORWARD_URL": "https://forward.test", "FORWARD_SNAPSHOT_CACHE_TTL": "120"}
174+
assert config_from_env(environ)["snapshot_cache_ttl"] == 120.0
175+
176+
def test_lifetime_read_from_the_environment(self) -> None:
177+
"""Spelled as a word, so a deployment can pin a run without a magic number."""
178+
environ = {"FORWARD_URL": "https://forward.test", "FORWARD_SNAPSHOT_CACHE_TTL": "lifetime"}
179+
assert config_from_env(environ)["snapshot_cache_ttl"] == "lifetime"

0 commit comments

Comments
 (0)