Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 49 additions & 11 deletions system/web/services/HandlerService.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) )
}

/**
Expand Down Expand Up @@ -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 ) ) {
Expand Down Expand Up @@ -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() );

Comment thread
homestar9 marked this conversation as resolved.
return resolved;
}

}
19 changes: 19 additions & 0 deletions test-harness/handlers/eventcachingSuffix.cfc
Original file line number Diff line number Diff line change
@@ -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
}

}
61 changes: 61 additions & 0 deletions tests/specs/integration/EventCachingSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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()
} );
} );
} );
}

Expand Down
Loading