Skip to main content

JetCache @CacheInvalidate Fails to Clear the Cache? Silent Failure from Inconsistent Key Generation Rules

TL;DR​

  • The key in @Cached and @CacheInvalidate are two mutually independent SpEL expressions; JetCache never verifies that they are equivalent.
  • When the physical keys produced by the keyConvertor after evaluating each side differ, cache.remove(key) deletes nothing: no exception, no log line — and with a local cache, even the return value claims "success".
  • The pit we fell into: the write-side key was {#userId} (a SpEL inline list, a habit carried over from Spring Cache), while the invalidate-side key was #userId. One pair of brackets apart, the physical keys become [1] versus 1.
  • One-line takeaway: for the same cache name, the read, write, and delete sides must generate keys by exactly the same rule, otherwise the cleanup fails — with zero warnings.

The Symptom: Permissions Change, the Frontend Never Does​

The classic scenario in a permission center: the user's role list is served from a cache.

@Cached(name = "getUserPermissions", key = "{#userId}", expire = 3600, cacheType = CacheType.LOCAL)
List<SysRole> getUserPermissions(Long userId) { ... }

After the admin console adjusts a user's roles, the update endpoint carries the invalidation annotation:

@CacheInvalidate(name = "getUserPermissions", key = "#userId")

Then the strange part begins: roles changed, the user re-logs in, refreshes — and still gets the old permissions. 100% of the time.

Stranger still are the logs: the whole call chain is spotless — no exception, no WARN — as if @CacheInvalidate had merely "been executed". No condition, and name is the same string constant on both sides — the annotation logic is this simple, so what could possibly break?

So we went straight to where the annotation AOP actually does the work, and set a breakpoint: CacheHandler#doInvalidate.

Breakpoint 1: The Delete Action Does Execute​

The breakpoint hits on the cache.remove(key) line:

Breakpoint stops at cache.remove(key), key is Integer 1

Note IDEA's inline hints: cache: CacheHandler$CacheHandlerRefreshCache@15761, key: 1.

  • key is {Integer@15540} 1, i.e. the argument userId = 1 — SpEL evaluation is fine;
  • cache is of type CacheHandler$CacheHandlerRefreshCache — a proxy JetCache wraps around the real cache to support @CacheRefresh — with LinkedHashMapCache buried deeper inside.

So the first round of suspicion is eliminated: the annotation did take effect, the cache instance was resolved, and the key was evaluated. The problem can only be in the "delete" action itself — it deleted, but hit nothing.

Breakpoint 2: The Entry Is Still There, but Its Key Lives in Another World​

At the breakpoint, Evaluate the cache variable, peel through the proxies layer by layer, and land on the real LinkedHashMapCache:

Evaluating cache: the key stored in innerMap is the string [1]

This screenshot says a lot; let's go through it:

  1. The cache field holds the real LinkedHashMapCache, whose innerMap (a LinkedHashMapCache$LRUMap) contains exactly one entry;
  2. That entry's key is the string "[1]" (IDEA renders strings with quotes; the content is [1]);
  3. The corresponding value is a CacheValueHolder wrapping an ArrayList<SysRole>: SysRole(id=1, roleCode=DEVELOPER...), SysRole(id=2, roleCode=PRODUCT...) — precisely the old permissions we thought we had deleted.

Put the two sides side by side and the scene is clear:

  • The key to delete at the breakpoint: Integer 1
  • The key actually stored in the cache: the string "[1]"

1 is the "logical key"; "[1]" is the "physical key". What's missing here is one layer of understanding: KeyConvertor serializes the SpEL evaluation result first, and what actually enters the cache is the serialized product. Same serialization strategy plus different inputs means the physical keys cannot match.

Source Code Background: The Key's Two Transformations​

First, let's trace the "logical key → physical key" chain clearly. The following source is from the JetCache main branch (2.x versions show no substantive difference in this area).

Both writing and deleting ultimately go through buildKey (AbstractEmbeddedCache):

public Object buildKey(K key) {
Object newKey = key;
Function<K, Object> keyConvertor = config.getKeyConvertor();
if (keyConvertor != null) {
newKey = keyConvertor.apply(key);
}
return newKey;
}

Write path: the @Cached method is invoked → cache.computeIfAbsent(key, loader) → internally GET(key) → do_GET:

protected CacheGetResult<V> do_GET(K key) {
Object newKey = buildKey(key); // writes use the buildKey-transformed key
CacheValueHolder<V> holder = (CacheValueHolder<V>) innerMap.getValue(newKey);
...
}

Delete path: doInvalidate → cache.remove(key) → do_REMOVE:

protected CacheResult do_REMOVE(K key) {
innerMap.removeValue(buildKey(key)); // deletion runs buildKey too
return CacheResult.SUCCESS_WITHOUT_MSG;
}

One important conclusion: reads, writes, and deletes all go through the same keyConvertor on the same Cache instance. So the serializer difference has been ruled out as the cause of "key mismatch" — the serializer is necessarily the same one; the difference can only come from the type and shape of the SpEL evaluation results themselves at the two call sites, @Cached and @CacheInvalidate.

Our starter's default convertor is Fastjson2KeyConvertor:

public Object apply(Object originalKey) {
if (originalKey == null) {
return null;
}
if (originalKey instanceof String) {
return originalKey;
}
return JSON.toJSONString(originalKey);
}

That is, non-string keys are always JSON-serialized:

JSON.toJSONString(1)                          // "1"
JSON.toJSONString(Collections.singletonList(1)) // "[1]"

Reconstructing the Scene​

Now lay out the key generation rules on both sides for comparison:

// Write side (on getUserPermissions)
@Cached(name = "getUserPermissions", key = "{#userId}", ...)
// SpEL inline list literal ↑

// Invalidate side (on the update-roles endpoint)
@CacheInvalidate(name = "getUserPermissions", key = "#userId")

{#userId} is SpEL's Inline List syntax. This habit largely migrated from legacy Spring Cache projects, where @Cacheable(key = "{#userId}") is the standard way to build a composite key. But JetCache also uses SpEL by default, so {...} is still parsed as a valid inline list — no error is ever raised — and the key quietly changes from Integer 1 to List[1].

So:

Key expressionEvaluation resultfastjson2 serializationPhysical key
Write{#userId}List[1][1]string "[1]"
Delete#userIdInteger 11string "1"

LRUMap.remove("1") searching a map that only holds "[1]" finds nothing, naturally. Both sides executed "correctly"; the writing key and the deleting key simply never touched at any point.

Why There Is No Warning At All​

This is the part this post really wants to stress. Look again at the full text of CacheHandler#doInvalidate:

private static void doInvalidate(CacheInvokeContext context, CacheInvalidateAnnoConfig annoConfig) {
Cache cache = context.getCacheFunction().apply(context, annoConfig);
if (cache == null) {
return; // silent point 1: cache instance unresolvable
}
boolean condition = ExpressionUtil.evalCondition(context, annoConfig);
if (!condition) {
return; // silent point 2: condition is false
}
Object key = ExpressionUtil.evalKey(context, annoConfig);
if (key == null) {
return; // silent point 3: key evaluates to null
}
if (annoConfig.isMulti()) {
...
Set keys = new HashSet();
it.forEach(k -> keys.add(k));
cache.removeAll(keys);
} else {
cache.remove(key); // silent point 4: whether it deleted, nobody knows
}
}

Four silent points, corresponding to four kinds of "looks executed, actually did nothing":

Silent point 1: when name/area are spelled inconsistently, JetCache looks up (and creates, if missing) a cache instance right at the annotation — you are deleting from a brand-new empty cache, equally without a sound.

Silent point 2: condition evaluates to false; return directly.

Silent point 3: when the key evaluates to null, it returns silently. Only when SpEL itself throws does ExpressionUtil.evalKey log an error and return null; when the expression "evaluates, but yields something that does not match the write side", even that log is absent — which is exactly this case.

Silent point 4: the return value of cache.remove(key) is discarded outright. And for a local cache, the return value cannot help you either:

// AbstractEmbeddedCache.do_REMOVE
protected CacheResult do_REMOVE(K key) {
innerMap.removeValue(buildKey(key));
return CacheResult.SUCCESS_WITHOUT_MSG; // no matter whether it hit, always SUCCESS
}

// Cache interface default method
default boolean remove(K key) {
return REMOVE(key).isSuccess(); // hence always true
}

When LinkedHashMap.remove() finds no such key it returns null, and the upper wrapper simply declares "success". In other words, even if you write boolean ok = cache.remove(key) in business code as a safety check, for a local cache that ok is always true — the framework merges "key not found" with "delete succeeded" and swallows them at the API level.

This is JetCache's deliberate design choice: cache frameworks tend to swallow every exception and degrade all operations into "result objects", treating invalidation of a nonexistent key as the perfectly normal "nothing to do". The price is that key consistency — the single most error-prone element that only the business developer can guarantee — is completely unverifiable by the framework.

The Fix​

Align the key expressions on both sides character by character:

@Cached(name = "getUserPermissions", key = "#userId", expire = 3600, cacheType = CacheType.LOCAL)
List<SysRole> getUserPermissions(Long userId) { ... }

@CacheInvalidate(name = "getUserPermissions", key = "#userId")
void assignRoles(Long userId, List<Long> roleIds) { ... }

Verification is straightforward: enter doInvalidate at the breakpoint one more time, and after cache.remove(key) executes, Evaluate cache again to confirm that the "[1]" entry in innerMap has really disappeared (size becomes 0, or the key no longer appears).

How to Prevent It​

1. Treat key expressions as an interface contract. For the same name, the key expressions of @Cached / @CacheUpdate / @CacheInvalidate should ideally be extracted into shared constants; failing that, at least make "read/write/delete key expressions for the same cache match character for character" a mandatory code-review item. Stay especially alert to the {#userId} habit migrated from Spring Cache — it is legal, raises no error, and quietly changes the key's type.

2. Write a round-trip test for cache behavior. This is the most effective insurance:

@Test
void invalidate_should_really_remove_cached_entry() {
// 1. First call: builds the cache (loader counter +1)
permissionService.getUserPermissions(1L);

// 2. Trigger the business method carrying the invalidation annotation
permissionService.assignRoles(1L, List.of(2L));

// 3. Second call: the loader counter must increase again
// If it did not, a stale cache was read => invalidation failed => key mismatch
permissionService.getUserPermissions(1L);
}

A loader (or business method entry) counter is all it takes. The "fake success" invalidation bug reproduces 100% in this test, while staying silent in production.

3. The three-step toolkit for investigating similar issues:

  • Set the breakpoint at CacheHandler#doInvalidate, never in the business method — the annotation logic all lives in the proxy layer;
  • When Evaluating the cache variable, mind the proxy layering: expand RefreshCache/ProxyCache down to the cache field first, then expand innerMap, and look at the string form of the physical key ("1" versus "[1]" settles the case at a glance);
  • For an external cache (Redis), use redis-cli --scan --pattern '*<name>*' (JetCache keys carry area/name prefixes) to inspect the serialization shape of the real key.

4. The checklist. All silent branches of @CacheInvalidate:

ScenarioAny log?Notes
name/area mismatch, resolving to another (freshly created, empty) cache instanceNoDeleted thin air
condition evaluates to falseNoExpected behavior, easily forgotten
Key expression evaluates to null / SpEL errorError log only when SpEL throws
Physical key mismatch (this post's case)None whatsoeverOnly the business side can detect it
multi = true but key is not IterableError log present

Also, @CacheUpdate suffers from exactly the same problem: when the key fails to match, cache.put(key, value) writes a ghost entry that can never be read back, again with no warning — and it evicts a real entry from the LRU.

Wrap-Up​

The convenience of annotation-driven caching scatters "key generation" — the most error-prone detail, and the one demanding strictest read/write/delete agreement — across multiple annotations that never see each other; and the framework, determined never to meddle, turns every failure into silence.

This episode's lesson, condensed to one sentence: the write rule and the clearing rule for cache keys must be designed and reviewed as one whole; after deleting, best confirm by hand that it truly vanished — because in JetCache's world, delete failures are not entitled to a warning.