Add `cachepath` Attribute to Query Tag for Efficient Cache Management

Summary

Add a cachepath attribute to <cfquery> and queryExecute() that prepends a custom prefix to auto-generated cache IDs, enabling efficient key-based cache management operations.

Problem Statement

Current Performance Issue

The existing tags attribute for query cache management stores tags in cache values, not keys. This means operations like cacheClear(["tagname"], "query") must:

  1. Retrieve all cache keys from the store (e.g., Redis)
  2. Read the complete value of every cached object
  3. Deserialize each object to check for matching tags
  4. Delete matching entries

Impact: In a Redis cache with 10,000+ cached queries, clearing by tag requires reading and deserializing all 10,000 objects, taking several seconds and blocking the application during the scan.

Auto-Generated Cache IDs

Additionally, Lucee auto-generates cache IDs as hashes based on SQL/datasource/credentials. This makes it impossible to:

  • Selectively clear specific cached queries
  • Check if a query is cached without executing it
  • Debug cache contents effectively

Proposed Solution

Add a cachepath attribute that prepends a custom string to the auto-generated cache ID:

<cfquery name="users" datasource="myDB"
         cachepath="users:active:"
         cachedwithin="#createTimeSpan(0,1,0,0)#">
    SELECT * FROM users WHERE active = 1
</cfquery>

Result: Cache ID becomes "users:active:A7F2E1C9D..." instead of just "A7F2E1C9D..."

Key Benefits

  1. Performance: Key-based cache clearing is O(k) where k = matching keys, vs O(n) where n = ALL cached objects
  2. Constant-time operations: Performance independent of total cache size—only matching keys are read
  3. Preserves uniqueness: Prepending maintains SQL/datasource hash, preventing conflicts
  4. Known key prefixes: Developers can use predictable prefixes for cache management
  5. Backward compatible: Zero impact on existing code

Use Case

1. Clear All User Queries


<cfquery cachepath="users:" cachedwithin="...">SELECT...</cfquery>

<!--- Clear only user queries, skip others --->

<cfset cacheRemove("users:*")> <!--- Fast: no deserialization --->

Compatibility

  • Fully backward compatible—existing queries work unchanged

  • Works with all cache handlers (RAM, Redis, EHCache, Memcached)

  • No breaking changes to cache ID generation for queries without cachepath

3 Likes

Just for reference, Ive already made the required changes in the Lucee code locally and tested with my app (and a bunch of Testbox tests). Its a simple change to codebase, so its ready to go if approved :slight_smile:

I really like it.
I’ve developed a small class that acts as a proxy for the Lucee cache that does just that: it adds the concept of “scope” to the cache.

getCacheManager().put/get( key, scope, value );

For this reason, I never use “cachedwithin” but instead add the query directly to the cache:

cache.put( "userGet_" & Hash( user.id ), "cfquery:userGet", local.userGetQuery );

Your concept of “path” (or prefix) seems much more flexible to me.

1 Like

Thanks @Roberto_Marzialetti
I actually prefer cacheprefix as the attribute name, it makes more sense.
I’ll update the proposal.
Cheers!

Edit, I cannot figure out how to edit the original post :grimacing:

@Zackster I was about to create an LDEV ticket, but the note said ‘Once the issue has been verified, one of the Lucee team will ask you to file an issue’. :slight_smile:

@dman1 , Could you please go ahead and create the LDEV ticket for this and commit your changes?
I’d really appreciate it. I’m happy to contribute with further testing if needed.

1 Like

Actually there’s already cacheRegion in ACF which we don’t support as of yet

https://luceeserver.atlassian.net/browse/LDEV-4130

@Zackster I understood that to be the cache that it would store in, like an override for the default query cache, in case you had multiple query caches like ‘userquerycache’ and ‘dataquerycache’ setup in Lucee and you want to tell the query which one to use?

Updated to cacheprefix at Jira

please target the 7.0 branch, 6.2 is in maintenance mode now and only getting bug fixes

Done.

@GianTschingt I wonder if it would be possible for you to push this one along a bit also :pray:

@dman1 thanks for the nudge, and sorry this sat so long!

I dug into it, and I think you can do this today without a new attribute, even on 6.2. Instead of cachedWithin, cache the query yourself under a key you choose. Clearing a group is then a plain key-pattern clear: on Redis that’s one KEYS users:active:* plus one DEL, with no values read or deserialized.

// Application.cfc: a cache for query groups, on its own Redis DB
this.cache.connections["queryGroups"] = {
	class: "lucee.extension.io.cache.redis.RedisCache",
	bundleName: "redis.extension",
	storage: false,
	custom: { host: "localhost", port: 6379, databaseIndex: 2 }
};
function cachedQuery( required string group, required string sql, any params = {}, struct options = {}, any timespan = createTimeSpan( 0, 1, 0, 0 ) ) {
	var key = lCase( group ) & hash( sql & serializeJSON( params ) & serializeJSON( options ) );
	var q = cacheGet( id = key, cacheName = "queryGroups" );
	if ( isNull( q ) ) {
		q = queryExecute( sql, params, options );
		cachePut( id = key, value = q, timeSpan = timespan, cacheName = "queryGroups" );
	}
	return q;
}

function clearQueryGroup( required string group ) {
	return cacheClear( lCase( group ) & "*", "queryGroups" );
}

users = cachedQuery( "users:active:", "SELECT * FROM users WHERE active = :a", { a: 1 } );
clearQueryGroup( "users:active:" ); // just that group
clearQueryGroup( "users:" );        // everything under users:

