diff --git a/changelog.md b/changelog.md index dbb169dd4..999659d50 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `BoxLangProvider` did not convert CacheBox's minute-based timeouts before handing them to BoxLang's + cache, which reads a bare number as seconds, so every timeout expired sixty times too soon. A region + moved from `CacheBoxProvider` to `BoxLangProvider` kept a 10 minute object for 10 seconds. + `LuceeProvider` and `CFProvider` already convert. + ## [8.1.0] - 2026-04-14 - diff --git a/system/cache/providers/BoxLangProvider.cfc b/system/cache/providers/BoxLangProvider.cfc index fee8aa9c5..912feac9b 100644 --- a/system/cache/providers/BoxLangProvider.cfc +++ b/system/cache/providers/BoxLangProvider.cfc @@ -327,8 +327,8 @@ component return cache( getConfiguration().cacheName ).getOrSet( arguments.objectKey, arguments.produce, - arguments.timeout, - arguments.lastAccessTimeout, + toTimespan( arguments.timeout ), + toTimespan( arguments.lastAccessTimeout ), arguments.extra ); } @@ -354,8 +354,8 @@ component cache( getConfiguration().cacheName ).set( arguments.objectKey, arguments.object, - arguments.timeout, - arguments.lastAccessTimeout, + toTimespan( arguments.timeout ), + toTimespan( arguments.lastAccessTimeout ), arguments.extra ); @@ -393,8 +393,8 @@ component cache( getConfiguration().cacheName ).set( arguments.objectKey, arguments.object, - arguments.timeout, - arguments.lastAccessTimeout, + toTimespan( arguments.timeout ), + toTimespan( arguments.lastAccessTimeout ), arguments.extra ); @@ -503,4 +503,33 @@ component return this; } + + /** + * Converts a CacheBox timeout to a timespan BoxLang's cache understands. + * + * CacheBox expresses every timeout in minutes, and BoxLang's cache reads a bare number as seconds, so + * an unconverted value expires sixty times too soon - a region moved here from CacheBoxProvider keeps + * a 10 minute object for 10 seconds. LuceeProvider and CFProvider both convert; only this one did not. + * + * An already-converted value is handed back untouched, and cannot be detected with isNumeric(): + * BoxLang's createTimespan() returns a java.time.Duration and isNumeric() answers true for one, so a + * Duration would otherwise be fed back through createTimespan() as though it were a minute count. + * + * Anything else - "" and 0 both meaning "no timeout" to CacheBox - passes through unchanged, so the + * only behaviour that changes is the case that was wrong. + * + * @timeout A CacheBox timeout in minutes, or a timespan + */ + private any function toTimespan( required any timeout ){ + if ( isInstanceOf( arguments.timeout, "java.time.Duration" ) ) { + return arguments.timeout; + } + + if ( !isNumeric( arguments.timeout ) || arguments.timeout <= 0 ) { + return arguments.timeout; + } + + return createTimespan( 0, 0, arguments.timeout, 0 ); + } + } diff --git a/tests/specs/cache/providers/BoxLangProviderTest.cfc b/tests/specs/cache/providers/BoxLangProviderTest.cfc index c390b4494..28b9f5d72 100755 --- a/tests/specs/cache/providers/BoxLangProviderTest.cfc +++ b/tests/specs/cache/providers/BoxLangProviderTest.cfc @@ -155,6 +155,48 @@ // debug(md); } + /** + * The tests above pass timespans, which is why the missing minute conversion went unnoticed. CacheBox's + * own contract is minutes - ConcurrentStore.set documents "Timeout in minutes", and CacheBoxProvider, + * LuceeProvider and CFProvider all treat it that way - so a bare number has to mean the same thing here. + */ + function testSetWithMinuteTimeouts(){ + testVal = { name : "luis", age : 32 }; + cache.clearAll(); + + cache.set( "test", testVal, 2, 1 ); + + md = cache.getCachedObjectMetadata( "test" ); + assertEquals( 120, md.timeout, "2 CacheBox minutes must not become 2 seconds" ); + assertEquals( 60, md.lastAccessTimeout, "1 CacheBox minute must not become 1 second" ); + } + + function testSetQuietWithMinuteTimeouts(){ + testVal = { name : "luis", age : 32 }; + cache.clearAll(); + + cache.setQuiet( "test", testVal, 2, 1 ); + + md = cache.getCachedObjectMetadata( "test" ); + assertEquals( 120, md.timeout ); + assertEquals( 60, md.lastAccessTimeout ); + } + + /** + * isNumeric() answers true for a java.time.Duration, so a timespan cannot be told from a minute count + * that way - without an explicit check an already-converted value is converted a second time. + */ + function testSetLeavesATimespanAlone(){ + testVal = { name : "luis", age : 32 }; + cache.clearAll(); + + cache.set( "test", testVal, createTimespan( 0, 0, 2, 0 ), createTimespan( 0, 0, 1, 0 ) ); + + md = cache.getCachedObjectMetadata( "test" ); + assertEquals( 120, md.timeout ); + assertEquals( 60, md.lastAccessTimeout ); + } + function testGetSize(){ testVal = { name : "luis", age : 32 }; cache.clearAll();