Skip to content

fix: clear extension caches by pattern where Redis can, as intended - #2219

Merged
netomi merged 5 commits into
mainfrom
fix/redis-pattern-eviction
Sep 16, 2026
Merged

netomi merged 5 commits into
mainfrom
fix/redis-pattern-eviction

Conversation

@netomi

@netomi netomi commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

First part of #1767 — see the analysis there.

The fast path never ran

if (cache instanceof RedisCacheWriter redisCache) {
    redisCache.clear(CACHE_EXTENSION_JSON, extensionJsonCacheKey.generateWildcard(extension).getBytes());
    return;
}

cache comes from cacheManager.getCache(...), and with ovsx.redis.enabled=true that is a RedisCache. From spring-data-redis 4.0.6, the version we build against:

public class RedisCache extends AbstractValueAdaptingCache     ← not a RedisCacheWriter
public RedisCacheWriter RedisCache.getNativeCache()            ← the writer is behind this
public void RedisCache.clear(java.lang.String)                 ← what this wanted

So every Redis deployment has been taking the fallback instead: an extension with 200 versions costs (3 aliases + 200 versions) × 13 target platforms = 2639 evictions 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 RedisCacheManager stores keys under. RedisCache.clear(String) is the right API — its bytecode runs the pattern through createAndConvertCacheKey, which applies that prefix, before calling clean.

A key format the prefix can match exactly

Review found that foo.bar* also swept foo.bar2, and scoping it to foo.bar-* only narrowed that: a name may contain a - and so may a version, so nothing separated the keys of bar from those of bar-baz.

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, ending the extension 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 ExtensionValidator accepts, 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 like bar* 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 runs KEYS over the whole keyspace in one command and blocks the server until it finishes. The manager is built on a writer with BatchStrategies.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

CacheServiceEvictionTest pins which way an eviction goes and what the pattern may sweep:

  • one clear("foo.bar:*") and no key-by-key eviction where the cache is a RedisCache, the ≥2600 guesses where it is not;
  • every key shape the fallback would evict is matched — universal, target-platform, alias, semver pre-release — while foo.bar2, foo.bar-baz and another namespace are not;
  • a name carrying the terminator, and names carrying glob syntax (*, ?, [, \, ., space), are encoded rather than widening the pattern;
  • a valid name is left exactly as it is.

RedisCacheManagerScanTest drives a cache built by CacheConfig itself over a mocked connection: scan is used and keys never is, and the captured ScanOptions carry extension.json::foo.bar:* — which also shows the cache-name prefix being applied by RedisCache.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

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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 as foo.bar2 can have its latest-version entries deleted when foo.bar is evicted. Include the - delimiter in this wildcard as well (for example foo.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.

Comment thread server/src/main/java/org/eclipse/openvsx/cache/CacheService.java
@netomi
netomi added this pull request to stack #2221 September 15, 2026 20:46
netomi and others added 2 commits September 15, 2026 22:54
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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 to k—so foo.K and foo.k receive the same cache prefix. That can serve one extension's cached value for the other and makes either wildcard clear remove both. Lowercase only ASCII A-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

Comment thread server/src/main/java/org/eclipse/openvsx/cache/CacheService.java
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>
netomi added a commit that referenced this pull request Sep 16, 2026
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>
@netomi
netomi merged commit 3bea40e into main Sep 16, 2026
5 checks passed
@netomi
netomi deleted the fix/redis-pattern-eviction branch September 16, 2026 06:08
netomi added a commit that referenced this pull request Sep 16, 2026
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>
netomi added a commit that referenced this pull request Sep 16, 2026
* 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>
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.

2 participants