From 1c12ee328f065283da6d8cecbb8351f803fd59a3 Mon Sep 17 00:00:00 2001 From: David Levin Date: Tue, 28 Jul 2026 10:28:51 -0700 Subject: [PATCH 1/2] Add specs demonstrating EVENT_CACHE_SUFFIX closure being frozen after first request The docs say EVENT_CACHE_SUFFIX can be a closure evaluated at runtime, but HandlerService.getEventCachingMetadata() evaluates it once and memoizes the result for the life of the app. Two of these specs fail on current code: - 'evaluates a closure suffix on every request producing distinct cache keys' The needle [-beta-] was not found in [cbox_event-eventcachingsuffix.index-alpha-FBEE7A3D7291A76924037D5B7F2AE52E] - 'keeps the closure in the memoized dictionary entry after requests' Expected [false] to be true --- test-harness/handlers/eventcachingSuffix.cfc | 19 ++++++++ tests/specs/integration/EventCachingSpec.cfc | 47 ++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 test-harness/handlers/eventcachingSuffix.cfc diff --git a/test-harness/handlers/eventcachingSuffix.cfc b/test-harness/handlers/eventcachingSuffix.cfc new file mode 100644 index 000000000..3a73ef077 --- /dev/null +++ b/test-harness/handlers/eventcachingSuffix.cfc @@ -0,0 +1,19 @@ +/** + * Fixture for EVENT_CACHE_SUFFIX closure specs. It lives apart from `eventcaching.cfc` + * because the suffix is handler-global and would change the cache keys of every spec + * that uses that handler. + */ +component output="false"{ + + // Evaluated per request: the cache key suffix carries the incoming slug + this.EVENT_CACHE_SUFFIX = function( eventHandlerBean ){ + return getRequestContext().getValue( "slug", "none" ) + } + + // cacheInclude="" keeps the rc hash constant, so only the suffix varies the cache key + function index( event, rc, prc ) cache="true" cacheTimeout="10" cacheInclude=""{ + prc.data = { when : now() } + return prc.data + } + +} diff --git a/tests/specs/integration/EventCachingSpec.cfc b/tests/specs/integration/EventCachingSpec.cfc index 16ec833b4..512e60c4a 100755 --- a/tests/specs/integration/EventCachingSpec.cfc +++ b/tests/specs/integration/EventCachingSpec.cfc @@ -451,6 +451,53 @@ expect( data2 ).notToBe( data ); } ); } ); + + describe( "EVENT_CACHE_SUFFIX", function(){ + it( "evaluates a closure suffix on every request producing distinct cache keys", function(){ + getRequestContext().setValue( "slug", "alpha" ) + var event1 = execute( event = "eventcachingSuffix.index", renderResults = true ) + var key1 = event1.getPrivateCollection().cbox_eventCacheableEntry.cacheKey + + expect( key1 ).toInclude( "-alpha-" ) + + // reset to simulate another request with a different slug + setup() + getRequestContext().setValue( "slug", "beta" ) + var event2 = execute( event = "eventcachingSuffix.index", renderResults = true ) + var key2 = event2.getPrivateCollection().cbox_eventCacheableEntry.cacheKey + + // the closure must re-evaluate per request, not freeze on the first request's value + expect( key2 ).toInclude( "-beta-" ) + expect( key2 ).notToBe( key1 ) + } ); + + it( "produces the same key on the serve-side lookup and the store-side build", function(){ + getRequestContext().setValue( "slug", "gamma" ) + var event = execute( event = "eventcachingSuffix.index", renderResults = true ) + var storeKey = event.getPrivateCollection().cbox_eventCacheableEntry.cacheKey + + // re-run the real serve-side path: getEventMetadataEntry() -> buildEventKey() + controller.getRequestService().eventCachingTest( event ) + var serveKey = event.getPrivateCollection().cbox_eventCacheableEntry.cacheKey + + // if lookup and storage keys disagree, cached responses are never served + expect( serveKey ).toBe( storeKey ) + } ); + + it( "keeps the closure in the memoized dictionary entry after requests", function(){ + getRequestContext().setValue( "slug", "delta" ) + execute( event = "eventcachingSuffix.index", renderResults = true ) + + var dictionary = prepareMock( controller.getHandlerService() ).$getProperty( + "eventCacheDictionary", + "variables" + ) + var suffix = dictionary[ "eventcachingSuffix.index" ].suffix + + // the dictionary must keep the closure so later requests can re-evaluate it + expect( isClosure( suffix ) || isCustomFunction( suffix ) ).toBeTrue() + } ); + } ); } ); } From 172961f44ac67263733a1dedf8945266bea8fba8 Mon Sep 17 00:00:00 2001 From: David Levin Date: Tue, 28 Jul 2026 10:33:08 -0700 Subject: [PATCH 2/2] Evaluate EVENT_CACHE_SUFFIX closures per request instead of freezing the first value The memoized eventCacheDictionary entry now stores the closure as declared. Both read points resolve it for the current request on a shallow copy via the new private resolveCacheSuffix(): - Store side: getEventCachingMetadata() resolves with the in-flight handler bean. - Serve side: getEventMetadataEntry() resolves via getHandlerBean(), with an isSimpleValue() fast path so static suffixes skip the work. The closure now also receives the request context as a second argument: this.EVENT_CACHE_SUFFIX = function( eventHandlerBean, event ){ ... } Existing single-argument closures keep working. The closure runs twice per request (serve-side lookup + store-side key build), so it must read request-stable inputs (rc, session, locale) and never time or randomness - the same contract the hashed rc already has. All EventCachingSpec specs pass, including the two that failed before this change; full integration suite green (86/86 on BoxLang). --- system/web/services/HandlerService.cfc | 60 ++++++++++++++++---- test-harness/handlers/eventcachingSuffix.cfc | 4 +- tests/specs/integration/EventCachingSpec.cfc | 14 +++++ 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/system/web/services/HandlerService.cfc b/system/web/services/HandlerService.cfc index 86ad886d2..3681d7bc5 100644 --- a/system/web/services/HandlerService.cfc +++ b/system/web/services/HandlerService.cfc @@ -577,7 +577,16 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { return getNewMDEntry() } - return variables.eventCacheDictionary[ arguments.targetEvent ] + var mdEntry = variables.eventCacheDictionary[ arguments.targetEvent ] + + // Fast path: a static suffix needs no per-request work, hand back the memoized entry + if ( isSimpleValue( mdEntry.suffix ) ) { + return mdEntry + } + + // Closure suffix: resolve it for THIS request. The serve side has no bean in + // hand, so getHandlerBean() supplies the bean the closure contract documents. + return resolveCacheSuffix( mdEntry, getHandlerBean( arguments.targetEvent ) ) } /** @@ -810,15 +819,12 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { mdEntry.cacheExclude = arguments.ehBean.getActionMetadata( "cacheExclude", "" ); mdEntry.cacheFilter = arguments.ehBean.getActionMetadata( "cacheFilter", "" ); - // Handler Event Cache Key Suffix, this is global to the event - if ( - isClosure( arguments.oEventHandler.EVENT_CACHE_SUFFIX ) || - isCustomFunction( arguments.oEventHandler.EVENT_CACHE_SUFFIX ) - ) { - mdEntry.suffix = oEventHandler.EVENT_CACHE_SUFFIX( arguments.ehBean ); - } else { - mdEntry.suffix = arguments.oEventHandler.EVENT_CACHE_SUFFIX; - } + // Handler Event Cache Key Suffix, this is global to the event. + // Stored AS DECLARED: a closure must NOT be evaluated here because this + // entry is memoized for the life of the app, and a request-time value + // (locale, session, slug) would freeze into every later request's cache + // key. resolveCacheSuffix() evaluates it on every read instead. + mdEntry.suffix = arguments.oEventHandler.EVENT_CACHE_SUFFIX; // if the cacheFilter has a length and is a method, then we need to verify and store the resulting closure if ( len( mdEntry.cacheFilter ) ) { @@ -861,7 +867,39 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { } // end if - return variables.eventCacheDictionary[ cacheKey ]; + return resolveCacheSuffix( variables.eventCacheDictionary[ cacheKey ], arguments.ehBean ); + } + + /** + * Resolve a metadata entry's cache-key suffix for the CURRENT request. + * + * A static string suffix passes the entry through untouched. A closure suffix is + * evaluated now, on a shallow COPY of the entry — the memoized entry keeps the + * closure so every request re-evaluates it (locale, session, slug use cases). + * + * The closure receives ( eventHandlerBean, event ) and runs twice per request + * (serve-side key lookup + store-side key build), so it must be deterministic + * within a request: read request-stable inputs only, never time or randomness, + * and mutate nothing — the same contract the hashed rc and the stored + * cacheFilter closure already have. + * + * @mdEntry The memoized event caching metadata entry + * @ehBean The event handler bean, passed to the closure as its first argument + */ + private struct function resolveCacheSuffix( required struct mdEntry, required ehBean ){ + // We check for isClosure and isCustomFunction for ACF/Lucee/BoxLang compatibility + if ( + !isClosure( arguments.mdEntry.suffix ) && + !isCustomFunction( arguments.mdEntry.suffix ) + ) { + return arguments.mdEntry; + } + + var resolved = structCopy( arguments.mdEntry ); + var suffixUDF = arguments.mdEntry.suffix; + resolved.suffix = suffixUDF( arguments.ehBean, variables.controller.getRequestService().getContext() ); + + return resolved; } } diff --git a/test-harness/handlers/eventcachingSuffix.cfc b/test-harness/handlers/eventcachingSuffix.cfc index 3a73ef077..b42ff6b0c 100644 --- a/test-harness/handlers/eventcachingSuffix.cfc +++ b/test-harness/handlers/eventcachingSuffix.cfc @@ -6,8 +6,8 @@ component output="false"{ // Evaluated per request: the cache key suffix carries the incoming slug - this.EVENT_CACHE_SUFFIX = function( eventHandlerBean ){ - return getRequestContext().getValue( "slug", "none" ) + this.EVENT_CACHE_SUFFIX = function( eventHandlerBean, event ){ + return arguments.event.getValue( "slug", "none" ) } // cacheInclude="" keeps the rc hash constant, so only the suffix varies the cache key diff --git a/tests/specs/integration/EventCachingSpec.cfc b/tests/specs/integration/EventCachingSpec.cfc index 512e60c4a..8b0566c67 100755 --- a/tests/specs/integration/EventCachingSpec.cfc +++ b/tests/specs/integration/EventCachingSpec.cfc @@ -484,6 +484,20 @@ expect( serveKey ).toBe( storeKey ) } ); + it( "leaves static string suffixes untouched when resolving", function(){ + var handlerService = controller.getHandlerService() + makePublic( handlerService, "resolveCacheSuffix" ) + + var mdEntry = { "cacheable" : true, "suffix" : "static" } + var resolved = handlerService.resolveCacheSuffix( + mdEntry, + handlerService.getHandlerBean( "eventcachingSuffix.index" ) + ) + + expect( isSimpleValue( resolved.suffix ) ).toBeTrue() + expect( resolved.suffix ).toBe( "static" ) + } ); + it( "keeps the closure in the memoized dictionary entry after requests", function(){ getRequestContext().setValue( "slug", "delta" ) execute( event = "eventcachingSuffix.index", renderResults = true )