feat(cache): NATS per-key TTL + init lock TTL on NATS#9769
Conversation
Merging this PR will degrade performance by 24.25%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | test_base_schema_duplicate_CoreProposedChange |
1.5 ms | 2.2 ms | -32.72% |
| ❌ | test_schemabranch_duplicate |
6.6 ms | 7.7 ms | -14.71% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing fac/nats-perkey-ttl-init-locks (36d0962) with stable (0f9a929)
There was a problem hiding this comment.
No issues found across 12 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Shadow auto-approve: would require human review. Major dependency bump (nats-py 2.7->2.15) and core cache/lock refactor; requires human review.
Re-trigger cubic
3cb86ce to
b118fff
Compare
9f350c8 to
74076cc
Compare
b118fff to
0b3dfe5
Compare
74076cc to
cb948da
Compare
cb948da to
bf65ecd
Compare
bf65ecd to
ed03fb6
Compare
|
Not an important one, we do not officially support NATS today anyway. |
| def _tokenize_key_name(key: str) -> str: | ||
| return key.replace(":", ".") | ||
|
|
||
| # FIXME: remove once NATS supports TTL for keys (2.11) |
There was a problem hiding this comment.
wow. a FIXME getting cleaned up. nice to see
There was a problem hiding this comment.
personal affair to me, happy to clean that up 2 years later :D
| async def set(self, key: str, value: str, expires: KVTTL | None = None, not_exists: bool = False) -> bool | None: | ||
| return await self.connection.set(name=key, value=value, ex=expires.value if expires else None, nx=not_exists) | ||
| async def set(self, key: str, value: str, expires: int | None = None, not_exists: bool = False) -> bool | None: | ||
| # int() normalizes both plain ints and KVTTL (IntEnum) members to a value redis-py can encode. |
There was a problem hiding this comment.
the comment makes it look like the typing should be expires: int | KVTTL | None = None, but I don't think that's right
There was a problem hiding this comment.
yeah dumb agent, we should keep expires: KVTTL | None = None actually since all callers use KVTTL
good catch ty
There was a problem hiding this comment.
I kept the shared set signature as expires: int | None (seconds) rather than KVTTL | None. It turns out not all callers use KVTTL: the NATS init-lock TTL passes a configurable seconds value (init_lock_ttl_mins * 60, default 1200), which isn't a KVTTL member. Since InfrahubCache.set is a single shared signature, narrowing only RedisCache to KVTTL while the base stays broader would be an LSP violation.
Existing cache callers still pass KVTTL (fine, it's an IntEnum). I removed the misleading comment; redis just coerces the IntEnum to a plain int for the driver. Happy to switch to a dedicated KVTTL-typed cache API + a separate seconds path for the lock if you'd prefer keeping expires strictly KVTTL.
There was a problem hiding this comment.
Updated: typed the cache set expiry as KVTTL | int | None across the abstract and both adapters. Cache callers keep passing KVTTL; the NATS init lock passes its configurable seconds value as an int. (A single shared type is required — ty rejects a narrower KVTTL override of an int abstract as an LSP violation.)
4102cb5 to
40eceb1
Compare
Bump nats-py to 2.15.0 and rewrite the NATS cache to use per-message (per-key) TTL, available since NATS server 2.11. Previously the adapter maintained a separate KV bucket per supported TTL and routed keys by prefix into the matching bucket; the per-key expiry passed by callers was ignored on NATS. The adapter now uses a single KV bucket and applies the caller-supplied expiry directly via msg_ttl, bringing NATS in line with the Redis backend. Existing buckets created before per-message TTL are upgraded in place. The cache `set` expiry accepts a KVTTL or a plain int number of seconds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NATSLock.acquire() did not accept the token passed by InfrahubLock, so acquiring any NATS-backed distributed lock raised a TypeError. Accept the token and reuse it, so the NATS lock stores the same timestamp::worker_id value the Redis lock does (and the deadlock cleaner can parse it). With per-key TTL now available on the NATS cache, NATSLock applies the lock TTL via the cache expiry, so global initialization locks auto-expire on NATS exactly as they do on Redis. A TTL'd lock is released only if we still own it, so an expired-and-reacquired lock is not clobbered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
40eceb1 to
36d0962
Compare
Summary
Builds on the Redis init-lock TTL work (this PR targets that branch). It modernizes the NATS cache backend and brings the global initialization-lock TTL safety net to NATS as well, so a worker that dies mid-initialization can no longer deadlock startup regardless of cache driver.
Key Changes
Commit 1 —
feat(cache): switch NATS cache to per-key TTLnats-py2.7.2 → 2.15.0.msg_ttl, so NATS finally honors the same per-key expiry Redis already did. Existing buckets are upgraded in place to enable per-message TTL.set(expires=…)value is now seconds (KVTTLmembers remain valid as they are integers).RedisCachealigned accordingly.nats:2.11-alpine.Commit 2 —
fix(lock): enforce init lock TTL on the NATS backendNATSLock.acquire()did not accept thetokenInfrahubLockpasses, so acquiring any NATS lock raised aTypeError.Related Context
Follow-up to the Redis global-init-lock TTL (issue #9277). The TTL value/config (
INFRAHUB_CACHE_INIT_LOCK_TTL_MINS, default 20 min) is defined on the base branch and reused here.Test Plan
Validated against a real NATS server (
nats:2.11-alpine, server v2.11.17) with the NATS cache driver:INFRAHUB_CACHE_DRIVER=nats uv run pytest backend/tests/component/services/adapters/nats— cache per-key TTL (create + put paths), no-TTL persistence, list_keys; init lock auto-expiry, configured-TTL wiring, acquire/release cycle.uv run pytest backend/tests/component/services/adapters/redis/test_redis.pyand.../redis/test_lock_ttl.py— Redis path unchanged.uv run pytest backend/tests/unit/test_lock.py backend/tests/unit/adapters/test_lock.py— local/Redis lock units.ruff,tyclean on changed files.Notes
nats-pybump (publish/subscribe/add_stream/add_consumer signatures unchanged;publishgains an optionalmsg_ttl).Summary by cubic
Switches the NATS cache to real per-key TTL and adds a configurable TTL to all global initialization locks so a crashed worker can’t block startup. Follow-up to #9277, aligning NATS with Redis behavior and defaulting TTL to 20 minutes via
INFRAHUB_CACHE_INIT_LOCK_TTL_MINS.New Features
global.init,global.taskmgr.init,global.worker.taskmgr.init) now auto-expire on both Redis and NATS; TTL is passed in seconds across caches.cache.set(expires=...)now takes seconds;KVTTLvalues still work since they’re ints.RedisCacheand NATS both accept int seconds.nats-pyto2.15.0; tests pinnats:2.14.3-alpine.Bug Fixes
NATSLock.acquire()now accepts an optional token to matchInfrahubLockand keeps thetimestamp::worker_idvalue.LockNotOwnedError; NATS checks the token).Written for commit 36d0962. Summary will update on new commits.