fix: stale-on-append datatree cache + graceful datetime sel (#118)#119
Closed
lhoupert wants to merge 1 commit into
Closed
fix: stale-on-append datatree cache + graceful datetime sel (#118)#119lhoupert wants to merge 1 commit into
lhoupert wants to merge 1 commit into
Conversation
Two titiler-side defects made the newest appended slice of an S1 RTC cube
unrenderable. Rebased onto current main (cache now uses EOPFCacheSettings with
nested redis + metadata_ttl + RedisError resilience).
Fix 1 — datatree cache stale-on-append (titiler/eopf/reader.py)
Opens were cached keyed only by src_path, in-process via functools.cache and in
Redis, so an append was invisible until TTL and flapped 500/400 across replicas
with no working invalidation. Fold a store-version token into the cache key:
HEAD the root zarr.json (zarr v3 consolidated metadata) and key on
{src_path}#{etag}, in both the in-process LRU and Redis. An append rewrites
zarr.json -> ETag changes -> new key -> fresh read; every replica computes the
same key. Probe failure falls back to the bare src_path key (a render never
fails because the probe did). This also defeats the in-process functools.cache
pinning, the stickiest stale layer per replica.
Perf guards (part of the design):
- _get_store is memoized — no Boto3CredentialProvider/S3Store rebuild per
request (the probe runs per render).
- _store_version is throttled to one HEAD per version_probe_ttl per path,
collapsing the per-tile HEAD storm a single viewport would cause.
- The memo is bounded lru_cache(maxsize=64) instead of unbounded
functools.cache, so version-keying can't leak one datatree per append.
Redis read/write keep the upstream RedisError resilience; TTL is metadata_ttl.
Fix 2 — int() crash on a datetime sel (titiler/eopf/reader.py)
da[dim].dtype.type(v) ran np.int64("2026-...") on a stale int64 time axis ->
ValueError -> HTTP 500. Wrapped in try/except -> BadRequestError (400, already
mapped). No datetime branch added: dtype.type is already np.datetime64 for a
datetime axis, so the happy path is unchanged.
Config / helm
- version_probe_ttl added to EOPFCacheSettings.
- Helm now emits TITILER_EOPF_CACHE_METADATA_TTL (the datatree TTL the reader
reads) from cache.ttl.datasets, closing the config mismatch the issue flagged.
Tests / verification
- 109 tests pass; pre-commit (ruff, isort, mypy) clean.
- Cache correctness: append -> new key + reread; probe-failure fallback;
HEAD-failure -> None.
- Deterministic perf invariants (call counts, not timing): one HEAD per window,
store built once, no reopen on a stable token, bounded memo.
- sel: int64 axis -> BadRequestError; datetime64 happy path preserved.
- CI benchmarks extended (warm-open, sel-tile). Warm open ~0.4ms vs cold
~5.8ms, confirming the probe throttle + memo on the hot path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7764860 to
7921933
Compare
| _open_dataset_cached.cache_clear() | ||
| _get_store.cache_clear() | ||
| with _version_cache_lock: | ||
| _version_cache.clear() |
Collaborator
There was a problem hiding this comment.
😬 this adds a lot of complexity
I will need a bit of time to review
Contributor
Author
There was a problem hiding this comment.
The irreducible fix is really two things:
- compute a version token from the store's root zarr.json ETag, and
- put it in the cache key (and key the in-process memo) on (src_path, version) (i.e. lru_cache instead of @cache(src_path)) so an append is observed in both Redis and the per-process memo.
Contributor
Author
There was a problem hiding this comment.
Everything else is optional hardening so the probe is free:
- _get_store memoization only avoids rebuilding the boto3 credential provider per request on the S3 + AWS_PROFILE path.
- _store_version_cached (the dict + lock TTL throttle) : only collapses the per-tile HEAD "storm" to ~1 HEAD per version_probe_ttl per path. (It is a pure perf addition suggested by claude when I asked it to review the performance, correctness doesn't depend on it)
- the open_dataset.cache_clear alias : it is only there to keep the existing benchmark's cache_clear() call working.
Collaborator
There was a problem hiding this comment.
Could we add the optional things in another PR?
Contributor
Author
There was a problem hiding this comment.
ok will work on that
Contributor
Author
There was a problem hiding this comment.
ok I splitted in 3 PRs! Closing this one now
This was referenced Jun 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #118.
Two titiler-side defects made the newest appended slice of an S1 RTC cube unrenderable. Both are fixed here; the producer side (CF-encoding
timeat every multiscale level) was already done.Fix 1 — datatree cache stale-on-append (
titiler/eopf/reader.py)Datatree opens were cached keyed only by
src_path(in-process viafunctools.cacheand in Redis), so an append was invisible until TTL and flapped 500/400 across ~40 replicas with no working invalidation.zarr.json(zarr v3 consolidated metadata) and key on{src_path}#{etag}, in both the in-process LRU and Redis. An append rewriteszarr.json→ ETag changes → new key → fresh read; every replica computes the same key; stale keys self-expire. Probe failure falls back to the baresrc_pathkey (a render never fails because the probe did).functools.cachepinning (not mentioned in the issue but the stickiest stale layer per replica) by making the version token part of the in-process key too.Perf guards (part of the design):
_get_storeis memoized — noBoto3CredentialProvider/S3Storerebuild per request._store_versionis throttled to one HEAD perversion_probe_ttlper path, collapsing the per-tile HEAD storm a single viewport would cause. Bounded staleness window (seconds) is fine for an append cron.lru_cache(maxsize=64)(vs prior unbounded@cache), so version-keying can't leak one datatree per append.Fix 2 —
int()crash on a datetimesel(titiler/eopf/reader.py)da[dim].dtype.type(v)rannp.int64("2026-06-08T17:38:52")on a stale int64timeaxis →ValueError→ HTTP 500. Wrapped intry/except→BadRequestError(400, already mapped). No datetime branch added —dtype.typeis alreadynp.datetime64for a datetime axis, so the happy path is unchanged.Config
datasets_ttl/version_probe_ttlon the legacyCacheSettings.TITILER_EOPF_CACHE_REDIS_DATASETS_TTL(the var the reader actually reads) fromcache.ttl.datasets— closing the config mismatch the issue flagged.Tests / verification
sel: int64 axis →BadRequestError; datetime64 happy path preserved.sel-tile) so the github-action-benchmark job alerts on regression. Locally: warm open397µsvs cold9,404µs(23×), confirming the probe throttle + memo on the hot path.🤖 Generated with Claude Code