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 new file mode 100644 index 000000000..b42ff6b0c --- /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, event ){ + return arguments.event.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..8b0566c67 100755 --- a/tests/specs/integration/EventCachingSpec.cfc +++ b/tests/specs/integration/EventCachingSpec.cfc @@ -451,6 +451,67 @@ 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( "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 ) + + 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() + } ); + } ); } ); }