Skip to content

fix: stale-on-append datatree cache + graceful datetime sel (#118)#119

Closed
lhoupert wants to merge 1 commit into
mainfrom
fix/issue-118-stale-cache-and-sel
Closed

fix: stale-on-append datatree cache + graceful datetime sel (#118)#119
lhoupert wants to merge 1 commit into
mainfrom
fix/issue-118-stale-cache-and-sel

Conversation

@lhoupert

Copy link
Copy Markdown
Contributor

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 time at 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 via functools.cache and in Redis), so an append was invisible until TTL and flapped 500/400 across ~40 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; stale keys self-expire. 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 (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_store is memoized — no Boto3CredentialProvider/S3Store rebuild per request.
  • _store_version is throttled to one HEAD per version_probe_ttl per path, collapsing the per-tile HEAD storm a single viewport would cause. Bounded staleness window (seconds) is fine for an append cron.
  • The in-process memo is bounded lru_cache(maxsize=64) (vs prior unbounded @cache), so version-keying can't leak one datatree per append.

Fix 2 — int() crash on a datetime sel (titiler/eopf/reader.py)

da[dim].dtype.type(v) ran np.int64("2026-06-08T17:38:52") on a stale int64 time axis → ValueError → HTTP 500. Wrapped in try/exceptBadRequestError (400, already mapped). No datetime branch added — dtype.type is already np.datetime64 for a datetime axis, so the happy path is unchanged.

Config

  • Configurable datasets_ttl / version_probe_ttl on the legacy CacheSettings.
  • Helm now also emits TITILER_EOPF_CACHE_REDIS_DATASETS_TTL (the var the reader actually reads) from cache.ttl.datasets — closing the config mismatch the issue flagged.

Tests / verification

  • 143 tests pass (+14 new); pre-commit (ruff, ruff-format, 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) so the github-action-benchmark job alerts on regression. Locally: warm open 397µs vs cold 9,404µs (23×), confirming the probe throttle + memo on the hot path.

🤖 Generated with Claude Code

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>
@lhoupert lhoupert force-pushed the fix/issue-118-stale-cache-and-sel branch from 7764860 to 7921933 Compare June 17, 2026 20:44
Comment thread titiler/eopf/reader.py
_open_dataset_cached.cache_clear()
_get_store.cache_clear()
with _version_cache_lock:
_version_cache.clear()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😬 this adds a lot of complexity

I will need a bit of time to review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The irreducible fix is really two things:

  1. compute a version token from the store's root zarr.json ETag, and
  2. 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add the optional things in another PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok will work on that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok I splitted in 3 PRs! Closing this one now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Datatree cache stale-on-append + int() crash on datetime sel break newest-slice renders

2 participants