You also get your other two points for free: cacheIdExists() and cacheGetAllIds( "users:*", "queryGroups" ) show what’s cached without running anything. I tested it against Redis on 6.2 and 7.1.

A few things to watch:

  • Keep prefixes lowercase. The Redis extension lowercases keys, but the pattern goes to Redis as-is (hence the lCase()). That’s a bug on our side, now tracked as LDEV-6538, with the fix going into 7.1. Until then, lCase() the prefix.
  • Avoid *, ? and [ in group names.
  • KEYS still walks the Redis DB on the server side, same as with your PR, so a separate databaseIndex keeps that small.

For single queries, there’s cachedWithinFlush( qry ) on 7.1+. On any version, re-running the same query with cachedWithin="#createTimeSpan( 0, 0, 0, 0 )#" removes its entry.

I’ve also written this up as a recipe, together with the other ways to clear cached queries: Invalidating and Grouping Cached Queries.

On the PR: new features only go into 8.0 at this point. If something is still missing for you after trying this, could you retarget it to the 8.0 branch and tell us what the gap is?

Gian

Hi Gian,

Thanks for digging into this, and for writing up the recipe.

Before replying I wanted real numbers rather than the ones in my original post, so we ran a background task on one of our non-production environments that reads the query cache every 15 minutes and logs what’s in it. Here’s what 26 hours of that showed, and it changes what I’m asking for.

1. Tag clears are expensive even on the RAM cache

Our query cache is on RAM now, not Redis. Tag clears are still the dominant cost:

  • RamCache doesn’t override remove(CacheEntryFilter), so it uses CacheSupport.remove(), which calls getQuiet() for every key.

  • TimespanCacheHandler decouples the query cache, so each of those getQuiet() calls deep-copies the cached query just so QueryTagFilter can read its tags.

  • With only about 150 entries per JVM, a single tag clear peaked at about 300 ms. One tag was cleared about 1,200 times in a day.

A cache hit on the same data is cheap: copying out our largest entry (about 1 MB, 3,900 rows) took 2 to 6 ms.

2. Moving query results to Redis would be slower for us than not caching

I measured the Redis cost by serializing and deserializing each entry:

  • About 1 MB / 3,900 rows: 34 to 50 ms to deserialize, against a 15 to 24 ms average run time.

  • About 5.5 MB / 42,000 rows: up to 300 ms to deserialize, against 97 ms to run.

So the cachePut + key-pattern recipe fixes the clear, but every hit on a large result costs more than running the query.

3. A bug: tag clears can fail partway and leave stale entries

CacheSupport.remove(CacheEntryFilter) lists the keys first, then calls getQuiet(key, null) for each one. If an entry expires or is removed in between, getQuiet() returns null and QueryTagFilter.accept() throws a NullPointerException on ce.getValue():


Cannot invoke "lucee.commons.io.cache.CacheEntry.getValue()" because "ce" is null

The exception aborts the clear, so matching entries after that point are never removed and stale results get served until they expire. We hit it 5 times in 26 hours. The same pattern is in keys(CacheEntryFilter) and values(CacheEntryFilter), and it’s present on 6.2, 7.0 and 7.1. values(CacheKeyFilter) already has the null check.

What I’d suggest

Given these numbers, I don’t think we need cacheprefix any more. Its benefit was avoiding a fetch and deserialize per entry on a remote cache. We’re keeping query caching on RAM, where filtering on tags costs about the same as filtering on a key prefix once the entries aren’t deep-copied. I’ll close the PR rather than retarget it to 8.0, unless you think it’s still useful for others on remote caches?

What would help is a bug fix for 6.2 / 7.x:

  • Skip null entries in the three CacheEntryFilter methods in CacheSupport, so a clear can’t abort partway and leave stale entries.

  • Give RamCache its own remove(CacheEntryFilter) that reads its entries map directly instead of going through the decoupling getQuiet(), so a tag clear no longer deep-copies every cached query.

No new API, and existing cachedWithin + tags code gets faster as-is.

Let me know if you want me to create a PR for this fix.

Thanks,

Dean

Hi Dean, those numbers are really useful, and they make the case much clearer than the original proposal. Agreed, cacheprefix isn’t needed then; closing that PR is fine.

I’ve filed both points as LDEV-6546: skipping null entries in the three CacheEntryFilter methods of CacheSupport, so a clear can’t stop partway, and a dedicated remove(CacheEntryFilter) in RamCache that doesn’t go through the decoupled getQuiet(). A PR from you would be very welcome. Please target 7.1; we’ll take it back to 6.2 and 7.0 from there since it’s a bug fix.

Thanks Dean, that was quick! I’ve reviewed #2942: CI is green, and I ran your tests locally with and without the fix (5 of the 6 fail without it), so they really cover the bug. The “filter gets the stored entry” change is fine, since no filter in core or the extensions modifies entries. I left a few small, non-blocking notes in the review. The follow-ups you offered are welcome as separate PRs. Once it’s merged on 7.1 we’ll take it back to 6.2 and 7.0.

Thanks Gian for the thorough review, and for running the tests.

I’ve pushed the small review changes to PR #2942: the cheaper keys(filter) path, a simplified null check in remove(filter), and test cleanup (restoring the default query cache and reusing the RamCache instance).

Thanks again Dean! #2942 is merged and the 7.1 build is green, so the fix is in 7.1.2.39-SNAPSHOT, and LDEV-6546 is now in QA. Your follow-ups (RamCache.keys() and the null cases in unfiltered values()/entries()) are welcome as separate PRs against 7.1.