fix: clear extension caches by pattern where Redis can, as intended - #2219
Conversation
The fast path never ran. It tests `cache instanceof RedisCacheWriter`, but the cache a RedisCacheManager hands out is a RedisCache, which extends AbstractValueAdaptingCache and holds its writer behind getNativeCache(). So every Redis deployment has been taking the fallback: an extension with 200 versions costs (3 aliases + 200 versions) x 13 target platforms = 2639 evictions of keys that mostly do not exist, each one a round trip, on the request thread - which is what makes deleting a review slow (#1767). Even reached, it would have matched nothing: the pattern was built here without the key prefix that RedisCacheManager stores keys under. RedisCache#clear(String) is the API that fits - it runs the pattern through the cache's own prefix before handing it to the writer. Pattern clearing also cannot miss, where guessing can: a version that was just deleted is no longer among the ones the fallback enumerates, so its cached JSON survives until the TTL. Also drops the null check on getVersions(), which never returns null - it initialises the list on first access. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Redis patterns could evict caches belonging to extension IDs with shared prefixes.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR improves extension cache eviction by using Redis pattern clearing while retaining key-by-key fallback behavior.
Changes:
- Adds Redis-aware pattern eviction.
- Removes the obsolete null-version guard.
- Adds tests for Redis and fallback paths.
File summaries
| File | Summary |
|---|---|
server/src/test/java/org/eclipse/openvsx/cache/CacheServiceEvictionTest.java |
Tests Redis pattern clearing and fallback eviction behavior. |
server/src/main/java/org/eclipse/openvsx/cache/CacheService.java |
Implements pattern-based eviction; patterns may be too broad and require delimiter-aware matching plus regression coverage. |
Review details
Suppressed comments (1)
server/src/main/java/org/eclipse/openvsx/cache/CacheService.java:180
- The same unbounded
foo.bar*pattern is used for the latest-version Redis cache, so an extension such asfoo.bar2can have its latest-version entries deleted whenfoo.baris evicted. Include the-delimiter in this wildcard as well (for examplefoo.bar-*) and cover the prefix-collision case.
if (clearByPattern(cache, latestExtensionVersionCacheKey.generateWildcard(extension))) {
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review follow-up: `foo.bar*` also matches `foo.bar2-1.0.0`, so clearing one extension dropped a same-prefix sibling's entries too. The keys are `<namespace>.<extension>-<version>[@<platform>]`, so the separator belongs in the pattern: `foo.bar-*`. That narrows it rather than closing it. A version may contain a `-` of its own (1.0.0-rc.1), so nothing separates the keys of `bar` from those of `bar-baz`, and the two still evict each other. What is left is over-eviction between same-prefix siblings of one namespace - a recomputation, not a wrong answer. Making it exact needs a key separator that a name cannot contain, which is a change to the key format rather than to the pattern. The tests match the glob themselves, so they are about the pattern we generate rather than what a particular Redis does with it: every key shape the key-by-key fallback would evict is matched, a sibling and another namespace are not, and the hyphenated sibling is recorded as the limit it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folding in the rest of the wildcard problem. `foo.bar-*` stopped `foo.bar2`
but not `foo.bar-baz`: a name may contain a `-` and so may a version, so
nothing in `foo.bar-baz-1.0.0` says which `-` ends the extension id.
The keys were borrowing NamingUtil.toFileFormat, which exists for naming
files - the .vsix names, the rename-downloads migration - and is not free to
change. The caches get their own format instead, ending the id with a
character a name cannot contain:
foo.bar:1.0.0@linux-x64 prefix "foo.bar:"
foo.bar-baz:1.0.0 prefix "foo.bar-baz:"
`:` is outside `[\w\-\+\$~]+`, the names the validator accepts, so the prefix
now matches one extension and no other. That holds for the Redis glob and for
any prefix scan built on it. All three generators share the id part, so the
two cannot drift apart.
Names are escaped rather than trusted: the character set is what the validator
enforces now, and rows older than it, or mirrored from elsewhere, were never
held to it.
Nothing persisted changes. Existing entries become unreachable and age out by
their TTL; during a rolling deploy both formats coexist, each pod reading what
it wrote.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical issues remain with legacy-key compatibility and Redis glob metacharacter escaping.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 2
- Review effort level: Lite
Review follow-up. The escaping claimed to handle names that were never held to the validator's character set, but only covered the terminator - and a key prefix becomes a Redis glob, where `* ? [ ] \` are syntax rather than text. A stored name like `bar*` turned `foo.bar*:*` into a pattern that also swept `foo.bar-baz`, which is the ambiguity the terminator exists to remove. Every byte outside `[a-z0-9_+$~-]` is percent-encoded now, so the pattern is literal whatever the stored name happens to be. A valid name is unchanged, so this only moves keys that could not have been published in the first place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unicode key collisions and blocking Redis keyspace scans remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
server/src/main/java/org/eclipse/openvsx/cache/ExtensionJsonCacheKeyGenerator.java:90
- Lowercasing the whole string before encoding breaks the promised isolation for legacy/mirrored names. Unicode case mappings can collapse an invalid name into valid ASCII—for example, Java maps
Ktok—sofoo.Kandfoo.kreceive the same cache prefix. That can serve one extension's cached value for the other and makes either wildcard clear remove both. Lowercase only ASCIIA-Z, and percent-encode every other unsafe byte from the original string.
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
A cache writer built from the connection factory alone uses BatchStrategies.keys(), which clears a pattern by running KEYS over the whole keyspace - a single command that blocks the server until it finishes. Clearing by pattern is not an occasional thing here: it is what every eviction does, on every publish, deprecation and review. The manager is built on a writer with BatchStrategies.scan instead, which walks the keyspace with a cursor and deletes in batches, so the server keeps answering in between. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up. Snapshotting the versions before deferring put an O(versions) query back on the request thread for every eviction - and the pattern clear, which is what a Redis-backed cache takes, is told the extension's name and nothing else. That is the work #2219 exists to remove, reintroduced one PR later. The snapshot now happens only where the keys have to be guessed, so the lazy association is left alone on the path that does not read it. Once the local caches clear by prefix as well, nothing reads it at all and the snapshot goes with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up. Snapshotting the versions before deferring put an O(versions) query back on the request thread for every eviction - and the pattern clear, which is what a Redis-backed cache takes, is told the extension's name and nothing else. That is the work #2219 exists to remove, reintroduced one PR later. The snapshot now happens only where the keys have to be guessed, so the lazy association is left alone on the path that does not read it. Once the local caches clear by prefix as well, nothing reads it at all and the snapshot goes with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: evict caches after the transaction commits, off the request thread Second part of #1767. Eviction runs inside the transaction today, which is both slower than it needs to be and wrong in a way that outlives the request: a cache evicted before the commit can be refilled by a concurrent read from the row as it still is, and that stale entry then survives the commit - worse than not evicting at all. ExtensionService#purgeExtension already says this in a comment ("evict the cache entries only after the changes have been committed") while doing it inline. AfterCommitExecutor registers a transaction synchronization and hands the work to the application task executor once the transaction has committed; a rollback runs nothing, and without a transaction the work goes straight to the executor. So the eviction leaves the request path entirely, which is what makes deleting a review slow on an extension with many versions. Callers now read the names they evict by before handing the task over: it runs on another thread with no persistence context, where a captured entity may be detached and its lazy versions unreadable. Hence the name-based overloads on the key generators. The file caches stay synchronous: they are keyed by the file just written rather than by a row, and their callers are not in a transaction. CacheServiceTest drives evictions inside a transaction that is rolled back, so it overrides the executor to run inline - those tests are about what gets evicted, and AfterCommitExecutorTest is about when. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: run the deferred eviction on the calling thread Dropping the executor. Taking the eviction off the request path was the point when it cost thousands of guesses; now that it is one pattern clear there is little left to save, and a background task is one more thing that can be lost on a restart or fail unnoticed. What stays is the part that fixes something: waiting for the commit. The class keeps its shape so the choice is one line if it ever pays to revisit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: do not load the versions for an eviction that will not read them Review follow-up. Snapshotting the versions before deferring put an O(versions) query back on the request thread for every eviction - and the pattern clear, which is what a Redis-backed cache takes, is told the extension's name and nothing else. That is the work #2219 exists to remove, reintroduced one PR later. The snapshot now happens only where the keys have to be guessed, so the lazy association is left alone on the path that does not read it. Once the local caches clear by prefix as well, nothing reads it at all and the snapshot goes with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: say where the eviction runs, now that it is not off the request thread Left over from the revision that handed the work to an executor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
First part of #1767 — see the analysis there.
The fast path never ran
cachecomes fromcacheManager.getCache(...), and withovsx.redis.enabled=truethat is aRedisCache. From spring-data-redis 4.0.6, the version we build against:So every Redis deployment has been taking the fallback instead: an extension with 200 versions costs
(3 aliases + 200 versions) × 13 target platforms = 2639evictions of keys that mostly do not exist, each a round trip, on the request thread. That is what makes deleting a review slow.Even if the branch had been reachable it would have matched nothing: the pattern was built here without the prefix
RedisCacheManagerstores keys under.RedisCache.clear(String)is the right API — its bytecode runs the pattern throughcreateAndConvertCacheKey, which applies that prefix, before callingclean.A key format the prefix can match exactly
Review found that
foo.bar*also sweptfoo.bar2, and scoping it tofoo.bar-*only narrowed that: a name may contain a-and so may a version, so nothing separated the keys ofbarfrom those ofbar-baz.The keys were borrowing
NamingUtil.toFileFormat, which exists for naming files — the.vsixnames, the rename-downloads migration — and is not free to change. The caches get their own format, ending the extension id with a character a name cannot contain::is outside[\w\-\+\$~]+, the namesExtensionValidatoraccepts, so a prefix now matches one extension and no other.Names are encoded, not trusted. That character set is what the validator enforces now; rows older than it, or mirrored from elsewhere, were never held to it. A prefix also becomes a Redis glob, where
* ? [ ] \are syntax — a stored name likebar*would widen the pattern back over its siblings. Every byte outside[a-z0-9_+$~-]is percent-encoded, so the pattern stays literal whatever the stored name is, and a valid name is byte-identical.Clearing without blocking Redis
Pattern clearing is no longer occasional — it is what every eviction does. A cache writer built from the connection factory alone uses
BatchStrategies.keys(), which runsKEYSover the whole keyspace in one command and blocks the server until it finishes. The manager is built on a writer withBatchStrategies.scan(256)instead, which walks the keyspace with a cursor and deletes in batches.Deploying it
Nothing persisted changes; this is a cache key format. Entries in the old format become unreachable and expire on their own.
One thing to know for a rolling deployment with a shared Redis: while both formats are in flight, a change committed by a pod on one format does not invalidate what a pod on the other has cached, so either can briefly serve stale JSON. Clearing the caches once after the rollout — the Caches page in the admin dashboard — closes that window. This is deliberately not handled in code: a transitional dual-format eviction would be a second clear per eviction that exists only to be removed a release later.
Testing
CacheServiceEvictionTestpins which way an eviction goes and what the pattern may sweep:clear("foo.bar:*")and no key-by-key eviction where the cache is aRedisCache, the ≥2600 guesses where it is not;foo.bar2,foo.bar-bazand another namespace are not;*,?,[,\,., space), are encoded rather than widening the pattern;RedisCacheManagerScanTestdrives a cache built byCacheConfigitself over a mocked connection:scanis used andkeysnever is, and the capturedScanOptionscarryextension.json::foo.bar:*— which also shows the cache-name prefix being applied byRedisCache.clear(String), as described above. Both fail against the previous one-line configuration.The glob is matched in-process, so these are about the pattern we generate rather than what a particular Redis does with it.
Confirmed not vacuous — against the current
main, three of the original four fail.Full server suite green (1434 tests); pre-commit hooks pass.
Note for the reviewer
This touches
CacheService, which #2215 also touches (it adds CDN purge calls to the same methods). The two are independent; whichever lands second needs a small textual merge.🤖 Generated with Claude Code