From 8d41e8295dd580af40647cf8319b5c535f359417 Mon Sep 17 00:00:00 2001 From: David Rogers Date: Thu, 30 Nov 2023 16:23:06 -0600 Subject: [PATCH 01/11] add failing test "does not return 'the same object' from a cacheGet call" adds hook to inject thread delays for testing purposes --- .../extension/io/cache/redis/RedisCache.java | 15 +++++ ...triesAreCopiedOnReadPriorToWriteCommit.cfc | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/NearCacheEntriesAreCopiedOnReadPriorToWriteCommit.cfc diff --git a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java index 92ca479..d01c330 100644 --- a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java +++ b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java @@ -77,6 +77,14 @@ public class RedisCache extends CacheSupport implements Command { private final Object token = new Object(); + /** + * Boxed type is to support representing the null case + */ + private Integer __test__writeCommitDelay_ms = null; + public Integer get__test__writeCommitDelay_ms() { + return __test__writeCommitDelay_ms; + } + public RedisCache() { if (async) { // storage = new Storage(this); @@ -97,6 +105,9 @@ public void init(Config config, Struct arguments) throws IOException { this.cl = arguments.getClass().getClassLoader(); if (config == null) config = CFMLEngineFactory.getInstance().getThreadConfig(); + __test__writeCommitDelay_ms = caster.toIntValue(arguments.get("__test__writeCommitDelay_ms", null), 0); + __test__writeCommitDelay_ms = __test__writeCommitDelay_ms <= 0 ? null : __test__writeCommitDelay_ms; + host = caster.toString(arguments.get("host", "localhost"), "localhost"); port = caster.toIntValue(arguments.get("port", null), 6379); @@ -956,6 +967,10 @@ public void run() { synchronized (tokenAddToNear) { if (entries.isEmpty()) tokenAddToNear.wait(); } + + if (cache.get__test__writeCommitDelay_ms() != null) { + Thread.sleep(cache.get__test__writeCommitDelay_ms()); + } } catch (Throwable e) { if (cache.log != null) cache.log.error("redis-cache", e); diff --git a/tests/NearCacheEntriesAreCopiedOnReadPriorToWriteCommit.cfc b/tests/NearCacheEntriesAreCopiedOnReadPriorToWriteCommit.cfc new file mode 100644 index 0000000..f9f4b55 --- /dev/null +++ b/tests/NearCacheEntriesAreCopiedOnReadPriorToWriteCommit.cfc @@ -0,0 +1,62 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="redis" { + + public void function beforeAll(){ + variables.cacheName = "NearCacheEntriesAreCopiedOnReadPriorToWriteCommit" + defineCache(); + } + + public void function afterAll(){ + application action="update" caches={}; + } + + private string function defineCache(){ + var redis = server.getDatasource("redis"); + if ( structCount(redis) eq 0 ) + throw "Redis is not configured?"; + + admin + action="updateCacheConnection" + type="server" + password=server.SERVERADMINPASSWORD + class="lucee.extension.io.cache.redis.simple.RedisCache" + bundleName="redis.extension" + name=cacheName + custom={ + "minIdle":8, + "maxTotal":40, + "maxIdle":24, + "host":redis.server, + "port":redis.port, + "socketTimeout":2000, + "liveTimeout":3600000, + "idleTimeout":60000, + "timeToLiveSeconds":0, + "testOnBorrow":true, + "rnd":1, + "__test__writeCommitDelay_ms": 5000 + }, + default="" + readonly=false + storage=false + remoteClients=""; + } + + function run() { + describe("NearCacheEntriesAreCopiedOnReadPriorToWriteCommit", () => { + it("does not return 'the same object' from a cacheGet call", () => { + var someObj = {v: 42} + var key = "redis-test/#createGuid()#"; + + cachePut(key = key, value = someObj, cacheName = cacheName); + + var fromCache = cacheGet(key = key, cacheName = cacheName); + + expect(fromCache.v).toBe(42); + + fromCache.v += 1; + + expect(someObj.v).toBe(42, "mutating 'fromCache' did not mutate the initial cache source 'someObj'"); + }) + }) + } +} From 72abe9ce1ade72e3b0c00e7fe6cc7e592f0b050d Mon Sep 17 00:00:00 2001 From: David Rogers Date: Sun, 9 Apr 2023 07:29:02 -0500 Subject: [PATCH 02/11] copy NearCacheEntry values as-if they had been materialized from cache With goal being not sharing live object refs to the underlying "to-be-cached" objects across NearCacheEntry objects fixes test introduced in prior commit resolves #13 --- .../io/cache/redis/NearCacheEntry.java | 19 +++++++++++++++++++ .../extension/io/cache/redis/RedisCache.java | 9 +++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java b/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java index c1066c0..40e5009 100644 --- a/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java +++ b/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java @@ -25,6 +25,25 @@ public NearCacheEntry(byte[] key, Object val, int exp, long count) { this.count = count; } + private NearCacheEntry(byte[] key, Object val, int exp, long count, byte[] serialized) { + this.key = key; + this.val = val; + this.exp = exp; + this.created = System.currentTimeMillis(); + this.count = count; + this.serialized = serialized; + } + + /** + * copy this object, also copying the underlying object as-if it had been materialized from cache (in particular, the underlying object + * is copied such that is no longer a reference to the original underlying object). Note that the serialized byte[] is shared across instances of + * copied NearCacheEntries. + */ + public NearCacheEntry copy(ClassLoader cl) throws IOException { + byte[] bytes = serialized(); + return new NearCacheEntry(key, Coder.evaluate(cl, bytes), exp, count, bytes); + } + @Override public Date created() { return new Date(created); diff --git a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java index d01c330..0434a34 100644 --- a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java +++ b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java @@ -247,7 +247,7 @@ public CacheEntry getCacheEntry(String skey) throws IOException { if (async) { NearCacheEntry val = storage.get(bkey); if (val != null) { - return val; + return val.copy(cl); } storage.doJoin(cnt, true); } @@ -341,7 +341,12 @@ public CacheEntry getCacheEntry(String skey, CacheEntry defaultValue) { if (async) { NearCacheEntry val = storage.get(bkey); if (val != null) { - return val; + try { + return val.copy(cl); + } + catch (IOException e) { + return defaultValue; + } } storage.doJoin(cnt, true); } From 063aae668cdcf429657c14a05cca50fe304e3a58 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Tue, 19 May 2026 22:30:08 +0200 Subject: [PATCH 03/11] LDEV-6327 replace near cache deque scan with map+queue --- .../io/cache/redis/NearCacheEntry.java | 3 +- .../extension/io/cache/redis/RedisCache.java | 78 ++++++++++++------- 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java b/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java index 40e5009..10a4522 100644 --- a/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java +++ b/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java @@ -25,7 +25,8 @@ public NearCacheEntry(byte[] key, Object val, int exp, long count) { this.count = count; } - private NearCacheEntry(byte[] key, Object val, int exp, long count, byte[] serialized) { + // val may be null when serialized is supplied; serialized() returns the cached bytes without touching val. + NearCacheEntry(byte[] key, Object val, int exp, long count, byte[] serialized) { this.key = key; this.val = val; this.exp = exp; diff --git a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java index 0434a34..cc2a54a 100644 --- a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java +++ b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java @@ -4,10 +4,12 @@ import java.net.Socket; import java.net.SocketException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Iterator; import java.util.List; -import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; import org.apache.commons.pool2.impl.BaseObjectPoolConfig; import org.apache.commons.pool2.impl.GenericObjectPoolConfig; @@ -408,14 +410,18 @@ else if (idle != null && idle.longValue() > 0) { } private void put(byte[] bkey, Object val, int exp) throws IOException { + putBytes(bkey, Coder.serialize(val), exp); + } + + void putBytes(byte[] bkey, byte[] serialized, int exp) throws IOException { Redis conn = getConnection(); try { if (exp > 0) { - conn.pipeline().call("SET", bkey, Coder.serialize(val)).call("EXPIRE", bkey, Integer.toString(exp)).read(); + conn.pipeline().call("SET", bkey, serialized).call("EXPIRE", bkey, Integer.toString(exp)).read(); } else { - conn.call("SET", bkey, Coder.serialize(val)); + conn.call("SET", bkey, serialized); } } catch (Exception e) { @@ -888,28 +894,22 @@ public void verify() throws IOException { private static class Storage extends Thread { - private ConcurrentLinkedDeque entries; + // Map overwrites on duplicate puts (LDEV-6327); queue preserves drain order. + private final ConcurrentHashMap entries; + private final ConcurrentLinkedQueue drainQueue; private RedisCache cache; - private CFMLEngine engine; private long current = Long.MIN_VALUE; private final Object tokenAddToNear = new Object(); private final Object tokenAddToCache = new Object(); public Storage(RedisCache cache) { - this.engine = CFMLEngineFactory.getInstance(); this.cache = cache; - this.entries = new ConcurrentLinkedDeque<>(); + this.entries = new ConcurrentHashMap<>(); + this.drainQueue = new ConcurrentLinkedQueue<>(); } public NearCacheEntry get(byte[] bkey) { - if (entries.isEmpty()) return null; - Object[] arr = entries.toArray(); - NearCacheEntry e; - for (Object obj: arr) { - e = (NearCacheEntry) obj; - if (equals(e.getByteKey(), bkey)) return e; - } - return null; + return entries.get(new ByteArrayWrapper(bkey)); } public long getCurrent() { @@ -919,7 +919,7 @@ public long getCurrent() { public void doJoin(long count, boolean one) { long startCurr = getCurrent(); - if (startCurr > count || entries.isEmpty()) { + if (startCurr > count || drainQueue.isEmpty()) { return; } @@ -935,7 +935,7 @@ public void doJoin(long count, boolean one) { if (one && startCurr < curr) { break; } - if (curr > count || entries.isEmpty()) { + if (curr > count || drainQueue.isEmpty()) { break; } synchronized (tokenAddToCache) { @@ -950,8 +950,13 @@ public void doJoin(long count, boolean one) { } } - public void put(byte[] bkey, Object val, int exp, long count) { - entries.add(new NearCacheEntry(bkey, val, exp, count)); + public void put(byte[] bkey, Object val, int exp, long count) throws IOException { + // Serialise now so subsequent caller mutation cannot reach the cache (LDEV-4413 write-side). + byte[] bytes = Coder.serialize(val); + NearCacheEntry entry = new NearCacheEntry(bkey, null, exp, count, bytes); + ByteArrayWrapper wkey = new ByteArrayWrapper(bkey); + entries.put(wkey, entry); + drainQueue.offer(wkey); synchronized (tokenAddToNear) { tokenAddToNear.notifyAll(); } @@ -960,17 +965,21 @@ public void put(byte[] bkey, Object val, int exp, long count) { @Override public void run() { while (true) { - NearCacheEntry entry; try { - while ((entry = entries.poll()) != null) { + ByteArrayWrapper wkey; + while ((wkey = drainQueue.poll()) != null) { + // Re-read from the map — a newer put may have overwritten since enqueue. + NearCacheEntry entry = entries.get(wkey); + if (entry == null) continue; current = entry.count(); - cache.put(entry.getByteKey(), entry.getValue(), entry.getExpires()); + cache.putBytes(entry.getByteKey(), entry.serialized(), entry.getExpires()); + entries.remove(wkey, entry); synchronized (tokenAddToCache) { tokenAddToCache.notifyAll(); } } synchronized (tokenAddToNear) { - if (entries.isEmpty()) tokenAddToNear.wait(); + if (drainQueue.isEmpty()) tokenAddToNear.wait(); } if (cache.get__test__writeCommitDelay_ms() != null) { @@ -991,13 +1000,26 @@ public void run() { } } - private static boolean equals(byte[] left, byte[] right) { - if (left.length != right.length) return false; + private static final class ByteArrayWrapper { + private final byte[] data; + private final int hash; + + ByteArrayWrapper(byte[] data) { + this.data = data; + this.hash = Arrays.hashCode(data); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ByteArrayWrapper)) return false; + return Arrays.equals(data, ((ByteArrayWrapper) o).data); + } - for (int i = 0; i < left.length; i++) { - if (left[i] != right[i]) return false; + @Override + public int hashCode() { + return hash; } - return true; } } From c6b85f852ecf8d8fa1954f35e99a694ca5ff55b3 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Wed, 20 May 2026 13:27:44 +0200 Subject: [PATCH 04/11] LDEV-4413 LDEV-6327 add additional tests --- .gitignore | 1 + ...adPriorToWriteCommit.cfc => NearCache.cfc} | 37 ++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) rename tests/{NearCacheEntriesAreCopiedOnReadPriorToWriteCommit.cfc => NearCache.cfc} (58%) diff --git a/.gitignore b/.gitignore index e31320e..474c8cb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.classpath *.DS_Store target/ +/test-output diff --git a/tests/NearCacheEntriesAreCopiedOnReadPriorToWriteCommit.cfc b/tests/NearCache.cfc similarity index 58% rename from tests/NearCacheEntriesAreCopiedOnReadPriorToWriteCommit.cfc rename to tests/NearCache.cfc index f9f4b55..c59d1df 100644 --- a/tests/NearCacheEntriesAreCopiedOnReadPriorToWriteCommit.cfc +++ b/tests/NearCache.cfc @@ -42,21 +42,46 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="redis" { } function run() { - describe("NearCacheEntriesAreCopiedOnReadPriorToWriteCommit", () => { - it("does not return 'the same object' from a cacheGet call", () => { + describe("Near cache regression tests", () => { + it("LDEV-4413 mutating a cacheGet result does not mutate the cached value", () => { var someObj = {v: 42} var key = "redis-test/#createGuid()#"; - + cachePut(key = key, value = someObj, cacheName = cacheName); var fromCache = cacheGet(key = key, cacheName = cacheName); - + expect(fromCache.v).toBe(42); - + fromCache.v += 1; - + expect(someObj.v).toBe(42, "mutating 'fromCache' did not mutate the initial cache source 'someObj'"); }) + + it("LDEV-4413 mutating the original value after cachePut does not leak into the cache", () => { + var someObj = {v: 42} + var key = "redis-test/#createGuid()#"; + + cachePut(key = key, value = someObj, cacheName = cacheName); + + // Caller mutates after put — eager serialisation at put time must isolate the cached copy + someObj.v = 99; + + var fromCache = cacheGet(key = key, cacheName = cacheName); + + expect(fromCache.v).toBe(42, "post-put mutation leaked into the near cache (got #fromCache.v#, expected 42)"); + }) + + it("LDEV-6327 cacheGet returns the latest value when two puts race on the same key", () => { + var key = "redis-test/#createGuid()#"; + + cachePut(key = key, value = "version-1", cacheName = cacheName); + cachePut(key = key, value = "version-2", cacheName = cacheName); + + var val = cacheGet(key = key, cacheName = cacheName); + + expect(val).toBe("version-2", "stale-wins: expected 'version-2', got '#val#'"); + }) }) } } From 6f2f1deecdb7da9a2b04597818b40b384660c578 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Wed, 20 May 2026 13:59:44 +0200 Subject: [PATCH 05/11] LDEV-6046 add Redis sessionInvalidate test --- tests/LDEV6046.cfc | 140 +++++++++++++++++++++ tests/LDEV6046/redis/Application.cfc | 43 +++++++ tests/LDEV6046/redis/accessWithOldCfid.cfm | 11 ++ tests/LDEV6046/redis/checkSession.cfm | 24 ++++ tests/LDEV6046/redis/createSession.cfm | 17 +++ tests/LDEV6046/redis/invalidateSession.cfm | 20 +++ 6 files changed, 255 insertions(+) create mode 100644 tests/LDEV6046.cfc create mode 100644 tests/LDEV6046/redis/Application.cfc create mode 100644 tests/LDEV6046/redis/accessWithOldCfid.cfm create mode 100644 tests/LDEV6046/redis/checkSession.cfm create mode 100644 tests/LDEV6046/redis/createSession.cfm create mode 100644 tests/LDEV6046/redis/invalidateSession.cfm diff --git a/tests/LDEV6046.cfc b/tests/LDEV6046.cfc new file mode 100644 index 0000000..19475f4 --- /dev/null +++ b/tests/LDEV6046.cfc @@ -0,0 +1,140 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="redis" { + + // LDEV-6046 (Redis variant): sessionInvalidate() with Redis-backed session storage. + // + // The core fix for LDEV-6046 lives in ScopeContext.removeCFSessionScope() and is + // storage-type agnostic. The MySQL test (lucee7.1/test/tickets/LDEV6046.cfc) covers the + // database-storage path. This test covers the Redis-storage path to confirm the same + // fix carries through. + // + // Also bears on GH lucee/extension-redis#5 (sessionInvalidate doesn't remove the key + // from Redis). + + function isNotSupported() { + var redis = server.getDatasource( "redis" ); + return structCount( redis ) eq 0; + } + + function run( testResults, testBox ) { + describe( "LDEV-6046: sessionInvalidate() with Redis session storage", function() { + + it( title="sessionInvalidate() should de-activate old session so it cannot be reused", skip=isNotSupported(), body=function( currentSpec ) { + var uri = createURI( "LDEV6046" ); + + var result1 = _InternalRequest( + template = "#uri#redis/createSession.cfm" + ); + var data1 = deserializeJSON( result1.filecontent.trim() ); + expect( data1.sessionId ).notToBeEmpty(); + expect( data1.user_id ).notToBeEmpty(); + + var originalSessionId = data1.sessionId; + var originalCfid = data1.cfid; + var originalCftoken = data1.cftoken; + + var result2 = _InternalRequest( + template = "#uri#redis/invalidateSession.cfm", + cookies = { + cfid: originalCfid, + cftoken: originalCftoken + } + ); + var data2 = deserializeJSON( result2.filecontent.trim() ); + + expect( data2.oldSessionId ).toBe( originalSessionId ); + expect( data2.newSessionId ).notToBe( originalSessionId, "Session ID should change after sessionInvalidate()" ); + expect( data2.hasUserId ).toBeFalse( "New session should not have user_id from old session" ); + + var result3 = _InternalRequest( + template = "#uri#redis/accessWithOldCfid.cfm", + cookies = { + cfid: originalCfid, + cftoken: originalCftoken + } + ); + var data3 = deserializeJSON( result3.filecontent.trim() ); + + expect( data3.hasUserId ).toBeFalse( "Old session should not be accessible after sessionInvalidate() - user_id should not exist" ); + } ); + + it( title="old session key should be removed from Redis after sessionInvalidate()", skip=isNotSupported(), body=function( currentSpec ) { + var uri = createURI( "LDEV6046" ); + + var result1 = _InternalRequest( + template = "#uri#redis/createSession.cfm" + ); + var data1 = deserializeJSON( result1.filecontent.trim() ); + var originalCfid = data1.cfid; + var originalCftoken = data1.cftoken; + + _InternalRequest( + template = "#uri#redis/invalidateSession.cfm", + cookies = { + cfid: originalCfid, + cftoken: originalCftoken + } + ); + + var result3 = _InternalRequest( + template = "#uri#redis/checkSession.cfm", + url = { cfid: originalCfid } + ); + var data3 = deserializeJSON( result3.filecontent.trim() ); + expect( data3.sessionExists ).toBeFalse( "Old session key (cfid #originalCfid#) should be removed from Redis. Matching keys: #serializeJSON( data3.matchingKeys )#" ); + } ); + + } ); + + describe( "LDEV-6046: sessionInvalidate() with Redis session storage (sessionCluster=false)", function() { + + it( title="sessionInvalidate() should de-activate old session with sessionCluster=false", skip=isNotSupported(), body=function( currentSpec ) { + var uri = createURI( "LDEV6046" ); + + var result1 = _InternalRequest( + template = "#uri#redis/createSession.cfm", + url = { sessionCluster: false } + ); + var data1 = deserializeJSON( result1.filecontent.trim() ); + expect( data1.sessionId ).notToBeEmpty(); + expect( data1.user_id ).notToBeEmpty(); + + var originalSessionId = data1.sessionId; + var originalCfid = data1.cfid; + var originalCftoken = data1.cftoken; + + var result2 = _InternalRequest( + template = "#uri#redis/invalidateSession.cfm", + url = { sessionCluster: false }, + cookies = { + cfid: originalCfid, + cftoken: originalCftoken + } + ); + var data2 = deserializeJSON( result2.filecontent.trim() ); + + expect( data2.oldSessionId ).toBe( originalSessionId ); + expect( data2.newSessionId ).notToBe( originalSessionId, "Session ID should change after sessionInvalidate()" ); + expect( data2.hasUserId ).toBeFalse( "New session should not have user_id from old session" ); + + var result3 = _InternalRequest( + template = "#uri#redis/accessWithOldCfid.cfm", + url = { sessionCluster: false }, + cookies = { + cfid: originalCfid, + cftoken: originalCftoken + } + ); + var data3 = deserializeJSON( result3.filecontent.trim() ); + + expect( data3.hasUserId ).toBeFalse( "Old session should not be accessible after sessionInvalidate() with sessionCluster=false" ); + } ); + + } ); + } + + private string function createURI( string calledName ) { + var path = getDirectoryFromPath( getCurrentTemplatePath() ); + return contractPath( path & calledName ) & "/"; + } + +} diff --git a/tests/LDEV6046/redis/Application.cfc b/tests/LDEV6046/redis/Application.cfc new file mode 100644 index 0000000..b6a519b --- /dev/null +++ b/tests/LDEV6046/redis/Application.cfc @@ -0,0 +1,43 @@ +component { + this.name = "ldev-6046-redis-" & hash( getCurrentTemplatePath() ); + this.sessionManagement = true; + this.sessionTimeout = createTimeSpan( 0, 0, 5, 0 ); + this.setClientCookies = true; + + redis = server.getDatasource( "redis" ); + + this.cache.connections[ "ldev6046SessionRedis" ] = { + class: "lucee.extension.io.cache.redis.simple.RedisCache", + bundleName: "redis.extension", + custom: { + minIdle: 8, + maxTotal: 40, + maxIdle: 24, + host: redis.server, + port: redis.port, + socketTimeout: 2000, + liveTimeout: 3600000, + idleTimeout: 60000, + timeToLiveSeconds: 0, + testOnBorrow: true + }, + readOnly: false, + storage: true, + default: "" + }; + + this.sessionStorage = "ldev6046SessionRedis"; + this.sessionCluster = isNull( url.sessionCluster ) ? true : url.sessionCluster; + + public function onRequestStart() { + setting requesttimeout=10 showdebugOutput=false; + } + + function onSessionStart() { + systemOutput( "------onSessionStart (redis)", true ); + } + + function onSessionEnd( sessionScope, applicationScope ) { + systemOutput( "------onSessionEnd (redis)", true ); + } +} diff --git a/tests/LDEV6046/redis/accessWithOldCfid.cfm b/tests/LDEV6046/redis/accessWithOldCfid.cfm new file mode 100644 index 0000000..fcb1cf5 --- /dev/null +++ b/tests/LDEV6046/redis/accessWithOldCfid.cfm @@ -0,0 +1,11 @@ + + result = { + sessionId: session.sessionid, + hasUserId: structKeyExists( session, "user_id" ), + userId: isNull( session.user_id ) ? "" : session.user_id, + hasTestValue: structKeyExists( session, "testValue" ), + testValue: isNull( session.testValue ) ? "" : session.testValue + }; + + echo( serializeJSON( result ) ); + diff --git a/tests/LDEV6046/redis/checkSession.cfm b/tests/LDEV6046/redis/checkSession.cfm new file mode 100644 index 0000000..07b3a30 --- /dev/null +++ b/tests/LDEV6046/redis/checkSession.cfm @@ -0,0 +1,24 @@ + + // Check whether a session key still lives in the Redis cache after sessionInvalidate(). + // The session-storage key format includes the cfid; we list all keys and look for it. + + cfidToCheck = isNull( url.cfid ) ? "" : url.cfid; + + allKeys = cacheGetAllIds( "*", "ldev6046SessionRedis" ); + + matchingKeys = []; + for ( key in allKeys ) { + if ( findNoCase( cfidToCheck, key ) ) { + arrayAppend( matchingKeys, key ); + } + } + + result = { + cfidChecked: cfidToCheck, + sessionExists: arrayLen( matchingKeys ) gt 0, + matchingKeys: matchingKeys, + allKeyCount: arrayLen( allKeys ) + }; + + echo( serializeJSON( result ) ); + diff --git a/tests/LDEV6046/redis/createSession.cfm b/tests/LDEV6046/redis/createSession.cfm new file mode 100644 index 0000000..f145031 --- /dev/null +++ b/tests/LDEV6046/redis/createSession.cfm @@ -0,0 +1,17 @@ + + if ( structKeyExists( url, "testValue" ) ) { + session.testValue = url.testValue; + } + + session.user_id = createUUID(); + + result = { + sessionId: session.sessionid, + cfid: session.cfid, + cftoken: session.cftoken, + user_id: session.user_id, + testValue: isNull( session.testValue ) ? "" : session.testValue + }; + + echo( serializeJSON( result ) ); + diff --git a/tests/LDEV6046/redis/invalidateSession.cfm b/tests/LDEV6046/redis/invalidateSession.cfm new file mode 100644 index 0000000..7be8e62 --- /dev/null +++ b/tests/LDEV6046/redis/invalidateSession.cfm @@ -0,0 +1,20 @@ + + oldSessionId = session.sessionid; + oldCfid = session.cfid; + oldCftoken = session.cftoken; + + sessionInvalidate(); + + result = { + oldSessionId: oldSessionId, + oldCfid: oldCfid, + oldCftoken: oldCftoken, + newSessionId: session.sessionid, + newCfid: session.cfid, + newCftoken: session.cftoken, + hasTestValue: structKeyExists( session, "testValue" ), + hasUserId: structKeyExists( session, "user_id" ) + }; + + echo( serializeJSON( result ) ); + From 1b2bf53b721ea62244cb7c01a26d49ec8d5b8e9a Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 21 May 2026 14:49:35 +0200 Subject: [PATCH 06/11] LDEV-6327 prevent near-cache data loss on transient redis failure --- .../java/src/lucee/extension/io/cache/redis/Redis.java | 5 ++++- .../src/lucee/extension/io/cache/redis/RedisCache.java | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/source/java/src/lucee/extension/io/cache/redis/Redis.java b/source/java/src/lucee/extension/io/cache/redis/Redis.java index 37c6a58..2e75c14 100644 --- a/source/java/src/lucee/extension/io/cache/redis/Redis.java +++ b/source/java/src/lucee/extension/io/cache/redis/Redis.java @@ -191,7 +191,10 @@ Object parse() throws IOException, ProtocolException { } break; case -1: - return null; + // EOF mid-response = connection closed by peer (Redis dead, proxy disabled, network drop). + // Returning null here masks the failure as "SET succeeded, returned null" which the async + // drain treats as success and removes the entry from the near cache — data lost silently. + throw new IOException("Unexpected EOF from Redis (connection closed before response)"); default: throw new ProtocolException("Unexpected input: " + (byte) read); } diff --git a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java index cc2a54a..2b53b82 100644 --- a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java +++ b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java @@ -972,7 +972,15 @@ public void run() { NearCacheEntry entry = entries.get(wkey); if (entry == null) continue; current = entry.count(); - cache.putBytes(entry.getByteKey(), entry.serialized(), entry.getExpires()); + try { + cache.putBytes(entry.getByteKey(), entry.serialized(), entry.getExpires()); + } + catch (Throwable t) { + // Re-offer so the entry isn't orphaned in the map forever. The outer + // catch backs off; next iteration retries from the queue. + drainQueue.offer(wkey); + throw t; + } entries.remove(wkey, entry); synchronized (tokenAddToCache) { tokenAddToCache.notifyAll(); From 11810aec38286ef1076506f8609d091df8fe36c3 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 21 May 2026 17:18:34 +0200 Subject: [PATCH 07/11] LDEV-6327 extend NearCache tests with burst and cacheRemove coverage --- tests/NearCache.cfc | 80 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/tests/NearCache.cfc b/tests/NearCache.cfc index c59d1df..c65ba79 100644 --- a/tests/NearCache.cfc +++ b/tests/NearCache.cfc @@ -33,7 +33,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="redis" { "timeToLiveSeconds":0, "testOnBorrow":true, "rnd":1, - "__test__writeCommitDelay_ms": 5000 + "__test__writeCommitDelay_ms": 1500 }, default="" readonly=false @@ -82,6 +82,84 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="redis" { expect(val).toBe("version-2", "stale-wins: expected 'version-2', got '#val#'"); }) + + it("LDEV-6327 burst of 20 duplicate puts — only the last wins", () => { + var key = "redis-test/#createGuid()#"; + + for ( var i = 1; i <= 20; i++ ) { + cachePut(key = key, value = "v#i#", cacheName = cacheName); + } + + var val = cacheGet(key = key, cacheName = cacheName); + + expect(val).toBe("v20", "burst-overwrite: expected 'v20', got '#val#'"); + }) + + it("LDEV-6327 burst of 30 unique keys all survive the drain", () => { + var keys = []; + for ( var i = 1; i <= 30; i++ ) { + var k = "redis-test/burst-#createGuid()#"; + arrayAppend(keys, k); + cachePut(key = k, value = "burst-#i#", cacheName = cacheName); + } + + // All should be readable immediately from the near cache + for ( var i = 1; i <= 30; i++ ) { + var v = cacheGet(key = keys[i], cacheName = cacheName); + expect(v).toBe("burst-#i#", "burst-survival: keys[#i#] returned '#v#'"); + } + + // Wait past the commit delay and verify drain completed cleanly via the + // back-of-Redis listing — cacheGetAllIds forces doJoin then queries Redis directly. + sleep(2000); + var allIds = cacheGetAllIds("redis-test/burst-*", cacheName); + var found = 0; + for ( var k in keys ) { + if ( arrayFindNoCase(allIds, k) ) found++; + } + expect(found).toBe(30, "burst-drain: expected 30 keys drained to Redis, found #found#"); + + // Cleanup + for ( var k in keys ) cacheRemove(k, false, cacheName); + }) + + it("cacheRemove during drain stall actually removes the queued put", () => { + var key = "redis-test/remove-#createGuid()#"; + + cachePut(key = key, value = "should-be-gone", cacheName = cacheName); + cacheRemove(key, false, cacheName); + + // After remove + waiting past the commit delay, the key should not be in Redis. + sleep(2000); + var allIds = cacheGetAllIds("redis-test/remove-*", cacheName); + expect(arrayFindNoCase(allIds, key)).toBe(0, "remove-during-stall: key '#key#' still in Redis after cacheRemove"); + }) + + it("LDEV-4413 mutations under burst don't cross-contaminate", () => { + var keys = []; + var originals = []; + for ( var i = 1; i <= 10; i++ ) { + var k = "redis-test/contam-#createGuid()#"; + var obj = { v: i }; + arrayAppend(keys, k); + arrayAppend(originals, obj); + cachePut(key = k, value = obj, cacheName = cacheName); + } + + // Mutate every original — none of these should reach the cache + for ( var i = 1; i <= 10; i++ ) { + originals[i].v = -999; + } + + // Each cacheGet should return the value at put-time, not the mutated one + for ( var i = 1; i <= 10; i++ ) { + var fromCache = cacheGet(key = keys[i], cacheName = cacheName); + expect(fromCache.v).toBe(i, "burst-contam: keys[#i#] saw mutation, v=#fromCache.v#"); + } + + // Cleanup + for ( var k in keys ) cacheRemove(k, false, cacheName); + }) }) } } From c53c6df2bb76674258f91b77b7f3ed6dac57882d Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 21 May 2026 18:01:05 +0200 Subject: [PATCH 08/11] Create CHANGELOG.md --- CHANGELOG.md | 307 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a9c0241 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,307 @@ +# Changelog + +## 4.2.0.0-SNAPSHOT (in development on `fix/LDEV-6327-stale-wins`) + +- [LDEV-6327](https://luceeserver.atlassian.net/browse/LDEV-6327) prevent near-cache data loss on transient redis failure — duplicate `cachePut`s no longer stale-win, and async writes during a Redis hiccup are retried instead of silently dropped +- [LDEV-6327](https://luceeserver.atlassian.net/browse/LDEV-6327) near-cache eviction is now O(1) (was O(n) deque scan) — noticeable under high churn +- [LDEV-4413](https://luceeserver.atlassian.net/browse/LDEV-4413) `cacheGet` no longer returns the live in-cache object reference — values are defensively copied, so mutating the returned struct/array can't corrupt the cache +- connection-drop failures now surface as `Unexpected EOF from Redis` instead of misleading null/cast errors — `cacheGet(key, false)` and other default-value calls still return null on miss, only the error message on real connection failures changes + +## 4.0.1.3-SNAPSHOT (2026-04-21) + +- allow different default values for the log name + +## 4.0.1.2-SNAPSHOT (2026-04-21) + +- log name is now configurable; more diagnostic logging in general + +## 4.0.1.1-SNAPSHOT (2026-02-20) + +- default tag type changed to `javax` (so install-and-go works on Lucee 6) + +## 4.0.1.0-SNAPSHOT (2026-02-20) + +- [LDEV-6120](https://luceeserver.atlassian.net/browse/LDEV-6120) extension now works again for Lucee 6 (javax) and Lucee 7 (jakarta) — single artefact, dual tag classes + +## 4.0.0.2 (2026-01-31) + +- add GAV (Group-Artifact-Version) to manifest + +## 4.0.0.1 (2025-11-19) + +- declare minimum Lucee core version in manifest +- internal: clean up avoidable jakarta references + +## 4.0.0.0 (2025-08-08) — Lucee 7 / Jakarta EE + +- [LDEV-5373](https://luceeserver.atlassian.net/browse/LDEV-5373) **migrate to Jakarta EE for Lucee 7 compatibility** — first release of the 4.x line. Lucee 6 users stay on 3.x. +- publishing moved to Maven Central (from legacy nexus-staging) + +## 3.0.1.0 (2025-08-08) + +Parallel 3.x release shipped alongside the 4.0.0.0 cut. + +- publishing moved to Maven Central (from legacy nexus-staging) + +## 3.0.0.55–3.0.0.57 (late 2023, untagged) + +Ant-era work that landed on master but was never tagged. Visible in `git log`: + +- bundle name reverted to previous names (post-3.0.0.54 bundle-rename fallout) +- [LDEV-5019](https://luceeserver.atlassian.net/browse/LDEV-5019) remove unused `amazon.ion` library +- [LDEV-5019](https://luceeserver.atlassian.net/browse/LDEV-5019) update Secret Manager lib +- remove unnecessary generic type causing problems with some Java versions +- [LDEV-4733](https://luceeserver.atlassian.net/browse/LDEV-4733) fix gzip detection — short payloads were occasionally mis-identified as gzipped (gzip-issue fix targeted 3.0.0.50) + +## 3.0.0.54 (2023-06-06) + +- [LDEV-4529](https://luceeserver.atlassian.net/browse/LDEV-4529) bundle amazon.ion + +## 3.0.0.53 (2023-06-06) + +- [LDEV-4529](https://luceeserver.atlassian.net/browse/LDEV-4529) bundle bouncycastle + +## 3.0.0.52 (2023-06-05) + +- [LDEV-4516](https://luceeserver.atlassian.net/browse/LDEV-4516) add fasterxml to the main Manifest (with a range to avoid version conflicts) + +## 3.0.0.51 (2023-06-05) + +- support loading classes from other extensions (e.g. Postgres driver) + +## 3.0.0.49 (2023-04-27) + +- update bundled `com.fasterxml.jackson` jars to 2.15.0; use merged AWS Secret Manager jar + +## 3.0.0.48 (2023-03-03) + +- test case for race conditions + +## 3.0.0.47 (2022-11-28) + +- [LDEV-4281](https://luceeserver.atlassian.net/browse/LDEV-4281) update bundled `httpcomponents` libs + +## 3.0.0.46 (2022-08-09) + +- clean up + +## 3.0.0.45 (2022-06-16) + +- update bundled jars + +## 3.0.0.44 (2022-05-13) + +- [LDEV-3990](https://luceeserver.atlassian.net/browse/LDEV-3990) stop logging stack traces for default-value lookups — expected failure, was spamming logs + +## 3.0.0.43 (2022-05-11) + +- remove all `Require-Bundle` entries that were already linked indirectly + +## 3.0.0.42 (2022-05-11) + +- merge branch tidy + +## 3.0.0.41 (2022-04-26) + +- change when `expire` gets set + +## 3.0.0.40 (2022-04-26) + +- fix a minor issue in bundle relation + +## 3.0.0.38 (2022-04-11) + +- fix conflict with `apache.commons.logging` + +## 3.0.0.37 (2022-03-31) + +- merge the two separate commands from release into one + +## 3.0.0.36 (2022-03-31) + +- support reading int64 values from BSON + +## 3.0.0.34 (2022-03-31) + +- make sure the time of the redlock is always updated when moving a key from open to closed + +## 3.0.0.33 (2022-02-02) + +- improve error message on connection error + +## 3.0.0.32 (2022-01-26) + +- support BSON `IKStorageValue` directly + +## 3.0.0.31 (2022-01-05) + +- update timestamp of existing records + +## 3.0.0.30 (2022-01-05) + +- improve SSL support + +## 3.0.0.29 (2021-12-03) + +(tagged as `2.0.0.29` — likely a typo for `3.0.0.29`) + +- resolve the issue where the extension breaks when different versions of the extension are loaded at the same time + +## 3.0.0.28 (2021-12-02) + +- add alias `connectionAcquireTimeout` for `connectionTimeout` + +## 3.0.0.27 (2021-12-01) + +- add `connectionTimeout` setting to the driver (including admin frontend) + +## 3.0.0.26 (2021-11-30) + +- fix timeout in redlock + +## 3.0.0.25 (2021-11-30) + +- **add `RedisCommandLowPriority()` and `RedisConnectionPoolInfo()` BIFs and `` tag** + +## 3.0.0.24 (2021-11-30) + +- **add SSL support** + +## 3.0.0.23 (2021-10-12) + +- **add AWS Secret Manager support** for credentials + +## 3.0.0.22 (2021-06-22) + +- **BSON serialisation by default** (with fallback to object serialisation, backwards-compatible reads) +- add method so Lucee can look for object serialisation support + +## 3.0.0.21 (2021-03-03) + +- improve near cache + +## 3.0.0.20 (2021-03-01) + +- resolve host every time a new connection is made (handles changing container IPs) + +## 3.0.0.19 (2021-02-25) + +- **add logging support** + +## 3.0.0.18 (2021-02-24) + +- do not allow defining when the validators are used + +## 3.0.0.17 (2021-02-24) + +- improve debugging; default `testOnBorrow` to true + +## 3.0.0.16 (2021-02-23) + +- improve performance of `getCacheEntry(..., defaultValue)` + +## 3.0.0.15 (2021-02-23) + +- add support for live and idle timeout; invalidate connection on socket timeout + +## 3.0.0.14 (2021-02-17) + +- do not invalidate connection when an exception happens + +## 3.0.0.13 (2021-02-16) + +- improve pool handling + +## 3.0.0.12 (2020-12-17) + +- convert numbers to strings consistently + +## 3.0.0.11 (2020-12-16) + +- fix `isObjectStream` method + +## 3.0.0.10 (2020-12-15) + +- fix locking issue with NearCache (early sibling of LDEV-4413 / LDEV-6327) + +## 3.0.0.9 (2020-12-03) + +- add support for database index + +## 3.0.0.8 (2020-12-03) + +- use pipeline in `SET` and `EXPIRE` + +## 3.0.0.7 (2020-12-02) + +- **DC-3283** add support for pipelining + +## 3.0.0.6 (2020-11-19) + +- fix NPE + +## 3.0.0.5 (2020-11-17) + +- add socket timeout + +## 3.0.0.4 (2020-11-17) + +- **add `RedisCommand()` BIF** — run arbitrary Redis commands from CFML + +## 3.0.0.3 (2020-10-15) + +- additional null check; passthrough `WildcardFilter` + +## 3.0.0.2 (2020-09-25) + +- **add support for (in-process) near cache** + +## 3.0.0.1 (2020-09-24) + +- fix idle time being ignored + +## 3.0.0.0 (2020-09-18) + +- **switch to a different Redis library** + +## 2.9.0.10 (2020-08-21) + +- improve connection pool handling + +## 2.9.0.7 (2020-07-31) + +- ensure the connection always gets closed + +## 2.9.0.6 (2020-05-28) + +- improve performance for `cacheClear(filter)` + +## 2.9.0.3 (2020-05-28) + +- improve performance when grabbing more than one entry; switch to binary storage + +## 2.9.0.2 (2018-12-12) + +- fix issue with `cacheClear` + +## 2.9.0.1 (2018-12-12) + +- fix case issue + +## 2.9.0.0 (2018-11-23) + +- **complete rewrite** focused on performance +- split into two drivers: plain Redis and Redis-with-Sentinel +- `hitcount` support removed (cost outweighed value) + +## 2.7.2.1-BETA (2017-09-06) + +- add UTF-8 BOM to properties files + +## 2.7.2.0-BETA (2017-09-06) + +- update version info and build process + +## Earlier + +- Initial commit 2016-08-19 (Thomas Rotter, Michael Offner). Pre-2.7.2.0-BETA history is in `git log`. From c0c6227cab34b564cef8a7610a696494108a91a1 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 21 May 2026 20:50:58 +0200 Subject: [PATCH 09/11] LDEV-6327 rename and hide write-commit delay setting, tidy Storage internals --- .../io/cache/redis/NearCacheEntry.java | 7 ++++++ .../extension/io/cache/redis/RedisCache.java | 25 +++++++++++-------- tests/NearCache.cfc | 2 +- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java b/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java index 10a4522..7944d1d 100644 --- a/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java +++ b/source/java/src/lucee/extension/io/cache/redis/NearCacheEntry.java @@ -69,6 +69,13 @@ public byte[] getByteKey() { return key; } + /** + * Returns the materialised value, or null if this entry was constructed in serialised-only form + * (i.e. queued by the async drain via {@link Storage#put} which stores only the serialised bytes + * to avoid the LDEV-4413 write-side aliasing window). In that case callers should use + * {@link #copy(ClassLoader)} to get a freshly deserialised value. The normal read path + * (RedisCache.getCacheEntry) does that automatically. + */ @Override public Object getValue() { return val; diff --git a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java index 2b53b82..60f6610 100644 --- a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java +++ b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java @@ -80,12 +80,17 @@ public class RedisCache extends CacheSupport implements Command { private final Object token = new Object(); /** - * Boxed type is to support representing the null case + * Near-cache write-commit delay (ms): when set, the async drain thread sleeps this many + * ms after each drain cycle completes (i.e. after the queue empties and the drain has + * been notified of the next put). Letting puts batch up reduces Redis writes under burst + * traffic at the cost of fresh data taking that long to reach the backing store. Also + * used by tests to deterministically observe near-cache state while puts pile up. + * Package-private — read directly by the Storage inner class, no public getter. + * Set via the `nearCacheWriteCommitDelay` init argument; null (default) disables it. + * + * Boxed type so we can represent the "unset" case as null. */ - private Integer __test__writeCommitDelay_ms = null; - public Integer get__test__writeCommitDelay_ms() { - return __test__writeCommitDelay_ms; - } + Integer nearCacheWriteCommitDelay = null; public RedisCache() { if (async) { @@ -107,8 +112,8 @@ public void init(Config config, Struct arguments) throws IOException { this.cl = arguments.getClass().getClassLoader(); if (config == null) config = CFMLEngineFactory.getInstance().getThreadConfig(); - __test__writeCommitDelay_ms = caster.toIntValue(arguments.get("__test__writeCommitDelay_ms", null), 0); - __test__writeCommitDelay_ms = __test__writeCommitDelay_ms <= 0 ? null : __test__writeCommitDelay_ms; + nearCacheWriteCommitDelay = caster.toIntValue(arguments.get("nearCacheWriteCommitDelay", null), 0); + nearCacheWriteCommitDelay = nearCacheWriteCommitDelay <= 0 ? null : nearCacheWriteCommitDelay; host = caster.toString(arguments.get("host", "localhost"), "localhost"); port = caster.toIntValue(arguments.get("port", null), 6379); @@ -897,7 +902,7 @@ private static class Storage extends Thread { // Map overwrites on duplicate puts (LDEV-6327); queue preserves drain order. private final ConcurrentHashMap entries; private final ConcurrentLinkedQueue drainQueue; - private RedisCache cache; + private final RedisCache cache; private long current = Long.MIN_VALUE; private final Object tokenAddToNear = new Object(); private final Object tokenAddToCache = new Object(); @@ -990,8 +995,8 @@ public void run() { if (drainQueue.isEmpty()) tokenAddToNear.wait(); } - if (cache.get__test__writeCommitDelay_ms() != null) { - Thread.sleep(cache.get__test__writeCommitDelay_ms()); + if (cache.nearCacheWriteCommitDelay != null) { + Thread.sleep(cache.nearCacheWriteCommitDelay); } } catch (Throwable e) { diff --git a/tests/NearCache.cfc b/tests/NearCache.cfc index c65ba79..a3f8ae0 100644 --- a/tests/NearCache.cfc +++ b/tests/NearCache.cfc @@ -33,7 +33,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="redis" { "timeToLiveSeconds":0, "testOnBorrow":true, "rnd":1, - "__test__writeCommitDelay_ms": 1500 + "nearCacheWriteCommitDelay": 1500 }, default="" readonly=false From b5294f4801ea19b9283d924633f9a96090dfcd1c Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 22 May 2026 18:42:56 +0200 Subject: [PATCH 10/11] LDEV-6327 add nearCache init arg to disable near-cache for clustering --- CHANGELOG.md | 1 + .../extension/io/cache/redis/RedisCache.java | 24 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9c0241..2b23e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - [LDEV-6327](https://luceeserver.atlassian.net/browse/LDEV-6327) prevent near-cache data loss on transient redis failure — duplicate `cachePut`s no longer stale-win, and async writes during a Redis hiccup are retried instead of silently dropped - [LDEV-6327](https://luceeserver.atlassian.net/browse/LDEV-6327) near-cache eviction is now O(1) (was O(n) deque scan) — noticeable under high churn +- [LDEV-6327](https://luceeserver.atlassian.net/browse/LDEV-6327) new `nearCache` init argument (default `true`) — set to `false` to disable the in-process near cache and route every get/put directly to Redis. Required for multi-node deployments where the near cache has no cross-node invalidation channel; trades ~2-4× throughput on hot-key workloads for correctness across nodes - [LDEV-4413](https://luceeserver.atlassian.net/browse/LDEV-4413) `cacheGet` no longer returns the live in-cache object reference — values are defensively copied, so mutating the returned struct/array can't corrupt the cache - connection-drop failures now surface as `Unexpected EOF from Redis` instead of misleading null/cast errors — `cacheGet(key, false)` and other default-value calls still return null on miss, only the error message on real connection failures changes diff --git a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java index 60f6610..e637430 100644 --- a/source/java/src/lucee/extension/io/cache/redis/RedisCache.java +++ b/source/java/src/lucee/extension/io/cache/redis/RedisCache.java @@ -64,7 +64,17 @@ public class RedisCache extends CacheSupport implements Command { private int port; private String username; - private final boolean async = true; + /** + * Whether the near-cache (async drain thread + local read cache) is enabled. + * Set from the `nearCache` init argument; defaults to true (existing behaviour). + * When false, every get/put round-trips Redis directly — required for multi-node + * deployments where the near-cache has no cross-node invalidation channel. + * + * Was previously `final true`; non-final now so init() can override. The field + * is effectively-final after init (written once at config time), so the JIT + * stable-field heuristic should still specialise the hot get/put paths. + */ + private boolean async = true; private Storage storage = new Storage(this); @@ -93,10 +103,7 @@ public class RedisCache extends CacheSupport implements Command { Integer nearCacheWriteCommitDelay = null; public RedisCache() { - if (async) { - // storage = new Storage(this); - storage.start(); - } + // storage.start() deferred to init() — async flag may be overridden by `nearCache` init arg. } @Override @@ -112,8 +119,15 @@ public void init(Config config, Struct arguments) throws IOException { this.cl = arguments.getClass().getClassLoader(); if (config == null) config = CFMLEngineFactory.getInstance().getThreadConfig(); + async = caster.toBooleanValue(arguments.get("nearCache", null), true); + if (async) storage.start(); + nearCacheWriteCommitDelay = caster.toIntValue(arguments.get("nearCacheWriteCommitDelay", null), 0); nearCacheWriteCommitDelay = nearCacheWriteCommitDelay <= 0 ? null : nearCacheWriteCommitDelay; + if (!async && nearCacheWriteCommitDelay != null) { + if (log != null) log.warn("redis-cache", "nearCacheWriteCommitDelay is ignored when nearCache=false (no async drain to delay)"); + nearCacheWriteCommitDelay = null; + } host = caster.toString(arguments.get("host", "localhost"), "localhost"); port = caster.toIntValue(arguments.get("port", null), 6379); From 8018acb62c32dc2f15b4eee021a73b55e05bb3ad Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 22 May 2026 19:04:31 +0200 Subject: [PATCH 11/11] LDEV-6327 bump snapshot to 4.1.0.0-SNAPSHOT --- CHANGELOG.md | 2 +- pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b23e85..c741366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 4.2.0.0-SNAPSHOT (in development on `fix/LDEV-6327-stale-wins`) +## 4.1.0.0-SNAPSHOT (in development on `fix/LDEV-6327-stale-wins`) - [LDEV-6327](https://luceeserver.atlassian.net/browse/LDEV-6327) prevent near-cache data loss on transient redis failure — duplicate `cachePut`s no longer stale-win, and async writes during a Redis hiccup are retried instead of silently dropped - [LDEV-6327](https://luceeserver.atlassian.net/browse/LDEV-6327) near-cache eviction is now O(1) (was O(n) deque scan) — noticeable under high churn diff --git a/pom.xml b/pom.xml index 4eeba65..fce3189 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 org.lucee redis-extension - 4.0.1.3-SNAPSHOT + 4.1.0.0-SNAPSHOT pom Redis Extension