diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 002b4d78..ffc62e4c 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -8,12 +8,15 @@ component { function configure() { settings = { - "defaultGrammar" : "AutoDiscover@qb", - "defaultQueryOptions" : {}, - "preventDuplicateJoins" : true, - "preventLazyLoading" : false, - "refreshOnSaveFallback" : true, - "lazyLoadingViolationCallback" : ( entity, relationName ) => { + "defaultGrammar" : "AutoDiscover@qb", + "defaultQueryOptions" : {}, + "parallelEagerLoadingExecutor" : "", + "parallelEagerLoadingMaxThreads" : 4, + "parallelEagerLoadingTimeout" : 60000, + "preventDuplicateJoins" : true, + "preventLazyLoading" : false, + "refreshOnSaveFallback" : true, + "lazyLoadingViolationCallback" : ( entity, relationName ) => { throw( type = "QuickLazyLoadingException", message = "Attempted to lazy load the [#arguments.relationName#] relationship on the entity [#arguments.entity.mappingName()#] but lazy loading is disabled. This is usually caused by the N+1 problem and is a sign that you are missing an eager load." @@ -54,6 +57,36 @@ component { } function onLoad() { + var asyncManager = wirebox.getInstance( "AsyncManager@coldbox" ); + variables.ownsParallelEagerLoadExecutor = false; + if ( trim( settings.parallelEagerLoadingExecutor ) == "" ) { + settings.parallelEagerLoadingExecutor = "quick-parallel-eager-loading"; + if ( !asyncManager.hasExecutor( settings.parallelEagerLoadingExecutor ) ) { + asyncManager.newExecutor( + name = "quick-parallel-eager-loading", + type = "fixed", + threads = max( 1, int( settings.parallelEagerLoadingMaxThreads ) ), + loadAppContext = true + ); + variables.ownsParallelEagerLoadExecutor = true; + } + } else if ( !asyncManager.hasExecutor( settings.parallelEagerLoadingExecutor ) ) { + throw( + type = "QuickParallelEagerLoadingExecutorNotFound", + message = "The configured parallel eager-loading executor [#settings.parallelEagerLoadingExecutor#] is not registered with ColdBox's AsyncManager." + ); + } + + var parallelExecutorMaxThreads = asyncManager + .getExecutor( settings.parallelEagerLoadingExecutor ) + .getMaximumPoolSize(); + if ( parallelExecutorMaxThreads <= 0 || parallelExecutorMaxThreads >= 2147483647 ) { + throw( + type = "QuickParallelEagerLoadingExecutorNotBounded", + message = "The configured parallel eager-loading executor [#settings.parallelEagerLoadingExecutor#] must have a bounded maximum pool size." + ); + } + binder .map( alias = "QuickQB@quick", force = true ) .to( "#moduleMapping#.models.QuickQB" ) @@ -86,6 +119,14 @@ component { } function onUnload() { + var asyncManager = wirebox.getInstance( "AsyncManager@coldbox" ); + if ( + variables.ownsParallelEagerLoadExecutor + && asyncManager.hasExecutor( settings.parallelEagerLoadingExecutor ) + ) { + asyncManager.deleteExecutor( settings.parallelEagerLoadingExecutor ); + } + var cacheBox = wirebox.getCachebox(); if ( cacheBox.cacheExists( settings.metadataCache.name ) ) { cacheBox.getCache( settings.metadataCache.name ).clearAll(); diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 67460aa8..434918b2 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -949,11 +949,11 @@ component accessors="true" { overlay = overlay.previous; } - var attributes = []; + var runtimeAttributes = []; for ( var i = newestFirst.len(); i >= 1; i-- ) { - attributes.append( newestFirst[ i ] ); + runtimeAttributes.append( newestFirst[ i ] ); } - return attributes; + return runtimeAttributes; } private void function registerRuntimeAttribute( required struct attribute ) { diff --git a/models/ParallelEagerLoadingCoordinator.cfc b/models/ParallelEagerLoadingCoordinator.cfc new file mode 100644 index 00000000..a8655147 --- /dev/null +++ b/models/ParallelEagerLoadingCoordinator.cfc @@ -0,0 +1,73 @@ +/** + * Coordinates parallel eager-loading work on Quick's application-wide executor. + */ +component singleton { + + property name="asyncManager" inject="AsyncManager@coldbox"; + property name="controller" inject="coldbox"; + property name="executorName" inject="box:setting:parallelEagerLoadingExecutor@quick"; + property name="requestService" inject="coldbox:requestService"; + + function init() { + variables.currentWorker = createObject( "java", "java.lang.ThreadLocal" ).init(); + return this; + } + + public any function submit( required any task ) { + return getExecutor().submit( arguments.task, "run" ); + } + + public any function getExecutor() { + return variables.asyncManager.getExecutor( variables.executorName ); + } + + public numeric function getMaximumThreads() { + return max( 1, getExecutor().getMaximumPoolSize() ); + } + + public struct function getWorkerApplicationSettings() { + if ( server.keyExists( "boxlang" ) || server.keyExists( "lucee" ) ) { + var applicationSettings = getApplicationSettings(); + var workerSettings = { "mappings" : structCopy( applicationSettings.mappings ) }; + if ( applicationSettings.keyExists( "datasource" ) && !isNull( applicationSettings.datasource ) ) { + workerSettings.datasource = applicationSettings.datasource; + } + return workerSettings; + } + return {}; + } + + public any function createWorkerRequestContext( required string workerName ) { + var sourceContext = variables.requestService.getContext(); + var workerContext = createObject( "component", "coldbox.system.web.context.RequestContext" ).init( + properties = variables.controller.getConfigSettings(), + controller = variables.controller + ); + workerContext.collectionAppend( structCopy( sourceContext.getCollection() ), true ); + workerContext.collectionAppend( + structCopy( sourceContext.getPrivateCollection() ), + true, + true + ); + workerContext.setPrivateValue( "__quickParallelWorkerContextId", arguments.workerName ); + return workerContext; + } + + public void function enterWorker( required string name ) { + variables.currentWorker.set( arguments.name ); + } + + public void function leaveWorker() { + variables.currentWorker.remove(); + } + + public boolean function isWorker() { + return !isNull( variables.currentWorker.get() ); + } + + public string function getWorkerName() { + var workerName = variables.currentWorker.get(); + return isNull( workerName ) ? "" : workerName; + } + +} diff --git a/models/ParallelEagerLoadingTask.cfc b/models/ParallelEagerLoadingTask.cfc new file mode 100644 index 00000000..018b0b7c --- /dev/null +++ b/models/ParallelEagerLoadingTask.cfc @@ -0,0 +1,86 @@ +/** + * Retrieves and hydrates one prepared top-level eager-loading branch. + * Relationship preparation and parent matching remain on the calling thread. + */ +component { + + function init( + required struct plan, + required string name, + required any coordinator, + required any requestContext, + required struct applicationSettings, + required any completionQueue + ) { + variables.plan = arguments.plan; + variables.name = arguments.name; + variables.coordinator = arguments.coordinator; + variables.requestContext = arguments.requestContext; + variables.applicationSettings = arguments.applicationSettings; + variables.completionQueue = arguments.completionQueue; + return this; + } + + public void function run() { + // ColdBox's BoxLang executor context retains the caller's request scope. + // Enter a fresh application request so concurrent workers cannot overwrite + // each other's ColdBox RequestContext. + if ( server.keyExists( "boxlang" ) ) { + runThreadInContext( + applicationName = getApplicationMetadata().name, + callback = function() { + applyWorkerApplicationSettings(); + execute(); + } + ); + return; + } + applyWorkerApplicationSettings(); + execute(); + } + + private void function applyWorkerApplicationSettings() { + if ( variables.applicationSettings.isEmpty() ) { + return; + } + if ( variables.applicationSettings.keyExists( "datasource" ) ) { + application + action ="update" + mappings =variables.applicationSettings.mappings + datasource=variables.applicationSettings.datasource; + } else { + application action="update" mappings=variables.applicationSettings.mappings; + } + } + + private void function execute() { + var requestContextInstalled = false; + var workerEntered = false; + try { + request.cb_requestContext = variables.requestContext; + requestContextInstalled = true; + variables.coordinator.enterWorker( variables.name ); + workerEntered = true; + var rows = variables.plan.relation.retrieveEagerRows(); + variables.completionQueue.offer( { + "name" : variables.name, + "results" : variables.plan.relation.hydrateEagerRows( rows ), + "success" : true + } ); + } catch ( any e ) { + variables.completionQueue.offer( { + "name" : variables.name, + "success" : false, + "error" : e + } ); + } finally { + if ( workerEntered ) { + variables.coordinator.leaveWorker(); + } + if ( requestContextInstalled ) { + structDelete( request, "cb_requestContext" ); + } + } + } + +} diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 23983303..764d4342 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -38,6 +38,11 @@ component accessors="true" transientCache="false" { */ property name="_eagerLoad"; + /** + * Whether top-level eager loads should execute concurrently. + */ + property name="_parallelEagerLoading" default="false"; + /** * A flag marking if this builder should return as a qb result or as a collection of entities. */ @@ -62,6 +67,27 @@ component accessors="true" transientCache="false" { */ property name="_lazyLoadingViolationCallback" inject="box:setting:lazyLoadingViolationCallback@quick"; + /** + * The maximum number of eager-loading workers that may run at once. + */ + property + name ="_parallelEagerLoadingMaxThreads" + default="4" + inject ="box:setting:parallelEagerLoadingMaxThreads@quick"; + + /** + * The number of milliseconds to wait for a batch of eager-loading workers. + */ + property + name ="_parallelEagerLoadingTimeout" + default="60000" + inject ="box:setting:parallelEagerLoadingTimeout@quick"; + + /** + * Application-wide coordinator for parallel eager-loading queries. + */ + property name="_parallelEagerLoadingCoordinator" inject="quick.models.ParallelEagerLoadingCoordinator"; + /** * A map of aliases to entities to use when qualifying aliased columns. */ @@ -93,14 +119,17 @@ component accessors="true" transientCache="false" { this.isQuickBuilder = true; function init() { - variables._eagerLoad = []; - variables._globalScopesApplied = false; - variables._globalScopeExcludeAll = false; - variables._asMemento = false; - variables._asQuery = false; - variables._withAliases = false; - variables._entityTransformers = []; - param variables._preventLazyLoading = false; + variables._eagerLoad = []; + variables._parallelEagerLoading = false; + variables._globalScopesApplied = false; + variables._globalScopeExcludeAll = false; + variables._asMemento = false; + variables._asQuery = false; + variables._withAliases = false; + variables._entityTransformers = []; + param variables._parallelEagerLoadingMaxThreads = 4; + param variables._parallelEagerLoadingTimeout = 60000; + param variables._preventLazyLoading = false; if ( !variables.keyExists( "_lazyLoadingViolationCallback" ) || isNull( variables._lazyLoadingViolationCallback ) ) { variables._lazyLoadingViolationCallback = ( entity, relationName ) => { throw( @@ -437,21 +466,65 @@ component accessors="true" transientCache="false" { * @return [quick.models.BaseEntity] */ private array function getEntities( any columns, struct options = {} ) { + return hydrateEagerRows( retrieveUnhydratedResults( argumentCollection = arguments ) ); + } + + /** + * Executes the configured query without hydrating entities. + * + * This internal seam lets parallel eager-loading workers perform database + * I/O while hydration and lifecycle events remain on the calling thread. + * + * @internal + */ + public QuickBuilder function prepareUnhydratedQuery() { + activateGlobalScopes(); if ( !variables._asQuery ) { ensureKeyColumnsSelected(); } - var results = variables.qb.get( argumentCollection = arguments ); + return this; + } + + /** + * Executes a query prepared by `prepareUnhydratedQuery`. + * + * @internal + */ + public array function retrieveUnhydratedResults( any columns, struct options = {} ) { + prepareUnhydratedQuery(); + return variables.qb.get( argumentCollection = arguments ); + } + + /** + * Hydrates rows from `retrieveUnhydratedResults` without applying this + * builder's eager loads or final transformations. + * + * @internal + */ + public array function hydrateEagerRows( required array results ) { if ( variables._asQuery ) { - return results; + return arguments.results; } var refreshQuery = variables.qb.clone(); var entities = []; - for ( var result in results ) { + for ( var result in arguments.results ) { entities.append( variables.loadEntity( result, refreshQuery ) ); } return entities; } + /** + * Applies normal Quick hydration, nested eager loads, transformations, and + * collection construction to rows executed on a worker thread. + * + * @internal + */ + public any function hydrateUnhydratedResults( required array results ) { + return getEntity().newCollection( + handleTransformations( eagerLoadRelations( hydrateEagerRows( arguments.results ) ) ) + ); + } + /** * Retrieves all the entities. * It does this by resetting the configured query before retrieving the results. @@ -472,7 +545,6 @@ component accessors="true" transientCache="false" { * @return any */ public any function get( any columns, struct options = {} ) { - activateGlobalScopes(); return getEntity().newCollection( handleTransformations( eagerLoadRelations( getEntities( argumentCollection = arguments ) ) ) ); @@ -713,9 +785,11 @@ component accessors="true" transientCache="false" { * @relationName A single relation name or array of relation * names to eager load. * + * @parallel If true, eager loads top-level relationships concurrently. + * * @return QuickBuilder */ - public any function with( required any relationName ) { + public any function with( required any relationName, boolean parallel = false ) { if ( isSimpleValue( arguments.relationName ) && arguments.relationName == "" ) { return this; } @@ -725,6 +799,7 @@ component accessors="true" transientCache="false" { arrayWrap( arguments.relationName ), true ); + variables._parallelEagerLoading = variables._parallelEagerLoading || arguments.parallel; return this; } @@ -761,6 +836,9 @@ component accessors="true" transientCache="false" { } } variables._eagerLoad = eagerLoadList; + if ( variables._eagerLoad.isEmpty() ) { + variables._parallelEagerLoading = false; + } return this; } @@ -770,7 +848,8 @@ component accessors="true" transientCache="false" { * @return QuickBuilder */ public any function clearEagerLoads() { - variables._eagerLoad = []; + variables._eagerLoad = []; + variables._parallelEagerLoading = false; return this; } @@ -806,17 +885,239 @@ component accessors="true" transientCache="false" { } var eagerLoads = denestEagerLoads( variables._eagerLoad ); - for ( var relationName in eagerLoads ) { - arguments.entities = eagerLoadRelation( - relationName, - eagerLoads[ relationName ], - arguments.entities - ); + if ( + variables._parallelEagerLoading + && eagerLoads.count() > 1 + && supportsParallelEagerLoading() + && !isInsideDatabaseTransaction() + ) { + eagerLoadRelationsInParallel( eagerLoads, arguments.entities ); + } else { + for ( var relationName in eagerLoads ) { + arguments.entities = eagerLoadRelation( + relationName, + eagerLoads[ relationName ], + arguments.entities + ); + } } return arguments.entities; } + /** + * Eager loads independent top-level relationships on Quick's fixed executor. + */ + private void function eagerLoadRelationsInParallel( required struct eagerLoads, required array entities ) { + var relationNames = arguments.eagerLoads.keyArray(); + var maxWorkers = min( + max( 1, int( variables._parallelEagerLoadingMaxThreads ) ), + variables._parallelEagerLoadingCoordinator.getMaximumThreads() + ); + var timeout = max( 1, int( variables._parallelEagerLoadingTimeout ) ); + var targetEntities = arguments.entities; + var plans = []; + + // Relationship resolution, user callbacks, and constraint construction may + // touch request state and entity prototypes. Keep all of it on the caller. + for ( var relationName in relationNames ) { + plans.append( + prepareParallelEagerLoad( + relationName, + arguments.eagerLoads[ relationName ], + targetEntities + ) + ); + } + + for ( var batchStart = 1; batchStart <= plans.len(); batchStart += maxWorkers ) { + var completionQueue = createObject( "java", "java.util.concurrent.LinkedBlockingQueue" ).init(); + var batchTasks = []; + var batchEnd = min( plans.len(), batchStart + maxWorkers - 1 ); + for ( var planIndex = batchStart; planIndex <= batchEnd; planIndex++ ) { + var plan = plans[ planIndex ]; + var taskName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + if ( !plan.hasMatches ) { + finalizeParallelEagerLoad( plan, [], targetEntities ); + continue; + } + var task = new quick.models.ParallelEagerLoadingTask( + plan, + taskName, + variables._parallelEagerLoadingCoordinator, + variables._parallelEagerLoadingCoordinator.createWorkerRequestContext( taskName ), + variables._parallelEagerLoadingCoordinator.getWorkerApplicationSettings(), + completionQueue + ); + try { + var future = variables._parallelEagerLoadingCoordinator.submit( task ); + } catch ( any e ) { + cancelParallelEagerLoadingTasks( batchTasks ); + throw( + type = "QuickParallelEagerLoadingException", + message = e.keyExists( "message" ) + ? e.message + : "A parallel eager-loading task could not be submitted." + ); + } + batchTasks.append( { + "name" : taskName, + "plan" : plan, + "future" : future + } ); + } + + var batchResults = awaitParallelEagerLoadingTasks( batchTasks, completionQueue, timeout ); + for ( var completedTask in batchTasks ) { + finalizeParallelEagerLoad( + completedTask.plan, + batchResults[ completedTask.name ], + targetEntities + ); + } + } + } + + private struct function awaitParallelEagerLoadingTasks( + required array tasks, + required any completionQueue, + required numeric timeout + ) { + var results = {}; + var timeUnit = createObject( "java", "java.util.concurrent.TimeUnit" ); + var system = createObject( "java", "java.lang.System" ); + var deadline = system.nanoTime() + ( arguments.timeout * 1000000 ); + + for ( var completedCount = 1; completedCount <= arguments.tasks.len(); completedCount++ ) { + var remainingNanos = deadline - system.nanoTime(); + if ( remainingNanos <= 0 ) { + cancelParallelEagerLoadingTasks( arguments.tasks ); + throw( + type = "QuickParallelEagerLoadingTimeout", + message = "Parallel eager loading did not complete within #arguments.timeout# milliseconds." + ); + } + + try { + var completion = arguments.completionQueue.poll( + javacast( "long", ceiling( remainingNanos / 1000000 ) ), + timeUnit.MILLISECONDS + ); + } catch ( "java.lang.InterruptedException" e ) { + createObject( "java", "java.lang.Thread" ).currentThread().interrupt(); + cancelParallelEagerLoadingTasks( arguments.tasks ); + throw( + type = "QuickParallelEagerLoadingCancellationException", + message = "Parallel eager loading was interrupted while waiting for its workers." + ); + } + + if ( isNull( completion ) ) { + cancelParallelEagerLoadingTasks( arguments.tasks ); + throw( + type = "QuickParallelEagerLoadingTimeout", + message = "Parallel eager loading did not complete within #arguments.timeout# milliseconds." + ); + } + if ( !completion.success ) { + cancelParallelEagerLoadingTasks( arguments.tasks ); + throw( + type = "QuickParallelEagerLoadingException", + message = completion.error.keyExists( "message" ) + ? completion.error.message + : "A parallel eager-loading worker failed." + ); + } + results[ completion.name ] = completion.results; + } + + return results; + } + + private void function cancelParallelEagerLoadingTasks( required array tasks ) { + for ( var task in arguments.tasks ) { + if ( !task.future.isDone() ) { + task.future.cancel( true ); + } + } + } + + private struct function prepareParallelEagerLoad( + required string relationName, + required struct eagerLoadConfig, + required array entities + ) { + var nestedEagerLoads = arguments.eagerLoadConfig.keyExists( "nested" ) + ? arguments.eagerLoadConfig.nested + : {}; + var relation = resolveRelationship( getEntity(), arguments.relationName ); + if ( arguments.eagerLoadConfig.keyExists( "callback" ) ) { + arguments.eagerLoadConfig.callback( relation ); + } + var hasMatches = relation.addEagerConstraints( arguments.entities, getEntity() ); + relation.with( renestEagerLoads( nestedEagerLoads ) ); + relation.initRelation( arguments.entities, arguments.relationName ); + if ( hasMatches ) { + relation.prepareEagerQuery( variables._asQuery, variables._withAliases ); + applyDefaultDatasourceToParallelEagerLoad( relation ); + } + return { + "hasMatches" : hasMatches, + "relation" : relation, + "relationName" : arguments.relationName + }; + } + + private void function applyDefaultDatasourceToParallelEagerLoad( required any relation ) { + var queryBuilder = arguments.relation.getRelationshipBuilder().getQb(); + var defaultOptions = queryBuilder.getDefaultOptions(); + if ( defaultOptions.keyExists( "datasource" ) ) { + return; + } + + var applicationMetadata = getApplicationMetadata(); + if ( applicationMetadata.keyExists( "datasource" ) && !isNull( applicationMetadata.datasource ) ) { + queryBuilder.mergeDefaultOptions( { "datasource" : applicationMetadata.datasource } ); + } + } + + private void function finalizeParallelEagerLoad( + required struct plan, + required any results, + required array entities + ) { + var matchedEntities = arguments.plan.relation.matchEagerResults( + arguments.entities, + arguments.plan.hasMatches ? arguments.results : [], + arguments.plan.relationName + ); + for ( var entity in matchedEntities ) { + if ( isStruct( entity ) && structKeyExists( entity, "isQuickEntity" ) ) { + entity.fireRelationshipLoaded( arguments.plan.relationName ); + } + } + } + + /** + * Adobe ColdFusion does not yet support parallel eager-loading execution. + */ + private boolean function supportsParallelEagerLoading() { + return !server.keyExists( "coldfusion" ) || !findNoCase( "ColdFusion", server.coldfusion.productName ); + } + + /** + * Worker threads cannot share a caller's transaction-bound connection. + */ + private boolean function isInsideDatabaseTransaction() { + if ( getFunctionList().keyExists( "isInTransaction" ) ) { + return isInTransaction(); + } + if ( server.keyExists( "lucee" ) ) { + return !getPageContext().getDataSourceManager().isAutoCommit(); + } + return true; + } + private struct function denestEagerLoads( required array eagerLoads ) { // this comes in as an array of items which can be: // 1. dot-delimited strings (e.g., "videos.tags") @@ -1913,8 +2214,8 @@ component accessors="true" transientCache="false" { .assignAttributesData( arguments.data ) .assignOriginalAttributes( arguments.data ) .set_preventLazyLoading( variables._preventLazyLoading ) - .set_lazyLoadingViolationCallback( variables._lazyLoadingViolationCallback ) - .markLoaded(); + .set_lazyLoadingViolationCallback( variables._lazyLoadingViolationCallback ); + markLoadedEntity( childEntity ); if ( hasVirtualData ) { childEntity.set_refreshQuery( arguments.refreshQuery ); } @@ -1925,8 +2226,8 @@ component accessors="true" transientCache="false" { .assignAttributesData( arguments.data ) .assignOriginalAttributes( arguments.data ) .set_preventLazyLoading( variables._preventLazyLoading ) - .set_lazyLoadingViolationCallback( variables._lazyLoadingViolationCallback ) - .markLoaded(); + .set_lazyLoadingViolationCallback( variables._lazyLoadingViolationCallback ); + markLoadedEntity( entity ); if ( hasVirtualData ) { entity.set_refreshQuery( arguments.refreshQuery ); } @@ -1934,6 +2235,10 @@ component accessors="true" transientCache="false" { } } + private void function markLoadedEntity( required any entity ) { + arguments.entity.markLoaded(); + } + /** * Automatically converts the entities found from a query to mementos. * @@ -1987,6 +2292,7 @@ component accessors="true" transientCache="false" { newBuilder.set_globalScopeExcludeAll( this.get_globalScopeExcludeAll() ); newBuilder.set_globalScopeExclusions( this.get_globalScopeExclusions() ); newBuilder.set_eagerLoad( this.get_eagerLoad() ); + newBuilder.set_parallelEagerLoading( this.get_parallelEagerLoading() ); newBuilder.set_asQuery( this.get_asQuery() ); newBuilder.set_withAliases( this.get_withAliases() ); newBuilder.set_preventLazyLoading( this.get_preventLazyLoading() ); diff --git a/models/Relationships/BaseRelationship.cfc b/models/Relationships/BaseRelationship.cfc index 0ee26447..715a723e 100644 --- a/models/Relationships/BaseRelationship.cfc +++ b/models/Relationships/BaseRelationship.cfc @@ -146,6 +146,54 @@ component accessors="true" implements="IRelationship" { return variables.relationshipBuilder.get(); } + /** + * Prepares the eager query without executing it. + * + * @internal + */ + public any function prepareEagerQuery( boolean asQuery = false, boolean withAliases = false ) { + if ( arguments.asQuery ) { + variables.relationshipBuilder.asQuery( arguments.withAliases ); + } + variables.relationshipBuilder.prepareUnhydratedQuery(); + return this; + } + + /** + * Executes the prepared eager query without hydrating entities. + * + * @internal + */ + public array function retrieveEagerRows() { + return variables.relationshipBuilder.retrieveUnhydratedResults(); + } + + /** + * Hydrates eager-query rows through the relationship builder's normal path. + * + * @internal + */ + public array function hydrateEagerRows( required array rows ) { + return variables.relationshipBuilder.hydrateUnhydratedResults( arguments.rows ); + } + + /** + * Matches worker-hydrated results to the original parents on the caller. + * + * @internal + */ + public array function matchEagerResults( + required array entities, + required any results, + required string relationName + ) { + return this.match( + arguments.entities, + arguments.results, + arguments.relationName + ); + } + /** * Gets the first matching record for the relationship. * Returns null if no record is found. diff --git a/models/Relationships/IRelationship.cfc b/models/Relationships/IRelationship.cfc index e6e4f540..7df609ab 100644 --- a/models/Relationships/IRelationship.cfc +++ b/models/Relationships/IRelationship.cfc @@ -17,6 +17,39 @@ interface displayname="IRelationship" { */ public array function getEager( boolean asQuery, boolean withAliases ); + /** + * Prepares an eager query for execution without running it. + * + * @internal + */ + public any function prepareEagerQuery( boolean asQuery, boolean withAliases ); + + /** + * Executes a prepared eager query without hydrating entities. + * + * @internal + */ + public array function retrieveEagerRows(); + + /** + * Hydrates rows returned by a prepared eager query, including nested eager + * loads and transformations local to this relationship branch. + * + * @internal + */ + public array function hydrateEagerRows( required array rows ); + + /** + * Matches a hydrated eager-loading branch to its original parent entities. + * + * @internal + */ + public array function matchEagerResults( + required array entities, + required any results, + required string relationName + ); + /** * Adds constraints for eager loading * diff --git a/models/Relationships/PolymorphicBelongsTo.cfc b/models/Relationships/PolymorphicBelongsTo.cfc index 0f7f0265..f7e702b9 100644 --- a/models/Relationships/PolymorphicBelongsTo.cfc +++ b/models/Relationships/PolymorphicBelongsTo.cfc @@ -152,6 +152,95 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { return variables.entities; } + /** + * Prepares each morph-type query on the calling thread. + * + * @internal + */ + public any function prepareEagerQuery( boolean asQuery = false, boolean withAliases = false ) { + variables.parallelEagerQueries = []; + for ( var type in variables.dictionary ) { + var morphParent = createModelByType( type ); + var query = prepareResultsQueryByType( + type, + morphParent, + arguments.asQuery, + arguments.withAliases + ); + applyDefaultDatasourceToParallelQuery( query ); + variables.parallelEagerQueries.append( { + "morphParent" : morphParent, + "query" : query, + "type" : type + } ); + } + return this; + } + + private void function applyDefaultDatasourceToParallelQuery( required any query ) { + var queryBuilder = arguments.query.getQB(); + var defaultOptions = queryBuilder.getDefaultOptions(); + if ( defaultOptions.keyExists( "datasource" ) ) { + return; + } + + var applicationMetadata = getApplicationMetadata(); + if ( applicationMetadata.keyExists( "datasource" ) && !isNull( applicationMetadata.datasource ) ) { + queryBuilder.mergeDefaultOptions( { "datasource" : applicationMetadata.datasource } ); + } + } + + /** + * Executes the prepared morph queries without hydrating their rows. + * + * @internal + */ + public array function retrieveEagerRows() { + var resultSets = []; + for ( var eagerQuery in variables.parallelEagerQueries ) { + resultSets.append( eagerQuery.query.retrieveUnhydratedResults() ); + } + return resultSets; + } + + /** + * Hydrates each morph result set without mutating the original parents. + * + * @internal + */ + public array function hydrateEagerRows( required array rows ) { + var hydratedResults = []; + for ( var i = 1; i <= variables.parallelEagerQueries.len(); i++ ) { + var eagerQuery = variables.parallelEagerQueries[ i ]; + hydratedResults.append( { + "morphParent" : eagerQuery.morphParent, + "results" : eagerQuery.query.hydrateUnhydratedResults( arguments.rows[ i ] ), + "type" : eagerQuery.type + } ); + } + return hydratedResults; + } + + /** + * Matches worker-hydrated morph results to the original parents on the caller. + * + * @internal + */ + public array function matchEagerResults( + required array entities, + required any results, + required string relationName + ) { + for ( var morphResults in arguments.results ) { + matchToMorphParents( + morphResults.type, + morphResults.morphParent, + morphResults.results + ); + } + return arguments.entities; + } + /** * Executes a query and returns the results for a given polymorphic type. * @@ -166,17 +255,29 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { boolean asQuery = false, boolean withAliases = false ) { - var localKeys = variables.localKeys.isEmpty() ? arguments.instance.keyNames() : variables.localKeys; - var allKeys = gatherKeysByType( type ); - if ( allKeys.isEmpty() ) { return []; } + return prepareResultsQueryByType( + arguments.type, + arguments.instance, + arguments.asQuery, + arguments.withAliases + ).get(); + } - var query = arguments.instance; + private any function prepareResultsQueryByType( + required string type, + required any instance, + boolean asQuery = false, + boolean withAliases = false + ) { + var localKeys = variables.localKeys.isEmpty() ? arguments.instance.keyNames() : variables.localKeys; + var allKeys = gatherKeysByType( arguments.type ); + var query = arguments.instance.newQuery(); if ( arguments.asQuery ) { - query = query.asQuery( arguments.withAliases ); + query.asQuery( arguments.withAliases ); } var eagerConstraints = query.getQB().forNestedWhere(); for ( var keys in allKeys ) { @@ -187,7 +288,8 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { eagerConstraints.addNestedWhereQuery( keyConstraints, "or" ); } query.getQB().addNestedWhereQuery( eagerConstraints ); - return query.get(); + query.prepareUnhydratedQuery(); + return query; } /** diff --git a/tests/resources/ModuleIntegrationSpec.cfc b/tests/resources/ModuleIntegrationSpec.cfc index 88f5c9ac..2da6b6b6 100644 --- a/tests/resources/ModuleIntegrationSpec.cfc +++ b/tests/resources/ModuleIntegrationSpec.cfc @@ -27,6 +27,10 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/app" { * @aroundEach */ function useDatabaseTransactions( spec ) { + if ( request.keyExists( "quickSkipDatabaseTransactions" ) && request.quickSkipDatabaseTransactions ) { + arguments.spec.body(); + return; + } transaction action="begin" { try { arguments.spec.body(); diff --git a/tests/resources/app/config/Coldbox.cfc b/tests/resources/app/config/Coldbox.cfc index c1ccfe0b..af057f98 100644 --- a/tests/resources/app/config/Coldbox.cfc +++ b/tests/resources/app/config/Coldbox.cfc @@ -45,10 +45,19 @@ viewCaching = false }; + executors = { + "quick-test-parallel-eager-loading" = { + "type" = "fixed", + "threads" = 3, + "loadAppContext" = true + } + }; + moduleSettings = { "quick" = { - "defaultGrammar" = "MySQLGrammar@qb" - }, + "defaultGrammar" = "MySQLGrammar@qb", + "parallelEagerLoadingExecutor" = "quick-test-parallel-eager-loading" + }, "mementifier" = { "convertToTimezone" = "UTC" } diff --git a/tests/resources/app/models/ParallelLifecycleUser.cfc b/tests/resources/app/models/ParallelLifecycleUser.cfc new file mode 100644 index 00000000..4c685268 --- /dev/null +++ b/tests/resources/app/models/ParallelLifecycleUser.cfc @@ -0,0 +1,26 @@ +component + table ="users" + extends ="quick.models.BaseEntity" + accessors="true" +{ + + property name="id"; + + function postLoad( eventData ) { + param request.parallelLifecyclePostLoads = []; + request.parallelLifecyclePostLoads.append( this ); + } + + function posts() { + return hasMany( "Post", "user_id" ); + } + + function comments() { + return hasMany( "Comment", "user_id" ); + } + + function postsLoaded( entity ) { + arguments.entity.assignRelationship( "loadedByUser", this ); + } + +} diff --git a/tests/resources/app/models/Post.cfc b/tests/resources/app/models/Post.cfc index badd9f42..4f5c7ca4 100644 --- a/tests/resources/app/models/Post.cfc +++ b/tests/resources/app/models/Post.cfc @@ -19,6 +19,10 @@ component return belongsTo( "User", "user_id" ); } + function scopedAuthor() { + return belongsTo( "UserWithGlobalScope", "user_id" ); + } + function authorWithEmptyDefault() { return belongsTo( "User", "user_id" ).withDefault(); } diff --git a/tests/resources/app/models/UserWithGlobalScope.cfc b/tests/resources/app/models/UserWithGlobalScope.cfc index 9c222173..54f2d764 100644 --- a/tests/resources/app/models/UserWithGlobalScope.cfc +++ b/tests/resources/app/models/UserWithGlobalScope.cfc @@ -18,6 +18,9 @@ component extends="User" table="users" accessors="true" { } function applyGlobalScopes( qb ) { + if ( request.keyExists( "trackParallelScopeThreads" ) ) { + request.parallelScopeThreads.append( createObject( "java", "java.lang.Thread" ).currentThread().getName() ); + } qb.withCountryName(); qb.withTeamName(); qb.withBoundCountryName(); diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index 4d45318a..1022dc2b 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -13,9 +13,25 @@ component extends="tests.resources.ModuleIntegrationSpec" { } function run() { + registerCoreEagerLoadingTests(); + registerParallelExecutionTests(); + registerParallelRelationshipStateTests(); + registerParallelQueryExecutionTests(); + registerParallelHydrationStateTests(); + registerParallelLifecycleTransactionTests(); + registerEagerLoadingContinuedTests(); + registerRelationTypeTests(); + registerPolymorphicNestedTests(); + registerRetrievalDefaultTests(); + registerLazyLoadingTests(); + registerAutomaticEagerLoadingTests(); + registerMultipleNestedEagerLoadingTests(); + } + + private void function registerCoreEagerLoadingTests() { describe( "Eager Loading Spec", function() { - beforeEach( function() { - variables.queries = []; + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); } ); it( "can eager load a belongs to relationship", function() { @@ -60,6 +76,454 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( keys ).toHaveLength( 2 ); } ); + } ); + } + + private void function registerParallelExecutionTests() { + describe( "parallel eager loading execution", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); + + it( + "can eager load top-level relationships in parallel", + function() { + var callingThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); + var callbackThreads = {}; + var posts = getInstance( "Post" ) + .with( + [ + { + "author" : function( relationship ) { + callbackThreads.author = createObject( "java", "java.lang.Thread" ) + .currentThread() + .getName(); + } + }, + { + "comments" : function( relationship ) { + callbackThreads.comments = createObject( "java", "java.lang.Thread" ) + .currentThread() + .getName(); + } + } + ], + true + ) + .get(); + + expect( posts[ 1 ].getAuthor() ).toBeInstanceOf( "app.models.User" ); + expect( posts[ 1 ].getComments() ).toBeArray(); + expect( callbackThreads.author ).toBe( callingThread ); + expect( callbackThreads.comments ).toBe( callingThread ); + expect( variables.workerQueryThreads.size() ).toBe( supportsParallelEagerLoadingForTest() ? 2 : 0 ); + expect( variables.workerRequestContexts.size() ).toBe( + supportsParallelEagerLoadingForTest() ? 2 : 0 + ); + }, + "no-transaction" + ); + + it( "keeps a single eager load on the calling thread", function() { + var callingThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); + var eagerThread = ""; + getInstance( "Post" ) + .with( + { + "author" : function( relationship ) { + eagerThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); + } + }, + true + ) + .get(); + + expect( eagerThread ).toBe( callingThread ); + } ); + + it( + "delivers lifecycle events once on the returned parallel entities", + function() { + var users = getInstance( "ParallelLifecycleUser" ) + .where( "id", 1 ) + .with( [ "posts", "comments" ], true ) + .get(); + + expect( users ).toHaveLength( 1 ); + expect( request.parallelLifecyclePostLoads ).toHaveLength( 1 ); + expect( request.parallelLifecyclePostLoads[ 1 ].isSameAs( users[ 1 ] ) ).toBeTrue(); + for ( var post in users[ 1 ].getPosts() ) { + expect( post.retrieveRelationship( "loadedByUser" ).isSameAs( users[ 1 ] ) ).toBeTrue(); + } + }, + "no-transaction" + ); + + it( + "hydrates relationship branches on workers and matches parents on the caller", + function() { + if ( !supportsParallelEagerLoadingForTest() ) { + return; + } + + var callingThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); + variables.trackParallelHydration = true; + + var users = getInstance( "ParallelLifecycleUser" ) + .where( "id", 1 ) + .with( [ "posts", "comments" ], true ) + .get(); + + expect( users ).toHaveLength( 1 ); + expect( users[ 1 ].getPosts() ).notToBeEmpty( "the fixture user should have posts" ); + expect( variables.parallelHydrationThreads ).notToBeEmpty( "postLoad should execute on a worker" ); + expect( variables.parallelHydrationThreads.containsKey( callingThread ) ).toBeFalse(); + expect( variables.parallelRelationshipLoadedThreads.size() ).toBe( 1 ); + expect( variables.parallelRelationshipLoadedThreads.containsKey( callingThread ) ).toBeTrue(); + }, + "no-transaction" + ); + } ); + } + + private void function registerParallelRelationshipStateTests() { + describe( "parallel eager loading relationship state", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); + it( + "preserves nested eager loads in parallel relationship graphs", + function() { + var post = getInstance( "Post" ) + .where( "post_pk", 1245 ) + .with( [ "author.country", "comments.author" ], true ) + .firstOrFail(); + + expect( post.getAuthor().isRelationshipLoaded( "country" ) ).toBeTrue(); + for ( var comment in post.getComments() ) { + expect( comment.isRelationshipLoaded( "author" ) ).toBeTrue(); + } + if ( supportsParallelEagerLoadingForTest() ) { + expect( variables.workerQueryCount.get() ).toBeGT( + 2, + "nested eager-load queries should execute inside their top-level workers" + ); + } + }, + "no-transaction" + ); + + it( + "preserves pivot relationships in parallel relationship graphs", + function() { + var post = getInstance( "Post" ) + .where( "post_pk", 1245 ) + .with( [ "tagsAsSubscriptions", "comments" ], true ) + .firstOrFail(); + + for ( var tag in post.getTagsAsSubscriptions() ) { + expect( tag.isRelationshipLoaded( "subscription" ) ).toBeTrue(); + var pivot = tag.getSubscription(); + expect( pivot ).toBeInstanceOf( "quick.models.Relationships.Pivot" ); + expect( pivot.getContext() ).notToBeEmpty(); + expect( pivot.getPivotParent() ).toBeInstanceOf( "app.models.Post" ); + expect( pivot.getPivotRelated().isSameAs( tag ) ).toBeTrue(); + } + }, + "no-transaction" + ); + + it( + "supports polymorphic belongs-to relationships in parallel", + function() { + var comments = getInstance( "Comment" ) + .where( "designation", "public" ) + .with( [ "commentable", "author" ], true ) + .get(); + + expect( comments ).toHaveLength( 3 ); + expect( comments[ 1 ].getCommentable() ).toBeInstanceOf( "app.models.Post" ); + expect( comments[ 3 ].getCommentable() ).toBeInstanceOf( "app.models.Video" ); + expect( comments[ 1 ].getAuthor() ).toBeInstanceOf( "app.models.User" ); + expect( variables.workerQueryThreads.size() ).toBe( supportsParallelEagerLoadingForTest() ? 2 : 0 ); + }, + "no-transaction" + ); + + it( + "applies relationship global scopes on the calling thread", + function() { + var callingThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); + request.trackParallelScopeThreads = true; + request.parallelScopeThreads = []; + + getInstance( "Post" ).with( [ "scopedAuthor", "comments" ], true ).get(); + + expect( request.parallelScopeThreads ).notToBeEmpty(); + for ( var scopeThread in request.parallelScopeThreads ) { + expect( scopeThread ).toBe( callingThread ); + } + }, + "no-transaction" + ); + } ); + } + + private void function registerParallelQueryExecutionTests() { + describe( "parallel eager loading query execution", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); + it( + "supports parallel eager loading for query results", + function() { + var posts = getInstance( "Post" ) + .with( [ "author", "comments" ], true ) + .asQuery() + .get(); + + expect( posts[ 1 ] ).toBeStruct(); + expect( posts[ 1 ].author ).toBeStruct(); + expect( posts[ 1 ].comments ).toBeArray(); + expect( posts[ 3 ].author ).toBeStruct().toBeEmpty(); + expect( posts[ 1 ].author ).toHaveKey( "streetTwo" ); + }, + "no-transaction" + ); + + it( + "limits the number of concurrent eager-loading workers", + function() { + if ( supportsParallelEagerLoadingForTest() ) { + variables.parallelWorkerDelay = 25; + var builder = getInstance( "Post" ).with( [ "author", "comments" ], true ); + builder.set_parallelEagerLoadingMaxThreads( 1 ).get(); + + expect( variables.maxActiveWorkers.get() ).toBe( 1 ); + } + }, + "no-transaction" + ); + + it( + "propagates parallel eager-loading worker failures", + function() { + if ( supportsParallelEagerLoadingForTest() ) { + variables.failParallelWorker = true; + expect( function() { + getInstance( "Post" ).with( [ "author", "comments" ], true ).get(); + } ).toThrow( type = "QuickParallelEagerLoadingException" ); + } + }, + "no-transaction" + ); + + it( + "times out and cancels unfinished parallel eager-loading workers", + function() { + if ( supportsParallelEagerLoadingForTest() ) { + var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + variables.parallelWorkerDelay = 100; + var builder = getInstance( "Post" ).with( [ "author", "comments" ], true ); + builder.set_parallelEagerLoadingTimeout( 1 ); + + expect( function() { + builder.get(); + } ).toThrow( type = "QuickParallelEagerLoadingTimeout", regex = "1 milliseconds" ); + + variables.parallelWorkerDelay = 0; + var posts = getInstance( "Post" ).with( [ "author", "comments" ], true ).get(); + expect( posts ).notToBeEmpty(); + expect( coordinator.getExecutor().getMaximumPoolSize() ).toBeGT( 0 ); + } + }, + "no-transaction" + ); + } ); + } + + private void function registerParallelHydrationStateTests() { + describe( "parallel eager loading hydration state", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); + it( + "hydrates virtual attributes in parallel branches", + function() { + var post = getInstance( "Post" ) + .where( "post_pk", 1245 ) + .with( + [ + { + "comments" : function( relationship ) { + relationship.addUpperBody(); + } + }, + "author" + ], + true + ) + .firstOrFail(); + + for ( var comment in post.getComments() ) { + expect( comment.hasAttribute( "upperBody" ) ).toBeTrue(); + expect( comment.retrieveAttribute( "upperBody" ) ).toBe( uCase( comment.getBody() ) ); + } + }, + "no-transaction" + ); + + it( + "preserves unloaded default relationship entities", + function() { + var post = getInstance( "Post" ) + .whereNull( "user_id" ) + .with( [ "authorWithEmptyDefault", "comments" ], true ) + .firstOrFail(); + + expect( post.getAuthorWithEmptyDefault() ).toBeInstanceOf( "app.models.User" ); + expect( post.getAuthorWithEmptyDefault().isLoaded() ).toBeFalse(); + }, + "no-transaction" + ); + + it( + "preserves parallel eager loading when cloning a builder", + function() { + getInstance( "Post" ) + .with( [ "author", "comments" ], true ) + .clone() + .get(); + + expect( variables.workerQueryThreads.size() ).toBe( supportsParallelEagerLoadingForTest() ? 2 : 0 ); + }, + "no-transaction" + ); + + it( + "clears the parallel flag with eager loads", + function() { + getInstance( "Post" ) + .with( [ "author", "comments" ], true ) + .clearEagerLoads() + .with( [ "author", "comments" ] ) + .get(); + + expect( variables.workerQueryThreads ).toBeEmpty(); + }, + "no-transaction" + ); + + it( + "clears the parallel flag when without removes every eager load", + function() { + getInstance( "Post" ) + .with( [ "author", "comments" ], true ) + .without( [ "author", "comments" ] ) + .with( [ "author", "comments" ] ) + .get(); + + expect( variables.workerQueryThreads ).toBeEmpty(); + }, + "no-transaction" + ); + } ); + } + + private void function registerParallelLifecycleTransactionTests() { + describe( "parallel eager loading lifecycle and transactions", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); + it( + "does not duplicate instance-ready events during parallel hydration", + function() { + variables.trackInstanceReady = true; + getInstance( "Post" ).with( [ "author", "comments" ] ).get(); + var serialInstanceCount = variables.instanceReadyCount.get(); + + variables.instanceReadyCount.set( 0 ); + getInstance( "Post" ).with( [ "author", "comments" ], true ).get(); + + expect( variables.instanceReadyCount.get() ).toBe( serialInstanceCount ); + }, + "no-transaction" + ); + + it( + "does not suppress lifecycle events for queries inside eager callbacks", + function() { + getInstance( "Post" ) + .with( + [ + { + "author" : function( relationship ) { + getInstance( "ParallelLifecycleUser" ).where( "id", 1 ).get(); + } + }, + "comments" + ], + true + ) + .get(); + + expect( request.parallelLifecyclePostLoads ).toHaveLength( 1 ); + }, + "no-transaction" + ); + + it( + "uses the configured application-wide fixed worker pool", + function() { + if ( !supportsParallelEagerLoadingForTest() ) { + return; + } + var firstCoordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + var secondCoordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + var executor = firstCoordinator.getExecutor(); + var submissionsBefore = executor.getTaskSubmissionCount(); + + expect( firstCoordinator ).toBe( secondCoordinator ); + expect( executor.getName() ).toBe( "quick-test-parallel-eager-loading" ); + expect( executor.getMaximumPoolSize() ).toBe( 3 ); + expect( firstCoordinator.getMaximumThreads() ).toBe( 3 ); + getInstance( "Post" ).with( [ "author", "comments" ], true ).get(); + getInstance( "Post" ).with( [ "author", "comments" ], true ).get(); + expect( executor.getTaskSubmissionCount() ).toBe( submissionsBefore + 4 ); + expect( executor.getLargestPoolSize() ).toBeLTE( executor.getMaximumPoolSize() ); + }, + "no-transaction" + ); + + it( "falls back to serial eager loading inside a database transaction", function() { + var user = getInstance( "User" ).create( { + "username" : "parallel-transaction-user", + "first_name" : "Parallel", + "last_name" : "Transaction", + "password" : hash( "password" ) + } ); + getInstance( "Post" ).create( { + "user_id" : user.getId(), + "body" : "uncommitted parallel eager load" + } ); + + var loadedUser = getInstance( "User" ) + .where( "id", user.getId() ) + .with( [ "posts", "roles" ], true ) + .firstOrFail(); + + expect( loadedUser.getPosts() ).toHaveLength( 1 ); + expect( loadedUser.getPosts()[ 1 ].getBody() ).toBe( "uncommitted parallel eager load" ); + expect( variables.workerQueryThreads ).toBeEmpty(); + } ); + } ); + } + + private void function registerEagerLoadingContinuedTests() { + describe( "Eager Loading Spec continued", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); it( "can eager load a belongs to relationship using a composite key", function() { var compositeChildren = getInstance( "CompositeChild" ).with( "parent" ).get(); @@ -213,6 +677,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { "Only two queries should have been executed. Instead got #variables.queries.len()#." ); } ); + } ); + } + + private void function registerRelationTypeTests() { + describe( "Eager Loading Spec relation types", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); it( "can eager load a hasOne relationship", function() { var users = getInstance( "User" ) @@ -361,6 +833,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); } ); + } ); + } + + private void function registerPolymorphicNestedTests() { + describe( "Eager Loading Spec polymorphic and nested", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); it( "can eager load polymorphic belongs to relationships", function() { var comments = getInstance( "Comment" ) @@ -570,6 +1050,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( variables.queries ).toHaveLength( 3, "Only three queries should have been executed." ); } ); + } ); + } + + private void function registerRetrievalDefaultTests() { + describe( "Eager Loading Spec retrieval and defaults", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); it( "can eager load a find or first call", function() { var post = getInstance( "Post" ).with( "comments.author" ).findOrFail( 1245 ); @@ -619,232 +1107,247 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( category.getParent() ).toBeInstanceOf( "Category" ); expect( category.getParent().isLoaded() ).toBeFalse(); } ); + } ); + } + + private void function registerLazyLoadingTests() { + describe( "handling lazy loading", () => { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); - describe( "handling lazy loading", () => { - it( "can completely disable lazy loading", () => { - var posts = getInstance( "Post" ).preventLazyLoading().get(); - expect( posts ).toBeArray(); - expect( posts ).toHaveLength( 4, "4 posts should have been loaded" ); - var postA = posts[ 1 ]; - expect( () => { - postA.getComments(); - } ).toThrow( - type = "QuickLazyLoadingException", - regex = "Attempted to lazy load the \[comments\] relationship on the entity \[Post\] but lazy loading is disabled\. This is usually caused by the N\+1 problem and is a sign that you are missing an eager load\." - ); - } ); + it( "can completely disable lazy loading", () => { + var posts = getInstance( "Post" ).preventLazyLoading().get(); + expect( posts ).toBeArray(); + expect( posts ).toHaveLength( 4, "4 posts should have been loaded" ); + var postA = posts[ 1 ]; + expect( () => { + postA.getComments(); + } ).toThrow( + type = "QuickLazyLoadingException", + regex = "Attempted to lazy load the \[comments\] relationship on the entity \[Post\] but lazy loading is disabled\. This is usually caused by the N\+1 problem and is a sign that you are missing an eager load\." + ); + } ); - it( "can enable lazy loading on an entity by entity basis", () => { - var posts = getInstance( "Post" ).allowLazyLoading().get(); - expect( posts ).toBeArray(); - expect( posts ).toHaveLength( 4, "4 posts should have been loaded" ); - var postA = posts[ 1 ]; - expect( () => { - postA.getComments(); - } ).notToThrow( - type = "QuickLazyLoadingException", - regex = "Attempted to lazy load the \[comments\] relationship on the entity \[Post\] but lazy loading is disabled\. This is usually caused by the N\+1 problem and is a sign that you are missing an eager load\." - ); - } ); + it( "can enable lazy loading on an entity by entity basis", () => { + var posts = getInstance( "Post" ).allowLazyLoading().get(); + expect( posts ).toBeArray(); + expect( posts ).toHaveLength( 4, "4 posts should have been loaded" ); + var postA = posts[ 1 ]; + expect( () => { + postA.getComments(); + } ).notToThrow( + type = "QuickLazyLoadingException", + regex = "Attempted to lazy load the \[comments\] relationship on the entity \[Post\] but lazy loading is disabled\. This is usually caused by the N\+1 problem and is a sign that you are missing an eager load\." + ); + } ); - it( "can use a callback to control how lazy loading is handled", () => { - var posts = getInstance( "Post" ) - .preventLazyLoading( ( entity, relationName ) => { - throw( - type = "CustomLazyLoadingException", - message = "Custom lazy loading message about #relationName#" - ); - } ) - .get(); - expect( posts ).toBeArray(); - expect( posts ).toHaveLength( 4, "4 posts should have been loaded" ); - var postA = posts[ 1 ]; + it( "can use a callback to control how lazy loading is handled", () => { + var posts = getInstance( "Post" ) + .preventLazyLoading( ( entity, relationName ) => { + throw( + type = "CustomLazyLoadingException", + message = "Custom lazy loading message about #relationName#" + ); + } ) + .get(); + expect( posts ).toBeArray(); + expect( posts ).toHaveLength( 4, "4 posts should have been loaded" ); + var postA = posts[ 1 ]; + expect( () => { + postA.getComments(); + } ).toThrow( type = "CustomLazyLoadingException", regex = "Custom lazy loading message about comments" ); + } ); + } ); + } + + private void function registerAutomaticEagerLoadingTests() { + describe( "automatic eager loading", () => { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); + + it( "will automatically eager load specified relationships", () => { + var posts = getInstance( "EagerLoadedPost" ).preventLazyLoading().get(); + expect( posts ).toBeArray(); + expect( posts ).toHaveLength( 4, "4 posts should have been loaded" ); + for ( var post in posts ) { expect( () => { - postA.getComments(); - } ).toThrow( - type = "CustomLazyLoadingException", - regex = "Custom lazy loading message about comments" + post.getComments(); + } ).notToThrow( type = "QuickLazyLoadingException" ); + } + if ( arrayLen( variables.queries ) != 2 ) { + expect( variables.queries ).toHaveLength( + 2, + "Only two queries should have been executed. #arrayLen( variables.queries )# were instead." ); - } ); + } } ); - describe( "automatic eager loading", () => { - it( "will automatically eager load specified relationships", () => { - var posts = getInstance( "EagerLoadedPost" ).preventLazyLoading().get(); - expect( posts ).toBeArray(); - expect( posts ).toHaveLength( 4, "4 posts should have been loaded" ); - for ( var post in posts ) { - expect( () => { - post.getComments(); - } ).notToThrow( type = "QuickLazyLoadingException" ); - } - if ( arrayLen( variables.queries ) != 2 ) { - expect( variables.queries ).toHaveLength( - 2, - "Only two queries should have been executed. #arrayLen( variables.queries )# were instead." - ); - } - } ); + it( "can disable an automatically eager loaded relationship", () => { + var posts = getInstance( "EagerLoadedPost" ) + .without( "comments" ) + .preventLazyLoading() + .get(); - it( "can disable an automatically eager loaded relationship", () => { - var posts = getInstance( "EagerLoadedPost" ) - .without( "comments" ) - .preventLazyLoading() - .get(); + expect( posts ).toHaveLength( 4 ); + expect( posts[ 1 ].isRelationshipLoaded( "comments" ) ).toBeFalse(); + expect( () => posts[ 1 ].getComments() ).toThrow( type = "QuickLazyLoadingException" ); + expect( variables.queries ).toHaveLength( 1, "Only the posts query should execute." ); + } ); - expect( posts ).toHaveLength( 4 ); - expect( posts[ 1 ].isRelationshipLoaded( "comments" ) ).toBeFalse(); - expect( () => posts[ 1 ].getComments() ).toThrow( type = "QuickLazyLoadingException" ); - expect( variables.queries ).toHaveLength( 1, "Only the posts query should execute." ); - } ); + it( "does not clear eager loads when without is called without arguments", () => { + var posts = getInstance( "EagerLoadedPost" ) + .without() + .preventLazyLoading() + .get(); - it( "does not clear eager loads when without is called without arguments", () => { - var posts = getInstance( "EagerLoadedPost" ) - .without() - .preventLazyLoading() - .get(); + expect( posts ).toHaveLength( 4 ); + expect( posts[ 1 ].isRelationshipLoaded( "comments" ) ).toBeTrue(); + expect( variables.queries ).toHaveLength( 2 ); + } ); - expect( posts ).toHaveLength( 4 ); - expect( posts[ 1 ].isRelationshipLoaded( "comments" ) ).toBeTrue(); - expect( variables.queries ).toHaveLength( 2 ); - } ); + it( "can explicitly clear all eager loads", () => { + var posts = getInstance( "EagerLoadedPost" ) + .clearEagerLoads() + .preventLazyLoading() + .get(); - it( "can explicitly clear all eager loads", () => { - var posts = getInstance( "EagerLoadedPost" ) - .clearEagerLoads() - .preventLazyLoading() - .get(); + expect( posts ).toHaveLength( 4 ); + expect( posts[ 1 ].isRelationshipLoaded( "comments" ) ).toBeFalse(); + expect( () => posts[ 1 ].getComments() ).toThrow( type = "QuickLazyLoadingException" ); + expect( variables.queries ).toHaveLength( 1, "Only the posts query should execute." ); + } ); + } ); + } - expect( posts ).toHaveLength( 4 ); - expect( posts[ 1 ].isRelationshipLoaded( "comments" ) ).toBeFalse(); - expect( () => posts[ 1 ].getComments() ).toThrow( type = "QuickLazyLoadingException" ); - expect( variables.queries ).toHaveLength( 1, "Only the posts query should execute." ); - } ); + private void function registerMultipleNestedEagerLoadingTests() { + describe( "multiple nested eager loads", () => { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); } ); - describe( "multiple nested eager loads", () => { - it( "can eager load multiple nested relationships with the same parent using strings", function() { - var users = getInstance( "User" ) - .with( [ "posts.tags", "posts.comments" ] ) - .latest() - .get(); + it( "can eager load multiple nested relationships with the same parent using strings", function() { + var users = getInstance( "User" ) + .with( [ "posts.tags", "posts.comments" ] ) + .latest() + .get(); - expect( users ).toBeArray(); - expect( users ).toHaveLength( 5, "Five users should be returned" ); + expect( users ).toBeArray(); + expect( users ).toHaveLength( 5, "Five users should be returned" ); - // Find elpete who has posts with tags and comments - var elpete = users[ 5 ]; - expect( elpete.getUsername() ).toBe( "elpete" ); + // Find elpete who has posts with tags and comments + var elpete = users[ 5 ]; + expect( elpete.getUsername() ).toBe( "elpete" ); - // Verify posts relationship is loaded - expect( elpete.isRelationshipLoaded( "posts" ) ).toBeTrue( "posts should be loaded" ); - expect( elpete.getPosts() ).toBeArray(); - expect( elpete.getPosts() ).toHaveLength( 2, "Two posts should belong to elpete" ); + // Verify posts relationship is loaded + expect( elpete.isRelationshipLoaded( "posts" ) ).toBeTrue( "posts should be loaded" ); + expect( elpete.getPosts() ).toBeArray(); + expect( elpete.getPosts() ).toHaveLength( 2, "Two posts should belong to elpete" ); - // Verify both nested relationships are loaded on the posts - var postWithTagsAndComments = elpete.getPosts()[ 2 ]; // post_pk 1245 - expect( postWithTagsAndComments.getPost_Pk() ).toBe( 1245 ); - expect( postWithTagsAndComments.isRelationshipLoaded( "tags" ) ).toBeTrue( "tags should be loaded on post" ); - expect( postWithTagsAndComments.isRelationshipLoaded( "comments" ) ).toBeTrue( "comments should be loaded on post" ); + // Verify both nested relationships are loaded on the posts + var postWithTagsAndComments = elpete.getPosts()[ 2 ]; // post_pk 1245 + expect( postWithTagsAndComments.getPost_Pk() ).toBe( 1245 ); + expect( postWithTagsAndComments.isRelationshipLoaded( "tags" ) ).toBeTrue( "tags should be loaded on post" ); + expect( postWithTagsAndComments.isRelationshipLoaded( "comments" ) ).toBeTrue( "comments should be loaded on post" ); - // Verify the actual data - post 1245 has 2 tags - expect( postWithTagsAndComments.getTags() ).toBeArray(); - expect( postWithTagsAndComments.getTags() ).toHaveLength( 2 ); - expect( postWithTagsAndComments.getComments() ).toBeArray(); + // Verify the actual data - post 1245 has 2 tags + expect( postWithTagsAndComments.getTags() ).toBeArray(); + expect( postWithTagsAndComments.getTags() ).toHaveLength( 2 ); + expect( postWithTagsAndComments.getComments() ).toBeArray(); - // Should be 4 queries: users, posts, tags, comments - expect( variables.queries ).toHaveLength( - 4, - "Four queries should have been executed (users, posts, tags, comments). #arrayLen( variables.queries )# were instead." - ); - } ); + // Should be 4 queries: users, posts, tags, comments + expect( variables.queries ).toHaveLength( + 4, + "Four queries should have been executed (users, posts, tags, comments). #arrayLen( variables.queries )# were instead." + ); + } ); - it( "can eager load multiple nested relationships with the same parent using structs with callbacks", function() { - var users = getInstance( "User" ) - .with( [ - { - "posts.tags" : function( q ) { - return q.where( "name", "programming" ); - } - }, - { - "posts.comments" : function( q ) { - return q.where( "designation", "public" ); - } + it( "can eager load multiple nested relationships with the same parent using structs with callbacks", function() { + var users = getInstance( "User" ) + .with( [ + { + "posts.tags" : function( q ) { + return q.where( "name", "programming" ); } - ] ) - .latest() - .get(); + }, + { + "posts.comments" : function( q ) { + return q.where( "designation", "public" ); + } + } + ] ) + .latest() + .get(); - expect( users ).toBeArray(); - expect( users ).toHaveLength( 5, "Five users should be returned" ); + expect( users ).toBeArray(); + expect( users ).toHaveLength( 5, "Five users should be returned" ); - // Find elpete who has posts with tags and comments - var elpete = users[ 5 ]; - expect( elpete.getUsername() ).toBe( "elpete" ); + // Find elpete who has posts with tags and comments + var elpete = users[ 5 ]; + expect( elpete.getUsername() ).toBe( "elpete" ); - // Verify posts relationship is loaded - expect( elpete.isRelationshipLoaded( "posts" ) ).toBeTrue( "posts should be loaded" ); + // Verify posts relationship is loaded + expect( elpete.isRelationshipLoaded( "posts" ) ).toBeTrue( "posts should be loaded" ); - // Verify both nested relationships are loaded on the posts - var postWithTagsAndComments = elpete.getPosts()[ 2 ]; // post_pk 1245 - expect( postWithTagsAndComments.getPost_Pk() ).toBe( 1245 ); - expect( postWithTagsAndComments.isRelationshipLoaded( "tags" ) ).toBeTrue( "tags should be loaded on post" ); - expect( postWithTagsAndComments.isRelationshipLoaded( "comments" ) ).toBeTrue( "comments should be loaded on post" ); + // Verify both nested relationships are loaded on the posts + var postWithTagsAndComments = elpete.getPosts()[ 2 ]; // post_pk 1245 + expect( postWithTagsAndComments.getPost_Pk() ).toBe( 1245 ); + expect( postWithTagsAndComments.isRelationshipLoaded( "tags" ) ).toBeTrue( "tags should be loaded on post" ); + expect( postWithTagsAndComments.isRelationshipLoaded( "comments" ) ).toBeTrue( "comments should be loaded on post" ); - // Verify the callbacks were applied - only "programming" tags - var tags = postWithTagsAndComments.getTags(); - expect( tags ).toBeArray(); - for ( var tag in tags ) { - expect( tag.getName() ).toBe( "programming" ); - } + // Verify the callbacks were applied - only "programming" tags + var tags = postWithTagsAndComments.getTags(); + expect( tags ).toBeArray(); + for ( var tag in tags ) { + expect( tag.getName() ).toBe( "programming" ); + } - // Verify the callbacks were applied - only "public" comments - var comments = postWithTagsAndComments.getComments(); - expect( comments ).toBeArray(); - for ( var comment in comments ) { - expect( comment.getDesignation() ).toBe( "public" ); - } + // Verify the callbacks were applied - only "public" comments + var comments = postWithTagsAndComments.getComments(); + expect( comments ).toBeArray(); + for ( var comment in comments ) { + expect( comment.getDesignation() ).toBe( "public" ); + } - // Should be 4 queries: users, posts, tags, comments - expect( variables.queries ).toHaveLength( - 4, - "Four queries should have been executed (users, posts, tags, comments). #arrayLen( variables.queries )# were instead." - ); - } ); + // Should be 4 queries: users, posts, tags, comments + expect( variables.queries ).toHaveLength( + 4, + "Four queries should have been executed (users, posts, tags, comments). #arrayLen( variables.queries )# were instead." + ); + } ); - it( "can mix string and struct eager loads with the same parent", function() { - var users = getInstance( "User" ) - .with( [ - "posts.tags", - { - "posts.comments" : function( q ) { - return q.where( "designation", "public" ); - } + it( "can mix string and struct eager loads with the same parent", function() { + var users = getInstance( "User" ) + .with( [ + "posts.tags", + { + "posts.comments" : function( q ) { + return q.where( "designation", "public" ); } - ] ) - .latest() - .get(); + } + ] ) + .latest() + .get(); - expect( users ).toBeArray(); - expect( users ).toHaveLength( 5, "Five users should be returned" ); + expect( users ).toBeArray(); + expect( users ).toHaveLength( 5, "Five users should be returned" ); - var elpete = users[ 5 ]; - expect( elpete.getUsername() ).toBe( "elpete" ); + var elpete = users[ 5 ]; + expect( elpete.getUsername() ).toBe( "elpete" ); - var postWithTagsAndComments = elpete.getPosts()[ 2 ]; - expect( postWithTagsAndComments.isRelationshipLoaded( "tags" ) ).toBeTrue( "tags should be loaded on post" ); - expect( postWithTagsAndComments.isRelationshipLoaded( "comments" ) ).toBeTrue( "comments should be loaded on post" ); + var postWithTagsAndComments = elpete.getPosts()[ 2 ]; + expect( postWithTagsAndComments.isRelationshipLoaded( "tags" ) ).toBeTrue( "tags should be loaded on post" ); + expect( postWithTagsAndComments.isRelationshipLoaded( "comments" ) ).toBeTrue( "comments should be loaded on post" ); - // Tags should have all tags (no filter) - expect( postWithTagsAndComments.getTags() ).toBeArray(); + // Tags should have all tags (no filter) + expect( postWithTagsAndComments.getTags() ).toBeArray(); - // Comments should only have public ones (callback applied) - var comments = postWithTagsAndComments.getComments(); - for ( var comment in comments ) { - expect( comment.getDesignation() ).toBe( "public" ); - } - } ); + // Comments should only have public ones (callback applied) + var comments = postWithTagsAndComments.getComments(); + for ( var comment in comments ) { + expect( comment.getDesignation() ).toBe( "public" ); + } } ); } ); } @@ -856,7 +1359,100 @@ component extends="tests.resources.ModuleIntegrationSpec" { rc, prc ) { - arrayAppend( variables.queries, interceptData ); + lock name="EagerLoadingSpecQueries" type="exclusive" timeout="5" { + arrayAppend( variables.queries, interceptData ); + } + + var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + if ( coordinator.isWorker() ) { + var threadName = coordinator.getWorkerName(); + variables.workerQueryThreads.put( threadName, true ); + variables.workerQueryCount.incrementAndGet(); + variables.workerRequestContexts.put( + arguments.event.getPrivateValue( "__quickParallelWorkerContextId" ), + true + ); + var activeWorkers = variables.activeWorkers.incrementAndGet(); + while ( + activeWorkers > variables.maxActiveWorkers.get() + && !variables.maxActiveWorkers.compareAndSet( variables.maxActiveWorkers.get(), activeWorkers ) + ) { + } + if ( variables.parallelWorkerDelay > 0 ) { + sleep( variables.parallelWorkerDelay ); + } + if ( variables.failParallelWorker ) { + variables.activeWorkers.decrementAndGet(); + throw( type = "ExpectedParallelFailure", message = "worker failed" ); + } + variables.activeWorkers.decrementAndGet(); + } + } + + function quickInstanceReady( + event, + interceptData, + buffer, + rc, + prc + ) { + if ( variables.trackInstanceReady ) { + variables.instanceReadyCount.incrementAndGet(); + } + } + + function quickRelationshipLoaded( + event, + interceptData, + buffer, + rc, + prc + ) { + if ( + variables.trackParallelHydration + && arrayContainsNoCase( [ "posts", "comments" ], arguments.interceptData.relationshipName ) + ) { + variables.parallelRelationshipLoadedThreads.put( + createObject( "java", "java.lang.Thread" ).currentThread().getName(), + true + ); + } + } + + function quickPostLoad( + event, + interceptData, + buffer, + rc, + prc + ) { + var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + if ( variables.trackParallelHydration && coordinator.isWorker() ) { + variables.parallelHydrationThreads.put( + createObject( "java", "java.lang.Thread" ).currentThread().getName(), + true + ); + } + } + + private void function setupEagerLoadingTestState( required string currentSpec ) { + request.quickSkipDatabaseTransactions = specHasLabel( arguments.currentSpec, "no-transaction" ); + variables.queries = []; + variables.workerQueryThreads = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); + variables.workerQueryCount = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init(); + variables.workerRequestContexts = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); + variables.activeWorkers = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init(); + variables.maxActiveWorkers = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init(); + variables.parallelWorkerDelay = 0; + variables.failParallelWorker = false; + variables.trackInstanceReady = false; + variables.instanceReadyCount = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init(); + variables.trackParallelHydration = false; + variables.parallelHydrationThreads = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); + variables.parallelRelationshipLoadedThreads = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); + structDelete( request, "parallelLifecyclePostLoads" ); + structDelete( request, "trackParallelScopeThreads" ); + structDelete( request, "parallelScopeThreads" ); } private array function extractBindingTypes( required struct queryLogEntry ) { @@ -869,4 +1465,32 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); } + private boolean function supportsParallelEagerLoadingForTest() { + return !server.keyExists( "coldfusion" ) || !findNoCase( "ColdFusion", server.coldfusion.productName ); + } + + private boolean function specHasLabel( + required string specName, + required string label, + array suites = this.$suites + ) { + for ( var suite in arguments.suites ) { + for ( var spec in suite.specs ) { + if ( spec.name == arguments.specName ) { + return spec.labels.findNoCase( arguments.label ) > 0; + } + } + if ( + specHasLabel( + arguments.specName, + arguments.label, + suite.suites + ) + ) { + return true; + } + } + return false; + } + } diff --git a/tests/specs/integration/ModuleCanBeActivedSpec.cfc b/tests/specs/integration/ModuleCanBeActivedSpec.cfc index daaf3c86..711625a6 100644 --- a/tests/specs/integration/ModuleCanBeActivedSpec.cfc +++ b/tests/specs/integration/ModuleCanBeActivedSpec.cfc @@ -7,6 +7,13 @@ component extends="tests.resources.ModuleIntegrationSpec" { "The quick module has not been registered" ); } ); + + it( "reuses an application-supplied parallel eager-loading executor", function() { + var asyncManager = getController().getAsyncManager(); + expect( asyncManager.hasExecutor( "quick-test-parallel-eager-loading" ) ).toBeTrue(); + expect( asyncManager.hasExecutor( "quick-parallel-eager-loading" ) ).toBeFalse(); + expect( asyncManager.getExecutor( "quick-test-parallel-eager-loading" ).getMaximumPoolSize() ).toBe( 3 ); + } ); } ); }