diff --git a/changelog.md b/changelog.md index dbb169dd4..e8eb15250 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- [COLDBOX-1419] Scheduled tasks could not load module components on engines that lose path mappings between requests, including Adobe ColdFusion behind IIS. ColdBox now stores mappings for module folders and registered module paths. Scheduled tasks register these paths with the CFML engine before the task starts. + ## [8.1.0] - 2026-04-14 - diff --git a/system/web/services/ModuleService.cfc b/system/web/services/ModuleService.cfc index 88aa0b51a..519638d34 100755 --- a/system/web/services/ModuleService.cfc +++ b/system/web/services/ModuleService.cfc @@ -250,6 +250,11 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { physicalPath : expandPath( "/" & replace( arguments.invocationPath, ".", "/", "all" ) ), invocationPath : arguments.invocationPath } + // Register the module path with the CFML engine. This lets scheduled tasks find + // module components when no web request is active. (COLDBOX-1419) + var explicitLocation = variables.moduleRegistry[ arguments.moduleName ] + variables.mappingRegistry[ explicitLocation.locationPath ] = explicitLocation.physicalPath + variables.util.addMapping( name: explicitLocation.locationPath, path: explicitLocation.physicalPath ) } // Check if passed module name is not loaded into the registry @@ -1506,7 +1511,14 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { */ private function scanModulesDirectory( required dirPath ){ var expandedPath = expandPath( arguments.dirpath ) - var dirEntries = directoryList( expandedPath, false, "query", "", "asc" ) + + // Register this module folder with the CFML engine. This lets scheduled tasks find + // module components when no web request is active. (COLDBOX-1419) + var mappingName = arguments.dirPath.startsWith( "/" ) ? arguments.dirPath : "/" & arguments.dirPath + variables.mappingRegistry[ mappingName ] = expandedPath + variables.util.addMapping( name: mappingName, path: expandedPath ) + + var dirEntries = directoryList( expandedPath, false, "query", "", "asc" ) for ( var item in dirEntries ) { // Only directories and no . folders if ( item.type == "Dir" && !item.name.startsWith( "." ) ) { diff --git a/system/web/tasks/ColdBoxScheduledTask.cfc b/system/web/tasks/ColdBoxScheduledTask.cfc index 97955baa1..3d9103252 100644 --- a/system/web/tasks/ColdBoxScheduledTask.cfc +++ b/system/web/tasks/ColdBoxScheduledTask.cfc @@ -104,6 +104,27 @@ component extends="coldbox.system.async.tasks.ScheduledTask" accessors="true" { return this } + /** + * Runs this task on the scheduler's background thread. + * Registers module paths with the CFML engine before the task starts. Adobe ColdFusion + * can lose mappings that code adds while the application runs. Scheduled tasks also + * skip the mapping setup used by web requests. Without this setup, a task may not find + * its module components. (COLDBOX-1419) + * + * @force Run the task even if it is disabled or blocked by a constraint + */ + function run( boolean force = false ){ + try { + if ( !isNull( variables.controller ) ) { + variables.controller.getModuleService().loadMappings(); + } + } catch ( any e ) { + // Log the mapping error and let the task continue. + err( "Error loading module mappings for task (#getName()#) : #e.message & e.detail#" ); + } + super.run( argumentCollection = arguments ); + } + /** * This method verifies if the running task is constrained to run on specific valid constraints: * diff --git a/tests/specs/async/tasks/ColdBoxScheduledTaskSpec.cfc b/tests/specs/async/tasks/ColdBoxScheduledTaskSpec.cfc index f84f0d723..901d455b0 100644 --- a/tests/specs/async/tasks/ColdBoxScheduledTaskSpec.cfc +++ b/tests/specs/async/tasks/ColdBoxScheduledTaskSpec.cfc @@ -86,6 +86,50 @@ component extends="tests.resources.BaseIntegrationTest" { expect( t.getCache().getKeys() ).toInclude( t.getFixationCacheKey() ); } ); + describe( "module mappings for scheduled tasks (COLDBOX-1419)", function(){ + it( "loads module mappings before the task runs", function(){ + var t = prepareMock( + scheduler + .task( "cbTask-mapping-replay" ) + .call( function(){ + return "ran"; + } ) + ); + + var mockModuleService = createEmptyMock( "coldbox.system.web.services.ModuleService" ).$( "loadMappings" ); + var mockController = createStub().$( "getModuleService", mockModuleService ); + t.$property( propertyName: "controller", mock: mockController ); + + t.run( force = true ); + + expect( mockModuleService.$once( "loadMappings" ) ).toBeTrue(); + expect( t.getStats().totalSuccess ).toBe( 1 ); + } ); + + it( "runs the task when loading module mappings fails", function(){ + var t = prepareMock( + scheduler + .task( "cbTask-mapping-replay-fail" ) + .call( function(){ + return "ran"; + } ) + ); + + var mockModuleService = createEmptyMock( "coldbox.system.web.services.ModuleService" ).$( + method : "loadMappings", + throwException: true, + throwType : "MockMappingException" + ); + var mockController = createStub().$( "getModuleService", mockModuleService ); + t.$property( propertyName: "controller", mock: mockController ); + + t.run( force = true ); + + expect( mockModuleService.$once( "loadMappings" ) ).toBeTrue(); + expect( t.getStats().totalSuccess ).toBe( 1 ); + } ); + } ); + describe( "schedule synchronization", function(){ it( "stores schedule metadata in cache lock", function(){ var t = scheduler diff --git a/tests/specs/web/services/ModuleServiceTest.cfc b/tests/specs/web/services/ModuleServiceTest.cfc index 8ed1d1345..5342974e4 100644 --- a/tests/specs/web/services/ModuleServiceTest.cfc +++ b/tests/specs/web/services/ModuleServiceTest.cfc @@ -32,6 +32,37 @@ component extends="tests.resources.BaseIntegrationTest" { .toBeInstanceOf( "mserv.models.MyModel" ); } ); + it( "Adds every module search folder to the mapping registry (COLDBOX-1419)", function(){ + var mappingRegistry = variables.moduleService.getMappingRegistry(); + var scanLocations = [ + "/coldbox/system/modules", + getController().getSetting( "ModulesLocation" ) + ]; + scanLocations.append( getController().getSetting( "ModulesExternalLocation" ), true ); + + scanLocations + .filter( function( location ){ + return arguments.location.trim().len(); + } ) + .each( function( location ){ + var mappingName = arguments.location.startsWith( "/" ) ? arguments.location : "/" & arguments.location; + expect( mappingRegistry ).toHaveKey( mappingName ); + expect( mappingRegistry[ mappingName ] ).toBe( expandPath( mappingName ) ); + } ); + } ); + + it( "Adds a registered module path to the mapping registry (COLDBOX-1419)", function(){ + if ( !variables.moduleService.isModuleRegistered( "test-module" ) ) { + variables.moduleService.registerAndActivateModule( "test-module", "tests.resources" ); + } + var mappingRegistry = variables.moduleService.getMappingRegistry(); + // The CFML component loader must be able to find the registered module path. + expect( mappingRegistry ).toHaveKey( "/tests/resources" ); + expect( mappingRegistry[ "/tests/resources" ] ).toBe( expandPath( "/tests/resources" ) ); + // Keep the "mserv" mapping declared by the test module. + expect( mappingRegistry ).toHaveKey( "/mserv" ); + } ); + it( "Can reload a convention registered module", function(){ variables.moduleService.reload( "api" ); expect( variables.moduleService.getModuleRegistry() ).toHaveKey( "api" );