From c4fb9975e591c66f1557a2a7d074403d89fe43bf Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 06:06:54 -0600 Subject: [PATCH 01/25] Add optional parallel eager loading Closes #55 --- models/QuickBuilder.cfc | 73 +++++++++++++++++-- .../Relationships/EagerLoadingSpec.cfc | 34 +++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 23983303..d6dd5d2f 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -94,6 +94,7 @@ component accessors="true" transientCache="false" { function init() { variables._eagerLoad = []; + variables._parallelEagerLoading = false; variables._globalScopesApplied = false; variables._globalScopeExcludeAll = false; variables._asMemento = false; @@ -713,9 +714,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 +728,7 @@ component accessors="true" transientCache="false" { arrayWrap( arguments.relationName ), true ); + variables._parallelEagerLoading = variables._parallelEagerLoading || arguments.parallel; return this; } @@ -806,17 +810,70 @@ 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 ) { + 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 separate threads. + */ + private void function eagerLoadRelationsInParallel( required struct eagerLoads, required array entities ) { + var threadNames = []; + + for ( var relationName in arguments.eagerLoads ) { + var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + threadNames.append( threadName ); + cfthread( + action = "run", + name = threadName, + builder = this, + relationName = relationName, + eagerLoadConfig = arguments.eagerLoads[ relationName ], + entities = entities + ) { + attributes.builder.eagerLoadRelation( + attributes.relationName, + attributes.eagerLoadConfig, + attributes.entities + ); + } + } + + cfthread( + action = "join", + name = threadNames.toList(), + timeout = 60000 + ); + + threadNames.each( function( threadName ) { + if ( cfthread[ threadName ].status == "TERMINATED" ) { + var threadError = cfthread[ threadName ].error; + throw( + type = threadError.keyExists( "type" ) ? threadError.type : "QuickParallelEagerLoadingException", + message = threadError.keyExists( "message" ) ? threadError.message : "A parallel eager-loading thread failed.", + detail = threadError.keyExists( "detail" ) ? threadError.detail : "" + ); + } + if ( cfthread[ threadName ].status != "COMPLETED" ) { + throw( + type = "QuickParallelEagerLoadingTimeout", + message = "Parallel eager loading did not complete within 60 seconds." + ); + } + } ); + } + 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") @@ -953,7 +1010,7 @@ component accessors="true" transientCache="false" { * @doc_generic quick.models.BaseEntity | struct * @return [quick.models.BaseEntity] | [struct] */ - private array function eagerLoadRelation( + public array function eagerLoadRelation( required string relationName, required struct eagerLoadConfig, required array entities diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index 4d45318a..5a899969 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -61,6 +61,40 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( keys ).toHaveLength( 2 ); } ); + it( "can eager load top-level relationships in parallel", function() { + var callingThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); + var eagerThreads = {}; + var posts = getInstance( "Post" ) + .with( + [ + { + "author" : function( relationship ) { + eagerThreads.author = createObject( "java", "java.lang.Thread" ) + .currentThread() + .getName(); + } + }, + { + "comments" : function( relationship ) { + eagerThreads.comments = createObject( "java", "java.lang.Thread" ) + .currentThread() + .getName(); + } + } + ], + true + ) + .get(); + + expect( posts[ 1 ].getAuthor() ).toBeInstanceOf( "app.models.User" ); + expect( posts[ 1 ].getComments() ).toBeArray(); + expect( eagerThreads ).toHaveKey( "author" ); + expect( eagerThreads ).toHaveKey( "comments" ); + expect( eagerThreads.author ).notToBe( callingThread ); + expect( eagerThreads.comments ).notToBe( callingThread ); + expect( eagerThreads.author ).notToBe( eagerThreads.comments ); + } ); + it( "can eager load a belongs to relationship using a composite key", function() { var compositeChildren = getInstance( "CompositeChild" ).with( "parent" ).get(); expect( compositeChildren ).toBeArray(); From 7c5e3d52183a91d35936596b515ad6c9e65f8ea8 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 08:20:18 -0600 Subject: [PATCH 02/25] fix: make parallel eager loading cross-engine safe --- models/QuickBuilder.cfc | 45 +++++++++++++++---- .../Relationships/EagerLoadingSpec.cfc | 11 +++-- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index d6dd5d2f..ed567391 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -810,7 +810,7 @@ component accessors="true" transientCache="false" { } var eagerLoads = denestEagerLoads( variables._eagerLoad ); - if ( variables._parallelEagerLoading && eagerLoads.count() > 1 ) { + if ( variables._parallelEagerLoading && eagerLoads.count() > 1 && supportsParallelEagerLoading() ) { eagerLoadRelationsInParallel( eagerLoads, arguments.entities ); } else { for ( var relationName in eagerLoads ) { @@ -829,20 +829,25 @@ component accessors="true" transientCache="false" { * Eager loads independent top-level relationships on separate threads. */ private void function eagerLoadRelationsInParallel( required struct eagerLoads, required array entities ) { - var threadNames = []; + var threadNames = []; + var threadRelations = {}; + var targetEntities = arguments.entities; for ( var relationName in arguments.eagerLoads ) { - var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + var threadEntities = arguments.entities.map( function( entity ) { + return structKeyExists( entity, "isQuickEntity" ) ? entity.clone( true ) : duplicate( entity ); + } ); threadNames.append( threadName ); + threadRelations[ threadName ] = relationName; cfthread( action = "run", name = threadName, - builder = this, relationName = relationName, eagerLoadConfig = arguments.eagerLoads[ relationName ], - entities = entities + entities = threadEntities ) { - attributes.builder.eagerLoadRelation( + thread.entities = eagerLoadRelation( attributes.relationName, attributes.eagerLoadConfig, attributes.entities @@ -860,9 +865,9 @@ component accessors="true" transientCache="false" { if ( cfthread[ threadName ].status == "TERMINATED" ) { var threadError = cfthread[ threadName ].error; throw( - type = threadError.keyExists( "type" ) ? threadError.type : "QuickParallelEagerLoadingException", - message = threadError.keyExists( "message" ) ? threadError.message : "A parallel eager-loading thread failed.", - detail = threadError.keyExists( "detail" ) ? threadError.detail : "" + type = "QuickParallelEagerLoadingException", + message = threadError.keyExists( "message" ) ? threadError.message : "A parallel eager-loading thread failed.", + extendedInfo = serializeJSON( threadError ) ); } if ( cfthread[ threadName ].status != "COMPLETED" ) { @@ -871,9 +876,31 @@ component accessors="true" transientCache="false" { message = "Parallel eager loading did not complete within 60 seconds." ); } + + var relationName = threadRelations[ threadName ]; + var eagerLoadedEntities = cfthread[ threadName ].entities; + for ( var i = 1; i <= targetEntities.len(); i++ ) { + if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { + var relationshipValue = eagerLoadedEntities[ i ].retrieveRelationship( relationName ); + if ( isNull( relationshipValue ) ) { + targetEntities[ i ].assignRelationship( relationName ); + } else { + targetEntities[ i ].assignRelationship( relationName, relationshipValue ); + } + } else if ( eagerLoadedEntities[ i ].keyExists( relationName ) ) { + targetEntities[ i ][ relationName ] = eagerLoadedEntities[ i ][ relationName ]; + } + } } ); } + /** + * Adobe ColdFusion loses CFC private-method resolution inside cfthread. + */ + private boolean function supportsParallelEagerLoading() { + return !findNoCase( "ColdFusion", server.coldfusion.productName ); + } + 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") diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index 5a899969..454990ad 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -90,9 +90,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts[ 1 ].getComments() ).toBeArray(); expect( eagerThreads ).toHaveKey( "author" ); expect( eagerThreads ).toHaveKey( "comments" ); - expect( eagerThreads.author ).notToBe( callingThread ); - expect( eagerThreads.comments ).notToBe( callingThread ); - expect( eagerThreads.author ).notToBe( eagerThreads.comments ); + if ( findNoCase( "ColdFusion", server.coldfusion.productName ) ) { + expect( eagerThreads.author ).toBe( callingThread ); + expect( eagerThreads.comments ).toBe( callingThread ); + } else { + expect( eagerThreads.author ).notToBe( callingThread ); + expect( eagerThreads.comments ).notToBe( callingThread ); + expect( eagerThreads.author ).notToBe( eagerThreads.comments ); + } } ); it( "can eager load a belongs to relationship using a composite key", function() { From eaf332f3abb65d28fce0b7200ba1794b801a952c Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 08:30:25 -0600 Subject: [PATCH 03/25] fix: preserve engine thread transfer semantics --- models/QuickBuilder.cfc | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index ed567391..f8b2073f 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -834,10 +834,7 @@ component accessors="true" transientCache="false" { var targetEntities = arguments.entities; for ( var relationName in arguments.eagerLoads ) { - var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; - var threadEntities = arguments.entities.map( function( entity ) { - return structKeyExists( entity, "isQuickEntity" ) ? entity.clone( true ) : duplicate( entity ); - } ); + var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; threadNames.append( threadName ); threadRelations[ threadName ] = relationName; cfthread( @@ -845,7 +842,7 @@ component accessors="true" transientCache="false" { name = threadName, relationName = relationName, eagerLoadConfig = arguments.eagerLoads[ relationName ], - entities = threadEntities + entities = targetEntities ) { thread.entities = eagerLoadRelation( attributes.relationName, From dfbcf0883bea49d8070df732ca3aa1f82a352651 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 13:45:19 -0600 Subject: [PATCH 04/25] refactor: avoid internal closures --- models/QuickBuilder.cfc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index f8b2073f..369e663e 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -858,7 +858,7 @@ component accessors="true" transientCache="false" { timeout = 60000 ); - threadNames.each( function( threadName ) { + for ( var threadName in threadNames ) { if ( cfthread[ threadName ].status == "TERMINATED" ) { var threadError = cfthread[ threadName ].error; throw( @@ -888,7 +888,7 @@ component accessors="true" transientCache="false" { targetEntities[ i ][ relationName ] = eagerLoadedEntities[ i ][ relationName ]; } } - } ); + } } /** From 6e0331a764bd8ec72a9a7b8889bcb40d204c84b1 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 13:56:47 -0600 Subject: [PATCH 05/25] fix: isolate parallel merge loop variables --- models/QuickBuilder.cfc | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 369e663e..7ed327ac 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -858,34 +858,35 @@ component accessors="true" transientCache="false" { timeout = 60000 ); - for ( var threadName in threadNames ) { - if ( cfthread[ threadName ].status == "TERMINATED" ) { - var threadError = cfthread[ threadName ].error; + for ( var threadIndex = 1; threadIndex <= threadNames.len(); threadIndex++ ) { + var completedThreadName = threadNames[ threadIndex ]; + if ( cfthread[ completedThreadName ].status == "TERMINATED" ) { + var threadError = cfthread[ completedThreadName ].error; throw( type = "QuickParallelEagerLoadingException", message = threadError.keyExists( "message" ) ? threadError.message : "A parallel eager-loading thread failed.", extendedInfo = serializeJSON( threadError ) ); } - if ( cfthread[ threadName ].status != "COMPLETED" ) { + if ( cfthread[ completedThreadName ].status != "COMPLETED" ) { throw( type = "QuickParallelEagerLoadingTimeout", message = "Parallel eager loading did not complete within 60 seconds." ); } - var relationName = threadRelations[ threadName ]; - var eagerLoadedEntities = cfthread[ threadName ].entities; + var completedRelationName = threadRelations[ completedThreadName ]; + var eagerLoadedEntities = cfthread[ completedThreadName ].entities; for ( var i = 1; i <= targetEntities.len(); i++ ) { if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { - var relationshipValue = eagerLoadedEntities[ i ].retrieveRelationship( relationName ); + var relationshipValue = eagerLoadedEntities[ i ].retrieveRelationship( completedRelationName ); if ( isNull( relationshipValue ) ) { - targetEntities[ i ].assignRelationship( relationName ); + targetEntities[ i ].assignRelationship( completedRelationName ); } else { - targetEntities[ i ].assignRelationship( relationName, relationshipValue ); + targetEntities[ i ].assignRelationship( completedRelationName, relationshipValue ); } - } else if ( eagerLoadedEntities[ i ].keyExists( relationName ) ) { - targetEntities[ i ][ relationName ] = eagerLoadedEntities[ i ][ relationName ]; + } else if ( eagerLoadedEntities[ i ].keyExists( completedRelationName ) ) { + targetEntities[ i ][ completedRelationName ] = eagerLoadedEntities[ i ][ completedRelationName ]; } } } From d486fc75ab150558f62e90bbc26519e6ced396af Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 14:39:07 -0600 Subject: [PATCH 06/25] fix: preserve Lucee thread merge scope --- models/QuickBuilder.cfc | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 7ed327ac..f8b2073f 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -858,38 +858,37 @@ component accessors="true" transientCache="false" { timeout = 60000 ); - for ( var threadIndex = 1; threadIndex <= threadNames.len(); threadIndex++ ) { - var completedThreadName = threadNames[ threadIndex ]; - if ( cfthread[ completedThreadName ].status == "TERMINATED" ) { - var threadError = cfthread[ completedThreadName ].error; + threadNames.each( function( threadName ) { + if ( cfthread[ threadName ].status == "TERMINATED" ) { + var threadError = cfthread[ threadName ].error; throw( type = "QuickParallelEagerLoadingException", message = threadError.keyExists( "message" ) ? threadError.message : "A parallel eager-loading thread failed.", extendedInfo = serializeJSON( threadError ) ); } - if ( cfthread[ completedThreadName ].status != "COMPLETED" ) { + if ( cfthread[ threadName ].status != "COMPLETED" ) { throw( type = "QuickParallelEagerLoadingTimeout", message = "Parallel eager loading did not complete within 60 seconds." ); } - var completedRelationName = threadRelations[ completedThreadName ]; - var eagerLoadedEntities = cfthread[ completedThreadName ].entities; + var relationName = threadRelations[ threadName ]; + var eagerLoadedEntities = cfthread[ threadName ].entities; for ( var i = 1; i <= targetEntities.len(); i++ ) { if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { - var relationshipValue = eagerLoadedEntities[ i ].retrieveRelationship( completedRelationName ); + var relationshipValue = eagerLoadedEntities[ i ].retrieveRelationship( relationName ); if ( isNull( relationshipValue ) ) { - targetEntities[ i ].assignRelationship( completedRelationName ); + targetEntities[ i ].assignRelationship( relationName ); } else { - targetEntities[ i ].assignRelationship( completedRelationName, relationshipValue ); + targetEntities[ i ].assignRelationship( relationName, relationshipValue ); } - } else if ( eagerLoadedEntities[ i ].keyExists( completedRelationName ) ) { - targetEntities[ i ][ completedRelationName ] = eagerLoadedEntities[ i ][ completedRelationName ]; + } else if ( eagerLoadedEntities[ i ].keyExists( relationName ) ) { + targetEntities[ i ][ relationName ] = eagerLoadedEntities[ i ][ relationName ]; } } - } + } ); } /** From f8250024f80c9d9e61a69c3d0270619a939495dd Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 14:50:06 -0600 Subject: [PATCH 07/25] fix: preserve parallel eager-load results across threads --- models/QuickBuilder.cfc | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index f8b2073f..de6c5492 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -832,6 +832,7 @@ component accessors="true" transientCache="false" { var threadNames = []; var threadRelations = {}; var targetEntities = arguments.entities; + var threadResults = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); for ( var relationName in arguments.eagerLoads ) { var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; @@ -840,14 +841,19 @@ component accessors="true" transientCache="false" { cfthread( action = "run", name = threadName, + threadName = threadName, relationName = relationName, eagerLoadConfig = arguments.eagerLoads[ relationName ], - entities = targetEntities + entities = targetEntities, + results = threadResults ) { - thread.entities = eagerLoadRelation( - attributes.relationName, - attributes.eagerLoadConfig, - attributes.entities + attributes.results.put( + attributes.threadName, + eagerLoadRelation( + attributes.relationName, + attributes.eagerLoadConfig, + attributes.entities + ) ); } } @@ -875,7 +881,7 @@ component accessors="true" transientCache="false" { } var relationName = threadRelations[ threadName ]; - var eagerLoadedEntities = cfthread[ threadName ].entities; + var eagerLoadedEntities = threadResults.get( threadName ); for ( var i = 1; i <= targetEntities.len(); i++ ) { if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { var relationshipValue = eagerLoadedEntities[ i ].retrieveRelationship( relationName ); From 2c9d8533d33f7ec7e8a0ce4b70dfbd51f7e342c6 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 14:56:04 -0600 Subject: [PATCH 08/25] fix: transfer eager relationship values directly --- models/QuickBuilder.cfc | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index de6c5492..461abf9b 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -847,14 +847,27 @@ component accessors="true" transientCache="false" { entities = targetEntities, results = threadResults ) { - attributes.results.put( - attributes.threadName, - eagerLoadRelation( - attributes.relationName, - attributes.eagerLoadConfig, - attributes.entities - ) + var loadedEntities = eagerLoadRelation( + attributes.relationName, + attributes.eagerLoadConfig, + attributes.entities ); + var relationshipValues = createObject( "java", "java.util.ArrayList" ).init(); + for ( var entity in loadedEntities ) { + if ( structKeyExists( entity, "isQuickEntity" ) ) { + var relationshipValue = entity.retrieveRelationship( attributes.relationName ); + relationshipValues.add( + isNull( relationshipValue ) ? javacast( "null", "" ) : relationshipValue + ); + } else { + relationshipValues.add( + entity.keyExists( attributes.relationName ) + ? entity[ attributes.relationName ] + : javacast( "null", "" ) + ); + } + } + attributes.results.put( attributes.threadName, relationshipValues ); } } @@ -880,18 +893,18 @@ component accessors="true" transientCache="false" { ); } - var relationName = threadRelations[ threadName ]; - var eagerLoadedEntities = threadResults.get( threadName ); + var relationName = threadRelations[ threadName ]; + var relationshipValues = threadResults.get( threadName ); for ( var i = 1; i <= targetEntities.len(); i++ ) { if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { - var relationshipValue = eagerLoadedEntities[ i ].retrieveRelationship( relationName ); + var relationshipValue = relationshipValues.get( i - 1 ); if ( isNull( relationshipValue ) ) { targetEntities[ i ].assignRelationship( relationName ); } else { targetEntities[ i ].assignRelationship( relationName, relationshipValue ); } - } else if ( eagerLoadedEntities[ i ].keyExists( relationName ) ) { - targetEntities[ i ][ relationName ] = eagerLoadedEntities[ i ][ relationName ]; + } else if ( !isNull( relationshipValues.get( i - 1 ) ) ) { + targetEntities[ i ][ relationName ] = relationshipValues.get( i - 1 ); } } } ); From 1aa04adf6a4aefd85e026fe437ecdbf02ac74278 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 15:11:40 -0600 Subject: [PATCH 09/25] fix: detect parallel support on BoxLang --- models/QuickBuilder.cfc | 2 +- .../integration/BaseEntity/Relationships/EagerLoadingSpec.cfc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 461abf9b..e65f2ec0 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -914,7 +914,7 @@ component accessors="true" transientCache="false" { * Adobe ColdFusion loses CFC private-method resolution inside cfthread. */ private boolean function supportsParallelEagerLoading() { - return !findNoCase( "ColdFusion", server.coldfusion.productName ); + return !server.keyExists( "coldfusion" ) || !findNoCase( "ColdFusion", server.coldfusion.productName ); } private struct function denestEagerLoads( required array eagerLoads ) { diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index 454990ad..8aa7033c 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -90,7 +90,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts[ 1 ].getComments() ).toBeArray(); expect( eagerThreads ).toHaveKey( "author" ); expect( eagerThreads ).toHaveKey( "comments" ); - if ( findNoCase( "ColdFusion", server.coldfusion.productName ) ) { + if ( server.keyExists( "coldfusion" ) && findNoCase( "ColdFusion", server.coldfusion.productName ) ) { expect( eagerThreads.author ).toBe( callingThread ); expect( eagerThreads.comments ).toBe( callingThread ); } else { From f36712a6b471ae010553eccee3d95d189f5e4fac Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 15:19:37 -0600 Subject: [PATCH 10/25] fix: retain eager-loaded CFCs in their worker state --- models/QuickBuilder.cfc | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index e65f2ec0..dd4112a4 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -855,10 +855,9 @@ component accessors="true" transientCache="false" { var relationshipValues = createObject( "java", "java.util.ArrayList" ).init(); for ( var entity in loadedEntities ) { if ( structKeyExists( entity, "isQuickEntity" ) ) { - var relationshipValue = entity.retrieveRelationship( attributes.relationName ); - relationshipValues.add( - isNull( relationshipValue ) ? javacast( "null", "" ) : relationshipValue - ); + // Quick entities are shared with and updated by the worker. Avoid + // transferring related CFC instances through Java collections. + relationshipValues.add( true ); } else { relationshipValues.add( entity.keyExists( attributes.relationName ) @@ -896,14 +895,10 @@ component accessors="true" transientCache="false" { var relationName = threadRelations[ threadName ]; var relationshipValues = threadResults.get( threadName ); for ( var i = 1; i <= targetEntities.len(); i++ ) { - if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { - var relationshipValue = relationshipValues.get( i - 1 ); - if ( isNull( relationshipValue ) ) { - targetEntities[ i ].assignRelationship( relationName ); - } else { - targetEntities[ i ].assignRelationship( relationName, relationshipValue ); - } - } else if ( !isNull( relationshipValues.get( i - 1 ) ) ) { + if ( + !structKeyExists( targetEntities[ i ], "isQuickEntity" ) && + !isNull( relationshipValues.get( i - 1 ) ) + ) { targetEntities[ i ][ relationName ] = relationshipValues.get( i - 1 ); } } From 62f29eaef51f439c64f12fa1cfd42a18516afa4f Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 15:27:35 -0600 Subject: [PATCH 11/25] fix: transfer parallel eager loads as entity state --- models/BaseEntity.cfc | 9 +++ models/QuickBuilder.cfc | 122 +++++++++++++++++++++++++++++++++++----- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 67460aa8..cd1ec2c1 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -2077,6 +2077,15 @@ component accessors="true" { return structKeyExists( variables._relationshipsLoaded, arguments.name ); } + /** + * Returns the names of the currently loaded relationships. + * + * @return The loaded relationship names. + */ + public array function retrieveLoadedRelationshipNames() { + return variables._relationshipsLoaded.keyArray(); + } + /** * Retrieves the result of a loaded relationship. For a new entity, an unloaded * relationship is initialized through its public getter without executing a diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index dd4112a4..e2da41cb 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -835,7 +835,13 @@ component accessors="true" transientCache="false" { var threadResults = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); for ( var relationName in arguments.eagerLoads ) { - var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + var threadEntities = []; + for ( var entity in arguments.entities ) { + threadEntities.append( + structKeyExists( entity, "isQuickEntity" ) ? entity.clone( true ) : duplicate( entity ) + ); + } threadNames.append( threadName ); threadRelations[ threadName ] = relationName; cfthread( @@ -844,7 +850,7 @@ component accessors="true" transientCache="false" { threadName = threadName, relationName = relationName, eagerLoadConfig = arguments.eagerLoads[ relationName ], - entities = targetEntities, + entities = threadEntities, results = threadResults ) { var loadedEntities = eagerLoadRelation( @@ -852,17 +858,18 @@ component accessors="true" transientCache="false" { attributes.eagerLoadConfig, attributes.entities ); - var relationshipValues = createObject( "java", "java.util.ArrayList" ).init(); + var relationshipValues = []; for ( var entity in loadedEntities ) { if ( structKeyExists( entity, "isQuickEntity" ) ) { - // Quick entities are shared with and updated by the worker. Avoid - // transferring related CFC instances through Java collections. - relationshipValues.add( true ); + var relationshipValue = entity.retrieveRelationship( attributes.relationName ); + relationshipValues.append( serializeParallelValue( relationshipValue ) ); } else { - relationshipValues.add( - entity.keyExists( attributes.relationName ) - ? entity[ attributes.relationName ] - : javacast( "null", "" ) + relationshipValues.append( + serializeParallelValue( + entity.keyExists( attributes.relationName ) + ? entity[ attributes.relationName ] + : javacast( "null", "" ) + ) ); } } @@ -895,16 +902,101 @@ component accessors="true" transientCache="false" { var relationName = threadRelations[ threadName ]; var relationshipValues = threadResults.get( threadName ); for ( var i = 1; i <= targetEntities.len(); i++ ) { - if ( - !structKeyExists( targetEntities[ i ], "isQuickEntity" ) && - !isNull( relationshipValues.get( i - 1 ) ) - ) { - targetEntities[ i ][ relationName ] = relationshipValues.get( i - 1 ); + var relationshipValue = deserializeParallelValue( relationshipValues[ i ] ); + if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { + if ( isNull( relationshipValue ) ) { + targetEntities[ i ].assignRelationship( relationName ); + } else { + targetEntities[ i ].assignRelationship( relationName, relationshipValue ); + } + } else if ( !isNull( relationshipValue ) ) { + targetEntities[ i ][ relationName ] = relationshipValue; } } } ); } + /** + * Converts eager-loaded values to CFC-free state for crossing thread boundaries. + */ + private struct function serializeParallelValue( any value ) { + if ( isNull( arguments.value ) ) { + return { "type" : "null" }; + } + if ( isArray( arguments.value ) ) { + var items = []; + for ( var item in arguments.value ) { + items.append( serializeParallelValue( item ) ); + } + return { "type" : "array", "value" : items }; + } + if ( isStruct( arguments.value ) && structKeyExists( arguments.value, "isQuickEntity" ) ) { + var relationships = {}; + for ( var relationshipName in arguments.value.retrieveLoadedRelationshipNames() ) { + relationships[ relationshipName ] = serializeParallelValue( + arguments.value.retrieveRelationship( relationshipName ) + ); + } + return { + "type" : "entity", + "mappingName" : arguments.value.mappingName(), + "attributes" : arguments.value.retrieveAttributesData( withNulls = true ), + "relationships" : relationships + }; + } + if ( isStruct( arguments.value ) ) { + var values = {}; + for ( var key in arguments.value ) { + values[ key ] = serializeParallelValue( arguments.value[ key ] ); + } + return { "type" : "struct", "value" : values }; + } + return { + "type" : "value", + "value" : arguments.value + }; + } + + /** + * Reconstructs eager-loaded values exported by a worker thread. + */ + private any function deserializeParallelValue( required struct state ) { + switch ( arguments.state.type ) { + case "null": + return javacast( "null", "" ); + case "array": + var items = []; + for ( var item in arguments.state.value ) { + items.append( deserializeParallelValue( item ) ); + } + return items; + case "entity": + var entity = getEntity().newEntity( arguments.state.mappingName ).hydrate( arguments.state.attributes ); + for ( var relationshipName in arguments.state.relationships ) { + var relationshipValue = deserializeParallelValue( + arguments.state.relationships[ relationshipName ] + ); + if ( isNull( relationshipValue ) ) { + entity.assignRelationship( relationshipName ); + } else { + entity.assignRelationship( relationshipName, relationshipValue ); + } + } + return entity; + case "struct": + var values = {}; + for ( var key in arguments.state.value ) { + var value = deserializeParallelValue( arguments.state.value[ key ] ); + if ( !isNull( value ) ) { + values[ key ] = value; + } + } + return values; + default: + return arguments.state.value; + } + } + /** * Adobe ColdFusion loses CFC private-method resolution inside cfthread. */ From fc1ce257811d41e46c6d4f12c7d49d93d90cfe6f Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 15:34:51 -0600 Subject: [PATCH 12/25] fix: tag null thread values before assignment --- models/QuickBuilder.cfc | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index e2da41cb..240db79a 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -861,8 +861,13 @@ component accessors="true" transientCache="false" { var relationshipValues = []; for ( var entity in loadedEntities ) { if ( structKeyExists( entity, "isQuickEntity" ) ) { - var relationshipValue = entity.retrieveRelationship( attributes.relationName ); - relationshipValues.append( serializeParallelValue( relationshipValue ) ); + if ( isNull( entity.retrieveRelationship( attributes.relationName ) ) ) { + relationshipValues.append( { "type" : "null" } ); + } else { + relationshipValues.append( + serializeParallelValue( entity.retrieveRelationship( attributes.relationName ) ) + ); + } } else { relationshipValues.append( serializeParallelValue( From d4ca77ca172f3b9e5be21d30c759fbbc914cb36b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 15:45:56 -0600 Subject: [PATCH 13/25] fix: hydrate loaded entities for parallel workers --- models/QuickBuilder.cfc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 240db79a..aa58152d 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -839,7 +839,9 @@ component accessors="true" transientCache="false" { var threadEntities = []; for ( var entity in arguments.entities ) { threadEntities.append( - structKeyExists( entity, "isQuickEntity" ) ? entity.clone( true ) : duplicate( entity ) + structKeyExists( entity, "isQuickEntity" ) + ? entity.newEntity().hydrate( entity.retrieveAttributesData( withNulls = true ) ) + : duplicate( entity ) ); } threadNames.append( threadName ); From 017045f2b89cac13e946e6e9cad06f1137e3e478 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 15:51:23 -0600 Subject: [PATCH 14/25] fix: hydrate parallel entities inside workers --- models/QuickBuilder.cfc | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index aa58152d..606e9ce8 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -835,13 +835,20 @@ component accessors="true" transientCache="false" { var threadResults = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); for ( var relationName in arguments.eagerLoads ) { - var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; - var threadEntities = []; + var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + var entityStates = []; for ( var entity in arguments.entities ) { - threadEntities.append( + entityStates.append( structKeyExists( entity, "isQuickEntity" ) - ? entity.newEntity().hydrate( entity.retrieveAttributesData( withNulls = true ) ) - : duplicate( entity ) + ? { + "isQuickEntity" : true, + "mappingName" : entity.mappingName(), + "attributes" : entity.retrieveAttributesData( withNulls = true ) + } + : { + "isQuickEntity" : false, + "value" : duplicate( entity ) + } ); } threadNames.append( threadName ); @@ -852,13 +859,21 @@ component accessors="true" transientCache="false" { threadName = threadName, relationName = relationName, eagerLoadConfig = arguments.eagerLoads[ relationName ], - entities = threadEntities, + entityStates = entityStates, results = threadResults ) { + var workerEntities = []; + for ( var entityState in attributes.entityStates ) { + workerEntities.append( + entityState.isQuickEntity + ? getEntity().newEntity( entityState.mappingName ).hydrate( entityState.attributes ) + : entityState.value + ); + } var loadedEntities = eagerLoadRelation( attributes.relationName, attributes.eagerLoadConfig, - attributes.entities + workerEntities ); var relationshipValues = []; for ( var entity in loadedEntities ) { From 26c70412e6fc7a264d1deda8e0e169043360b339 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 18:05:59 -0600 Subject: [PATCH 15/25] fix: preserve parallel eager loading semantics --- ModuleConfig.cfc | 14 +- models/BaseEntity.cfc | 9 - models/ParallelEagerLoadingContext.cfc | 24 ++ models/QuickBuilder.cfc | 290 ++++++++++++------ .../app/models/ParallelLifecycleUser.cfc | 26 ++ .../Relationships/EagerLoadingSpec.cfc | 137 +++++++++ 6 files changed, 384 insertions(+), 116 deletions(-) create mode 100644 models/ParallelEagerLoadingContext.cfc create mode 100644 tests/resources/app/models/ParallelLifecycleUser.cfc diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 002b4d78..8e8b06a1 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -8,12 +8,14 @@ component { function configure() { settings = { - "defaultGrammar" : "AutoDiscover@qb", - "defaultQueryOptions" : {}, - "preventDuplicateJoins" : true, - "preventLazyLoading" : false, - "refreshOnSaveFallback" : true, - "lazyLoadingViolationCallback" : ( entity, relationName ) => { + "defaultGrammar" : "AutoDiscover@qb", + "defaultQueryOptions" : {}, + "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." diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index cd1ec2c1..67460aa8 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -2077,15 +2077,6 @@ component accessors="true" { return structKeyExists( variables._relationshipsLoaded, arguments.name ); } - /** - * Returns the names of the currently loaded relationships. - * - * @return The loaded relationship names. - */ - public array function retrieveLoadedRelationshipNames() { - return variables._relationshipsLoaded.keyArray(); - } - /** * Retrieves the result of a loaded relationship. For a new entity, an unloaded * relationship is initialized through its public getter without executing a diff --git a/models/ParallelEagerLoadingContext.cfc b/models/ParallelEagerLoadingContext.cfc new file mode 100644 index 00000000..c993c6e9 --- /dev/null +++ b/models/ParallelEagerLoadingContext.cfc @@ -0,0 +1,24 @@ +/** + * Tracks parallel eager-loading workers without leaking state between threads. + */ +component singleton { + + function init() { + variables.lifecycleEventsSuppressed = createObject( "java", "java.lang.ThreadLocal" ).init(); + return this; + } + + public void function suppressLifecycleEvents() { + variables.lifecycleEventsSuppressed.set( true ); + } + + public void function restoreLifecycleEvents() { + variables.lifecycleEventsSuppressed.remove(); + } + + public boolean function areLifecycleEventsSuppressed() { + var value = variables.lifecycleEventsSuppressed.get(); + return !isNull( value ) && value; + } + +} diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 606e9ce8..c1a919b0 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -62,6 +62,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"; + + /** + * Thread-local lifecycle state shared by every QuickBuilder instance. + */ + property name="_parallelEagerLoadingContext" inject="quick.models.ParallelEagerLoadingContext"; + /** * A map of aliases to entities to use when qualifying aliased columns. */ @@ -93,15 +114,17 @@ component accessors="true" transientCache="false" { this.isQuickBuilder = true; function init() { - variables._eagerLoad = []; - variables._parallelEagerLoading = false; - 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( @@ -829,113 +852,157 @@ component accessors="true" transientCache="false" { * Eager loads independent top-level relationships on separate threads. */ private void function eagerLoadRelationsInParallel( required struct eagerLoads, required array entities ) { - var threadNames = []; - var threadRelations = {}; - var targetEntities = arguments.entities; - var threadResults = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); - - for ( var relationName in arguments.eagerLoads ) { - var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; - var entityStates = []; - for ( var entity in arguments.entities ) { - entityStates.append( - structKeyExists( entity, "isQuickEntity" ) - ? { - "isQuickEntity" : true, - "mappingName" : entity.mappingName(), - "attributes" : entity.retrieveAttributesData( withNulls = true ) - } - : { - "isQuickEntity" : false, - "value" : duplicate( entity ) - } - ); - } - threadNames.append( threadName ); - threadRelations[ threadName ] = relationName; - cfthread( - action = "run", - name = threadName, - threadName = threadName, - relationName = relationName, - eagerLoadConfig = arguments.eagerLoads[ relationName ], - entityStates = entityStates, - results = threadResults - ) { - var workerEntities = []; - for ( var entityState in attributes.entityStates ) { - workerEntities.append( - entityState.isQuickEntity - ? getEntity().newEntity( entityState.mappingName ).hydrate( entityState.attributes ) - : entityState.value - ); + var relationNames = arguments.eagerLoads.keyArray(); + var maxWorkers = max( 1, int( variables._parallelEagerLoadingMaxThreads ) ); + var timeout = max( 1, int( variables._parallelEagerLoadingTimeout ) ); + var targetEntities = arguments.entities; + var threadResults = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); + var entityStates = []; + for ( var entity in arguments.entities ) { + entityStates.append( + structKeyExists( entity, "isQuickEntity" ) + ? { + "isQuickEntity" : true, + "mappingName" : entity.mappingName(), + "attributes" : entity.retrieveAttributesData( withNulls = true ) } - var loadedEntities = eagerLoadRelation( - attributes.relationName, - attributes.eagerLoadConfig, - workerEntities - ); - var relationshipValues = []; - for ( var entity in loadedEntities ) { - if ( structKeyExists( entity, "isQuickEntity" ) ) { - if ( isNull( entity.retrieveRelationship( attributes.relationName ) ) ) { - relationshipValues.append( { "type" : "null" } ); - } else { - relationshipValues.append( - serializeParallelValue( entity.retrieveRelationship( attributes.relationName ) ) + : { + "isQuickEntity" : false, + "value" : duplicate( entity ) + } + ); + } + + for ( var batchStart = 1; batchStart <= relationNames.len(); batchStart += maxWorkers ) { + var batchThreadNames = []; + var batchThreadRelations = {}; + var batchEnd = min( relationNames.len(), batchStart + maxWorkers - 1 ); + for ( var relationIndex = batchStart; relationIndex <= batchEnd; relationIndex++ ) { + var relationName = relationNames[ relationIndex ]; + var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + batchThreadNames.append( threadName ); + batchThreadRelations[ threadName ] = relationName; + cfthread( + action = "run", + name = threadName, + threadName = threadName, + relationName = relationName, + eagerLoadConfig = arguments.eagerLoads[ relationName ], + entityStates = entityStates, + results = threadResults + ) { + variables._parallelEagerLoadingContext.suppressLifecycleEvents(); + try { + var workerEntities = []; + for ( var entityState in attributes.entityStates ) { + workerEntities.append( + entityState.isQuickEntity + ? hydrateParallelEntityState( entityState ) + : entityState.value ); } - } else { - relationshipValues.append( - serializeParallelValue( - entity.keyExists( attributes.relationName ) - ? entity[ attributes.relationName ] - : javacast( "null", "" ) - ) + var loadedEntities = eagerLoadRelation( + attributes.relationName, + attributes.eagerLoadConfig, + workerEntities ); + var relationshipValues = []; + for ( var loadedEntity in loadedEntities ) { + if ( structKeyExists( loadedEntity, "isQuickEntity" ) ) { + if ( isNull( loadedEntity.retrieveRelationship( attributes.relationName ) ) ) { + relationshipValues.append( { "type" : "null" } ); + } else { + relationshipValues.append( + serializeParallelValue( + loadedEntity.retrieveRelationship( attributes.relationName ) + ) + ); + } + } else if ( loadedEntity.keyExists( attributes.relationName ) ) { + relationshipValues.append( + serializeParallelValue( loadedEntity[ attributes.relationName ] ) + ); + } else { + relationshipValues.append( { "type" : "null" } ); + } + } + attributes.results.put( attributes.threadName, relationshipValues ); + } finally { + variables._parallelEagerLoadingContext.restoreLifecycleEvents(); } } - attributes.results.put( attributes.threadName, relationshipValues ); } - } - cfthread( - action = "join", - name = threadNames.toList(), - timeout = 60000 - ); + cfthread( + action = "join", + name = batchThreadNames.toList(), + timeout = timeout + ); - threadNames.each( function( threadName ) { - if ( cfthread[ threadName ].status == "TERMINATED" ) { - var threadError = cfthread[ threadName ].error; + var failedThread = ""; + var timedOut = false; + for ( var batchThreadName in batchThreadNames ) { + if ( cfthread[ batchThreadName ].status == "TERMINATED" && failedThread == "" ) { + failedThread = batchThreadName; + } + if ( + cfthread[ batchThreadName ].status != "COMPLETED" && cfthread[ batchThreadName ].status != "TERMINATED" + ) { + timedOut = true; + } + } + if ( failedThread != "" || timedOut ) { + terminateParallelEagerLoadingThreads( batchThreadNames ); + } + if ( failedThread != "" ) { + var threadError = cfthread[ failedThread ].error; throw( type = "QuickParallelEagerLoadingException", message = threadError.keyExists( "message" ) ? threadError.message : "A parallel eager-loading thread failed.", extendedInfo = serializeJSON( threadError ) ); } - if ( cfthread[ threadName ].status != "COMPLETED" ) { + if ( timedOut ) { throw( type = "QuickParallelEagerLoadingTimeout", - message = "Parallel eager loading did not complete within 60 seconds." + message = "Parallel eager loading did not complete within #timeout# milliseconds." ); } - var relationName = threadRelations[ threadName ]; - var relationshipValues = threadResults.get( threadName ); - for ( var i = 1; i <= targetEntities.len(); i++ ) { - var relationshipValue = deserializeParallelValue( relationshipValues[ i ] ); - if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { - if ( isNull( relationshipValue ) ) { - targetEntities[ i ].assignRelationship( relationName ); - } else { - targetEntities[ i ].assignRelationship( relationName, relationshipValue ); + for ( var completedThreadName in batchThreadNames ) { + var completedRelationName = batchThreadRelations[ completedThreadName ]; + var relationshipValues = threadResults.get( completedThreadName ); + for ( var i = 1; i <= targetEntities.len(); i++ ) { + var completedRelationshipValue = deserializeParallelValue( relationshipValues[ i ] ); + if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { + if ( isNull( completedRelationshipValue ) ) { + targetEntities[ i ].assignRelationship( completedRelationName ); + } else { + targetEntities[ i ].assignRelationship( completedRelationName, completedRelationshipValue ); + } + targetEntities[ i ].fireRelationshipLoaded( completedRelationName ); + } else if ( !isNull( completedRelationshipValue ) ) { + targetEntities[ i ][ completedRelationName ] = completedRelationshipValue; } - } else if ( !isNull( relationshipValue ) ) { - targetEntities[ i ][ relationName ] = relationshipValue; } } - } ); + } + } + + private any function hydrateParallelEntityState( required struct state ) { + return getEntity() + .newEntity( arguments.state.mappingName ) + .assignAttributesData( arguments.state.attributes ) + .assignOriginalAttributes( arguments.state.attributes ) + .set_loaded( true ); + } + + private void function terminateParallelEagerLoadingThreads( required array threadNames ) { + for ( var threadName in arguments.threadNames ) { + if ( cfthread[ threadName ].status != "COMPLETED" && cfthread[ threadName ].status != "TERMINATED" ) { + cfthread( action = "terminate", name = threadName ); + } + } } /** @@ -954,7 +1021,7 @@ component accessors="true" transientCache="false" { } if ( isStruct( arguments.value ) && structKeyExists( arguments.value, "isQuickEntity" ) ) { var relationships = {}; - for ( var relationshipName in arguments.value.retrieveLoadedRelationshipNames() ) { + for ( var relationshipName in arguments.value.get_relationshipsLoaded().keyArray() ) { relationships[ relationshipName ] = serializeParallelValue( arguments.value.retrieveRelationship( relationshipName ) ); @@ -993,7 +1060,15 @@ component accessors="true" transientCache="false" { } return items; case "entity": - var entity = getEntity().newEntity( arguments.state.mappingName ).hydrate( arguments.state.attributes ); + var entity = getEntity().newEntity( arguments.state.mappingName ); + try { + entity.hydrate( arguments.state.attributes ); + } catch ( MissingHydrationKey missingKey ) { + entity + .assignAttributesData( arguments.state.attributes ) + .assignOriginalAttributes( arguments.state.attributes ) + .set_loaded( true ); + } for ( var relationshipName in arguments.state.relationships ) { var relationshipValue = deserializeParallelValue( arguments.state.relationships[ relationshipName ] @@ -1003,6 +1078,7 @@ component accessors="true" transientCache="false" { } else { entity.assignRelationship( relationshipName, relationshipValue ); } + entity.fireRelationshipLoaded( relationshipName ); } return entity; case "struct": @@ -1162,7 +1238,7 @@ component accessors="true" transientCache="false" { * @doc_generic quick.models.BaseEntity | struct * @return [quick.models.BaseEntity] | [struct] */ - public array function eagerLoadRelation( + private array function eagerLoadRelation( required string relationName, required struct eagerLoadConfig, required array entities @@ -1186,7 +1262,11 @@ component accessors="true" transientCache="false" { ); var loadedRelationshipName = arguments.relationName; for ( var entity in matchedEntities ) { - if ( isStruct( entity ) && structKeyExists( entity, "isQuickEntity" ) ) { + if ( + !variables._parallelEagerLoadingContext.areLifecycleEventsSuppressed() + && isStruct( entity ) + && structKeyExists( entity, "isQuickEntity" ) + ) { entity.fireRelationshipLoaded( loadedRelationshipName ); } } @@ -2122,8 +2202,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 ); } @@ -2134,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( entity ); if ( hasVirtualData ) { entity.set_refreshQuery( arguments.refreshQuery ); } @@ -2143,6 +2223,14 @@ component accessors="true" transientCache="false" { } } + private void function markLoadedEntity( required any entity ) { + if ( variables._parallelEagerLoadingContext.areLifecycleEventsSuppressed() ) { + arguments.entity.set_loaded( true ); + } else { + arguments.entity.markLoaded(); + } + } + /** * Automatically converts the entities found from a query to mementos. * 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/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index 8aa7033c..1bfe5e05 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -16,6 +16,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { describe( "Eager Loading Spec", function() { beforeEach( function() { variables.queries = []; + structDelete( request, "parallelLifecyclePostLoads" ); } ); it( "can eager load a belongs to relationship", function() { @@ -100,6 +101,138 @@ component extends="tests.resources.ModuleIntegrationSpec" { } } ); + 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(); + } + } ); + + 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(); + } + } ); + + 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(); + expect( tag.getSubscription() ).toBeInstanceOf( "quick.models.Relationships.Pivot" ); + } + } ); + + 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(); + } ); + + it( "limits the number of concurrent eager-loading workers", function() { + if ( supportsParallelEagerLoadingForTest() ) { + var activeWorkers = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init(); + var maxWorkers = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init(); + var trackWorker = function( relationship ) { + var active = activeWorkers.incrementAndGet(); + while ( active > maxWorkers.get() && !maxWorkers.compareAndSet( maxWorkers.get(), active ) ) { + } + sleep( 25 ); + activeWorkers.decrementAndGet(); + }; + var builder = getInstance( "Post" ).with( + [ + { "author" : trackWorker }, + { "comments" : trackWorker } + ], + true + ); + builder.set_parallelEagerLoadingMaxThreads( 1 ).get(); + + expect( maxWorkers.get() ).toBe( 1 ); + } + } ); + + it( "propagates parallel eager-loading worker failures", function() { + if ( supportsParallelEagerLoadingForTest() ) { + expect( function() { + getInstance( "Post" ) + .with( + [ + { + "author" : function( relationship ) { + throw( type = "ExpectedParallelFailure", message = "worker failed" ); + } + }, + "comments" + ], + true + ) + .get(); + } ).toThrow( type = "QuickParallelEagerLoadingException", regex = "worker failed" ); + } + } ); + + it( "times out and cancels unfinished parallel eager-loading workers", function() { + if ( supportsParallelEagerLoadingForTest() ) { + var builder = getInstance( "Post" ).with( + [ + { + "author" : function( relationship ) { + sleep( 100 ); + } + }, + "comments" + ], + true + ); + builder.set_parallelEagerLoadingTimeout( 1 ); + + expect( function() { + builder.get(); + } ).toThrow( type = "QuickParallelEagerLoadingTimeout", regex = "1 milliseconds" ); + } + } ); + it( "can eager load a belongs to relationship using a composite key", function() { var compositeChildren = getInstance( "CompositeChild" ).with( "parent" ).get(); expect( compositeChildren ).toBeArray(); @@ -908,4 +1041,8 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); } + private boolean function supportsParallelEagerLoadingForTest() { + return !server.keyExists( "coldfusion" ) || !findNoCase( "ColdFusion", server.coldfusion.productName ); + } + } From e423faa82f0c20e2187fef22ee59501142d4c5f8 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 18:11:47 -0600 Subject: [PATCH 16/25] test: allow concurrent worker failure ordering --- .../integration/BaseEntity/Relationships/EagerLoadingSpec.cfc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index 1bfe5e05..4e30a1a2 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -208,7 +208,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { true ) .get(); - } ).toThrow( type = "QuickParallelEagerLoadingException", regex = "worker failed" ); + } ).toThrow( type = "QuickParallelEagerLoadingException" ); } } ); From 1913614293fc146168eab7dd1da6f92176abd3ba Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 18:17:53 -0600 Subject: [PATCH 17/25] fix: use portable thread attribute resolution --- models/QuickBuilder.cfc | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index c1a919b0..a6496f67 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -894,7 +894,7 @@ component accessors="true" transientCache="false" { variables._parallelEagerLoadingContext.suppressLifecycleEvents(); try { var workerEntities = []; - for ( var entityState in attributes.entityStates ) { + for ( var entityState in entityStates ) { workerEntities.append( entityState.isQuickEntity ? hydrateParallelEntityState( entityState ) @@ -902,31 +902,27 @@ component accessors="true" transientCache="false" { ); } var loadedEntities = eagerLoadRelation( - attributes.relationName, - attributes.eagerLoadConfig, + relationName, + eagerLoadConfig, workerEntities ); var relationshipValues = []; for ( var loadedEntity in loadedEntities ) { if ( structKeyExists( loadedEntity, "isQuickEntity" ) ) { - if ( isNull( loadedEntity.retrieveRelationship( attributes.relationName ) ) ) { + if ( isNull( loadedEntity.retrieveRelationship( relationName ) ) ) { relationshipValues.append( { "type" : "null" } ); } else { relationshipValues.append( - serializeParallelValue( - loadedEntity.retrieveRelationship( attributes.relationName ) - ) + serializeParallelValue( loadedEntity.retrieveRelationship( relationName ) ) ); } - } else if ( loadedEntity.keyExists( attributes.relationName ) ) { - relationshipValues.append( - serializeParallelValue( loadedEntity[ attributes.relationName ] ) - ); + } else if ( loadedEntity.keyExists( relationName ) ) { + relationshipValues.append( serializeParallelValue( loadedEntity[ relationName ] ) ); } else { relationshipValues.append( { "type" : "null" } ); } } - attributes.results.put( attributes.threadName, relationshipValues ); + results.put( threadName, relationshipValues ); } finally { variables._parallelEagerLoadingContext.restoreLifecycleEvents(); } From 66fbbc86318372d4f1aee73112caf272ed5d38f4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 18:22:14 -0600 Subject: [PATCH 18/25] fix: disambiguate serialized attribute state --- models/QuickBuilder.cfc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index a6496f67..5e64b8cc 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -988,8 +988,8 @@ component accessors="true" transientCache="false" { private any function hydrateParallelEntityState( required struct state ) { return getEntity() .newEntity( arguments.state.mappingName ) - .assignAttributesData( arguments.state.attributes ) - .assignOriginalAttributes( arguments.state.attributes ) + .assignAttributesData( arguments.state[ "attributes" ] ) + .assignOriginalAttributes( arguments.state[ "attributes" ] ) .set_loaded( true ); } @@ -1058,11 +1058,11 @@ component accessors="true" transientCache="false" { case "entity": var entity = getEntity().newEntity( arguments.state.mappingName ); try { - entity.hydrate( arguments.state.attributes ); + entity.hydrate( arguments.state[ "attributes" ] ); } catch ( MissingHydrationKey missingKey ) { entity - .assignAttributesData( arguments.state.attributes ) - .assignOriginalAttributes( arguments.state.attributes ) + .assignAttributesData( arguments.state[ "attributes" ] ) + .assignOriginalAttributes( arguments.state[ "attributes" ] ) .set_loaded( true ); } for ( var relationshipName in arguments.state.relationships ) { From 6bec058a7a9866cf36140e0c310b621d28a369e2 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 26 Aug 2026 18:39:06 -0600 Subject: [PATCH 19/25] fix: avoid BoxLang thread scope collision --- models/BaseEntity.cfc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 ) { From 782ae5e4b2d69040d606902b80ced3ba01e6c54b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 27 Aug 2026 13:44:30 -0600 Subject: [PATCH 20/25] fix parallel eager loading isolation --- models/ParallelEagerLoadingContext.cfc | 24 - models/ParallelEagerLoadingCoordinator.cfc | 56 ++ models/QuickBuilder.cfc | 373 ++++++------ models/Relationships/BaseRelationship.cfc | 31 + models/Relationships/IRelationship.cfc | 21 + models/Relationships/PolymorphicBelongsTo.cfc | 78 ++- tests/resources/ModuleIntegrationSpec.cfc | 4 + tests/resources/app/models/Post.cfc | 4 + .../app/models/UserWithGlobalScope.cfc | 3 + .../Relationships/EagerLoadingSpec.cfc | 554 +++++++++++++----- 10 files changed, 793 insertions(+), 355 deletions(-) delete mode 100644 models/ParallelEagerLoadingContext.cfc create mode 100644 models/ParallelEagerLoadingCoordinator.cfc diff --git a/models/ParallelEagerLoadingContext.cfc b/models/ParallelEagerLoadingContext.cfc deleted file mode 100644 index c993c6e9..00000000 --- a/models/ParallelEagerLoadingContext.cfc +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Tracks parallel eager-loading workers without leaking state between threads. - */ -component singleton { - - function init() { - variables.lifecycleEventsSuppressed = createObject( "java", "java.lang.ThreadLocal" ).init(); - return this; - } - - public void function suppressLifecycleEvents() { - variables.lifecycleEventsSuppressed.set( true ); - } - - public void function restoreLifecycleEvents() { - variables.lifecycleEventsSuppressed.remove(); - } - - public boolean function areLifecycleEventsSuppressed() { - var value = variables.lifecycleEventsSuppressed.get(); - return !isNull( value ) && value; - } - -} diff --git a/models/ParallelEagerLoadingCoordinator.cfc b/models/ParallelEagerLoadingCoordinator.cfc new file mode 100644 index 00000000..6c3b26c9 --- /dev/null +++ b/models/ParallelEagerLoadingCoordinator.cfc @@ -0,0 +1,56 @@ +/** + * Provides application-wide admission control for parallel eager-loading work. + */ +component singleton { + + property + name ="maxWorkers" + default="4" + inject ="box:setting:parallelEagerLoadingMaxThreads@quick"; + + function init() { + param variables.maxWorkers = 4; + return this; + } + + function onDIComplete() { + variables.semaphore = createObject( "java", "java.util.concurrent.Semaphore" ).init( + javacast( "int", max( 1, int( variables.maxWorkers ) ) ), + javacast( "boolean", true ) + ); + variables.currentWorker = createObject( "java", "java.lang.ThreadLocal" ).init(); + } + + 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; + } + + public boolean function acquire( required numeric timeout ) { + return variables.semaphore.tryAcquire( + javacast( "long", arguments.timeout ), + createObject( "java", "java.util.concurrent.TimeUnit" ).MILLISECONDS + ); + } + + public void function release() { + variables.semaphore.release(); + } + + public numeric function availablePermits() { + return variables.semaphore.availablePermits(); + } + +} diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 5e64b8cc..1e0c5702 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. */ @@ -79,9 +84,9 @@ component accessors="true" transientCache="false" { inject ="box:setting:parallelEagerLoadingTimeout@quick"; /** - * Thread-local lifecycle state shared by every QuickBuilder instance. + * Application-wide admission control for parallel eager-loading queries. */ - property name="_parallelEagerLoadingContext" inject="quick.models.ParallelEagerLoadingContext"; + property name="_parallelEagerLoadingCoordinator" inject="quick.models.ParallelEagerLoadingCoordinator"; /** * A map of aliases to entities to use when qualifying aliased columns. @@ -461,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. @@ -496,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 ) ) ) ); @@ -788,6 +836,9 @@ component accessors="true" transientCache="false" { } } variables._eagerLoad = eagerLoadList; + if ( variables._eagerLoad.isEmpty() ) { + variables._parallelEagerLoading = false; + } return this; } @@ -797,7 +848,8 @@ component accessors="true" transientCache="false" { * @return QuickBuilder */ public any function clearEagerLoads() { - variables._eagerLoad = []; + variables._eagerLoad = []; + variables._parallelEagerLoading = false; return this; } @@ -833,7 +885,12 @@ component accessors="true" transientCache="false" { } var eagerLoads = denestEagerLoads( variables._eagerLoad ); - if ( variables._parallelEagerLoading && eagerLoads.count() > 1 && supportsParallelEagerLoading() ) { + if ( + variables._parallelEagerLoading + && eagerLoads.count() > 1 + && supportsParallelEagerLoading() + && !isInsideDatabaseTransaction() + ) { eagerLoadRelationsInParallel( eagerLoads, arguments.entities ); } else { for ( var relationName in eagerLoads ) { @@ -857,74 +914,57 @@ component accessors="true" transientCache="false" { var timeout = max( 1, int( variables._parallelEagerLoadingTimeout ) ); var targetEntities = arguments.entities; var threadResults = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); - var entityStates = []; - for ( var entity in arguments.entities ) { - entityStates.append( - structKeyExists( entity, "isQuickEntity" ) - ? { - "isQuickEntity" : true, - "mappingName" : entity.mappingName(), - "attributes" : entity.retrieveAttributesData( withNulls = true ) - } - : { - "isQuickEntity" : false, - "value" : duplicate( entity ) - } + 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 <= relationNames.len(); batchStart += maxWorkers ) { - var batchThreadNames = []; - var batchThreadRelations = {}; - var batchEnd = min( relationNames.len(), batchStart + maxWorkers - 1 ); - for ( var relationIndex = batchStart; relationIndex <= batchEnd; relationIndex++ ) { - var relationName = relationNames[ relationIndex ]; - var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; + for ( var batchStart = 1; batchStart <= plans.len(); batchStart += maxWorkers ) { + var batchThreadNames = []; + var batchThreadPlans = {}; + var batchEnd = min( plans.len(), batchStart + maxWorkers - 1 ); + for ( var planIndex = batchStart; planIndex <= batchEnd; planIndex++ ) { + var plan = plans[ planIndex ]; + var threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; batchThreadNames.append( threadName ); - batchThreadRelations[ threadName ] = relationName; + batchThreadPlans[ threadName ] = plan; cfthread( - action = "run", - name = threadName, - threadName = threadName, - relationName = relationName, - eagerLoadConfig = arguments.eagerLoads[ relationName ], - entityStates = entityStates, - results = threadResults + action = "run", + name = threadName, + threadName = threadName, + plan = plan, + results = threadResults, + workerTimeout = timeout ) { - variables._parallelEagerLoadingContext.suppressLifecycleEvents(); + var acquiredPermit = false; + variables._parallelEagerLoadingCoordinator.enterWorker( threadName ); try { - var workerEntities = []; - for ( var entityState in entityStates ) { - workerEntities.append( - entityState.isQuickEntity - ? hydrateParallelEntityState( entityState ) - : entityState.value - ); - } - var loadedEntities = eagerLoadRelation( - relationName, - eagerLoadConfig, - workerEntities - ); - var relationshipValues = []; - for ( var loadedEntity in loadedEntities ) { - if ( structKeyExists( loadedEntity, "isQuickEntity" ) ) { - if ( isNull( loadedEntity.retrieveRelationship( relationName ) ) ) { - relationshipValues.append( { "type" : "null" } ); - } else { - relationshipValues.append( - serializeParallelValue( loadedEntity.retrieveRelationship( relationName ) ) - ); - } - } else if ( loadedEntity.keyExists( relationName ) ) { - relationshipValues.append( serializeParallelValue( loadedEntity[ relationName ] ) ); - } else { - relationshipValues.append( { "type" : "null" } ); + if ( plan.hasMatches ) { + acquiredPermit = variables._parallelEagerLoadingCoordinator.acquire( workerTimeout ); + if ( !acquiredPermit ) { + throw( + type = "QuickParallelEagerLoadingTimeout", + message = "Parallel eager loading could not acquire a worker within #workerTimeout# milliseconds." + ); } + results.put( threadName, plan.relation.retrieveEagerRows() ); + } else { + results.put( threadName, [] ); } - results.put( threadName, relationshipValues ); } finally { - variables._parallelEagerLoadingContext.restoreLifecycleEvents(); + if ( acquiredPermit ) { + variables._parallelEagerLoadingCoordinator.release(); + } + variables._parallelEagerLoadingCoordinator.leaveWorker(); } } } @@ -948,12 +988,15 @@ component accessors="true" transientCache="false" { } } if ( failedThread != "" || timedOut ) { - terminateParallelEagerLoadingThreads( batchThreadNames ); + terminateAndJoinParallelEagerLoadingThreads( batchThreadNames, timeout ); } if ( failedThread != "" ) { var threadError = cfthread[ failedThread ].error; + var errorType = threadError.keyExists( "type" ) && threadError.type == "QuickParallelEagerLoadingTimeout" + ? "QuickParallelEagerLoadingTimeout" + : "QuickParallelEagerLoadingException"; throw( - type = "QuickParallelEagerLoadingException", + type = errorType, message = threadError.keyExists( "message" ) ? threadError.message : "A parallel eager-loading thread failed.", extendedInfo = serializeJSON( threadError ) ); @@ -966,136 +1009,103 @@ component accessors="true" transientCache="false" { } for ( var completedThreadName in batchThreadNames ) { - var completedRelationName = batchThreadRelations[ completedThreadName ]; - var relationshipValues = threadResults.get( completedThreadName ); - for ( var i = 1; i <= targetEntities.len(); i++ ) { - var completedRelationshipValue = deserializeParallelValue( relationshipValues[ i ] ); - if ( structKeyExists( targetEntities[ i ], "isQuickEntity" ) ) { - if ( isNull( completedRelationshipValue ) ) { - targetEntities[ i ].assignRelationship( completedRelationName ); - } else { - targetEntities[ i ].assignRelationship( completedRelationName, completedRelationshipValue ); - } - targetEntities[ i ].fireRelationshipLoaded( completedRelationName ); - } else if ( !isNull( completedRelationshipValue ) ) { - targetEntities[ i ][ completedRelationName ] = completedRelationshipValue; - } - } + finalizeParallelEagerLoad( + batchThreadPlans[ completedThreadName ], + threadResults.get( completedThreadName ), + targetEntities + ); } } } - private any function hydrateParallelEntityState( required struct state ) { - return getEntity() - .newEntity( arguments.state.mappingName ) - .assignAttributesData( arguments.state[ "attributes" ] ) - .assignOriginalAttributes( arguments.state[ "attributes" ] ) - .set_loaded( 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 ); + } + return { + "hasMatches" : hasMatches, + "relation" : relation, + "relationName" : arguments.relationName + }; } - private void function terminateParallelEagerLoadingThreads( required array threadNames ) { - for ( var threadName in arguments.threadNames ) { - if ( cfthread[ threadName ].status != "COMPLETED" && cfthread[ threadName ].status != "TERMINATED" ) { - cfthread( action = "terminate", name = threadName ); + private void function finalizeParallelEagerLoad( + required struct plan, + required array rows, + required array entities + ) { + var results = arguments.plan.hasMatches ? arguments.plan.relation.hydrateEagerRows( arguments.rows ) : []; + var matchedEntities = arguments.plan.relation.match( + arguments.entities, + results, + arguments.plan.relationName + ); + for ( var entity in matchedEntities ) { + if ( isStruct( entity ) && structKeyExists( entity, "isQuickEntity" ) ) { + entity.fireRelationshipLoaded( arguments.plan.relationName ); } } } - /** - * Converts eager-loaded values to CFC-free state for crossing thread boundaries. - */ - private struct function serializeParallelValue( any value ) { - if ( isNull( arguments.value ) ) { - return { "type" : "null" }; - } - if ( isArray( arguments.value ) ) { - var items = []; - for ( var item in arguments.value ) { - items.append( serializeParallelValue( item ) ); + private void function terminateAndJoinParallelEagerLoadingThreads( + required array threadNames, + required numeric timeout + ) { + for ( var threadName in arguments.threadNames ) { + if ( cfthread[ threadName ].status != "COMPLETED" && cfthread[ threadName ].status != "TERMINATED" ) { + cfthread( action = "terminate", name = threadName ); } - return { "type" : "array", "value" : items }; } - if ( isStruct( arguments.value ) && structKeyExists( arguments.value, "isQuickEntity" ) ) { - var relationships = {}; - for ( var relationshipName in arguments.value.get_relationshipsLoaded().keyArray() ) { - relationships[ relationshipName ] = serializeParallelValue( - arguments.value.retrieveRelationship( relationshipName ) + cfthread( + action = "join", + name = arguments.threadNames.toList(), + timeout = arguments.timeout + ); + for ( var joinedThreadName in arguments.threadNames ) { + if ( + cfthread[ joinedThreadName ].status != "COMPLETED" + && cfthread[ joinedThreadName ].status != "TERMINATED" + ) { + throw( + type = "QuickParallelEagerLoadingCancellationException", + message = "Parallel eager-loading worker [#joinedThreadName#] remained active after cancellation." ); } - return { - "type" : "entity", - "mappingName" : arguments.value.mappingName(), - "attributes" : arguments.value.retrieveAttributesData( withNulls = true ), - "relationships" : relationships - }; } - if ( isStruct( arguments.value ) ) { - var values = {}; - for ( var key in arguments.value ) { - values[ key ] = serializeParallelValue( arguments.value[ key ] ); - } - return { "type" : "struct", "value" : values }; - } - return { - "type" : "value", - "value" : arguments.value - }; } /** - * Reconstructs eager-loaded values exported by a worker thread. + * Adobe ColdFusion loses CFC private-method resolution inside cfthread. */ - private any function deserializeParallelValue( required struct state ) { - switch ( arguments.state.type ) { - case "null": - return javacast( "null", "" ); - case "array": - var items = []; - for ( var item in arguments.state.value ) { - items.append( deserializeParallelValue( item ) ); - } - return items; - case "entity": - var entity = getEntity().newEntity( arguments.state.mappingName ); - try { - entity.hydrate( arguments.state[ "attributes" ] ); - } catch ( MissingHydrationKey missingKey ) { - entity - .assignAttributesData( arguments.state[ "attributes" ] ) - .assignOriginalAttributes( arguments.state[ "attributes" ] ) - .set_loaded( true ); - } - for ( var relationshipName in arguments.state.relationships ) { - var relationshipValue = deserializeParallelValue( - arguments.state.relationships[ relationshipName ] - ); - if ( isNull( relationshipValue ) ) { - entity.assignRelationship( relationshipName ); - } else { - entity.assignRelationship( relationshipName, relationshipValue ); - } - entity.fireRelationshipLoaded( relationshipName ); - } - return entity; - case "struct": - var values = {}; - for ( var key in arguments.state.value ) { - var value = deserializeParallelValue( arguments.state.value[ key ] ); - if ( !isNull( value ) ) { - values[ key ] = value; - } - } - return values; - default: - return arguments.state.value; - } + private boolean function supportsParallelEagerLoading() { + return !server.keyExists( "coldfusion" ) || !findNoCase( "ColdFusion", server.coldfusion.productName ); } /** - * Adobe ColdFusion loses CFC private-method resolution inside cfthread. + * Worker threads cannot share a caller's transaction-bound connection. */ - private boolean function supportsParallelEagerLoading() { - return !server.keyExists( "coldfusion" ) || !findNoCase( "ColdFusion", server.coldfusion.productName ); + 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 ) { @@ -1258,11 +1268,7 @@ component accessors="true" transientCache="false" { ); var loadedRelationshipName = arguments.relationName; for ( var entity in matchedEntities ) { - if ( - !variables._parallelEagerLoadingContext.areLifecycleEventsSuppressed() - && isStruct( entity ) - && structKeyExists( entity, "isQuickEntity" ) - ) { + if ( isStruct( entity ) && structKeyExists( entity, "isQuickEntity" ) ) { entity.fireRelationshipLoaded( loadedRelationshipName ); } } @@ -2220,11 +2226,7 @@ component accessors="true" transientCache="false" { } private void function markLoadedEntity( required any entity ) { - if ( variables._parallelEagerLoadingContext.areLifecycleEventsSuppressed() ) { - arguments.entity.set_loaded( true ); - } else { - arguments.entity.markLoaded(); - } + arguments.entity.markLoaded(); } /** @@ -2280,6 +2282,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..885112c7 100644 --- a/models/Relationships/BaseRelationship.cfc +++ b/models/Relationships/BaseRelationship.cfc @@ -146,6 +146,37 @@ 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 ); + } + /** * 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..1689412a 100644 --- a/models/Relationships/IRelationship.cfc +++ b/models/Relationships/IRelationship.cfc @@ -17,6 +17,27 @@ 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. + * + * @internal + */ + public array function hydrateEagerRows( required array rows ); + /** * Adds constraints for eager loading * diff --git a/models/Relationships/PolymorphicBelongsTo.cfc b/models/Relationships/PolymorphicBelongsTo.cfc index 0f7f0265..de512d4b 100644 --- a/models/Relationships/PolymorphicBelongsTo.cfc +++ b/models/Relationships/PolymorphicBelongsTo.cfc @@ -152,6 +152,59 @@ 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 ); + variables.parallelEagerQueries.append( { + "morphParent" : morphParent, + "query" : prepareResultsQueryByType( + type, + morphParent, + arguments.asQuery, + arguments.withAliases + ), + "type" : type + } ); + } + return this; + } + + /** + * 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 and matches each morph result set on the calling thread. + * + * @internal + */ + public array function hydrateEagerRows( required array rows ) { + for ( var i = 1; i <= variables.parallelEagerQueries.len(); i++ ) { + var eagerQuery = variables.parallelEagerQueries[ i ]; + matchToMorphParents( + eagerQuery.type, + eagerQuery.morphParent, + eagerQuery.query.hydrateUnhydratedResults( arguments.rows[ i ] ) + ); + } + return variables.entities; + } + /** * Executes a query and returns the results for a given polymorphic type. * @@ -166,17 +219,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 +252,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/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 4e30a1a2..5ce40edd 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -14,9 +14,19 @@ component extends="tests.resources.ModuleIntegrationSpec" { function run() { describe( "Eager Loading Spec", function() { - beforeEach( function() { - variables.queries = []; + beforeEach( function( currentSpec ) { + request.quickSkipDatabaseTransactions = specHasLabel( arguments.currentSpec, "no-transaction" ); + variables.queries = []; + variables.workerQueryThreads = 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(); structDelete( request, "parallelLifecyclePostLoads" ); + structDelete( request, "trackParallelScopeThreads" ); + structDelete( request, "parallelScopeThreads" ); } ); it( "can eager load a belongs to relationship", function() { @@ -62,44 +72,41 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( keys ).toHaveLength( 2 ); } ); - it( "can eager load top-level relationships in parallel", function() { - var callingThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); - var eagerThreads = {}; - var posts = getInstance( "Post" ) - .with( - [ - { - "author" : function( relationship ) { - eagerThreads.author = createObject( "java", "java.lang.Thread" ) - .currentThread() - .getName(); + 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(); + } } - }, - { - "comments" : function( relationship ) { - eagerThreads.comments = createObject( "java", "java.lang.Thread" ) - .currentThread() - .getName(); - } - } - ], - true - ) - .get(); + ], + true + ) + .get(); - expect( posts[ 1 ].getAuthor() ).toBeInstanceOf( "app.models.User" ); - expect( posts[ 1 ].getComments() ).toBeArray(); - expect( eagerThreads ).toHaveKey( "author" ); - expect( eagerThreads ).toHaveKey( "comments" ); - if ( server.keyExists( "coldfusion" ) && findNoCase( "ColdFusion", server.coldfusion.productName ) ) { - expect( eagerThreads.author ).toBe( callingThread ); - expect( eagerThreads.comments ).toBe( callingThread ); - } else { - expect( eagerThreads.author ).notToBe( callingThread ); - expect( eagerThreads.comments ).notToBe( callingThread ); - expect( eagerThreads.author ).notToBe( eagerThreads.comments ); - } - } ); + 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 ); + }, + "no-transaction" + ); it( "keeps a single eager load on the calling thread", function() { var callingThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); @@ -118,119 +125,326 @@ component extends="tests.resources.ModuleIntegrationSpec" { 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(); + 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(); - } - } ); + 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( + "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(); + } + }, + "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(); - 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( 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" + ); - expect( post.getAuthor().isRelationshipLoaded( "country" ) ).toBeTrue(); - for ( var comment in post.getComments() ) { - expect( comment.isRelationshipLoaded( "author" ) ).toBeTrue(); - } - } ); + it( + "supports parallel eager loading for query results", + function() { + var posts = getInstance( "Post" ) + .with( [ "author", "comments" ], true ) + .asQuery() + .get(); - it( "preserves pivot relationships in parallel relationship graphs", function() { - var post = getInstance( "Post" ) - .where( "post_pk", 1245 ) - .with( [ "tagsAsSubscriptions", "comments" ], true ) - .firstOrFail(); + 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" ); + var availablePermitsBeforeRun = coordinator.availablePermits(); + 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" ); + expect( variables.activeWorkers.get() ).toBe( 0 ); + expect( coordinator.availablePermits() ).toBe( availablePermitsBeforeRun ); + } + }, + "no-transaction" + ); + + it( + "hydrates virtual attributes on the calling thread", + 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(); - for ( var tag in post.getTagsAsSubscriptions() ) { - expect( tag.isRelationshipLoaded( "subscription" ) ).toBeTrue(); - expect( tag.getSubscription() ).toBeInstanceOf( "quick.models.Relationships.Pivot" ); - } - } ); + expect( variables.workerQueryThreads.size() ).toBe( supportsParallelEagerLoadingForTest() ? 2 : 0 ); + }, + "no-transaction" + ); - it( "supports parallel eager loading for query results", function() { - var posts = getInstance( "Post" ) - .with( [ "author", "comments" ], true ) - .asQuery() - .get(); + it( + "clears the parallel flag with eager loads", + function() { + getInstance( "Post" ) + .with( [ "author", "comments" ], true ) + .clearEagerLoads() + .with( [ "author", "comments" ] ) + .get(); - expect( posts[ 1 ] ).toBeStruct(); - expect( posts[ 1 ].author ).toBeStruct(); - expect( posts[ 1 ].comments ).toBeArray(); - expect( posts[ 3 ].author ).toBeStruct().toBeEmpty(); - } ); + 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(); - it( "limits the number of concurrent eager-loading workers", function() { - if ( supportsParallelEagerLoadingForTest() ) { - var activeWorkers = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init(); - var maxWorkers = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init(); - var trackWorker = function( relationship ) { - var active = activeWorkers.incrementAndGet(); - while ( active > maxWorkers.get() && !maxWorkers.compareAndSet( maxWorkers.get(), active ) ) { - } - sleep( 25 ); - activeWorkers.decrementAndGet(); - }; - var builder = getInstance( "Post" ).with( - [ - { "author" : trackWorker }, - { "comments" : trackWorker } - ], - true - ); - builder.set_parallelEagerLoadingMaxThreads( 1 ).get(); + expect( variables.workerQueryThreads ).toBeEmpty(); + }, + "no-transaction" + ); + + 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( maxWorkers.get() ).toBe( 1 ); - } - } ); + expect( request.parallelLifecyclePostLoads ).toHaveLength( 1 ); + }, + "no-transaction" + ); - it( "propagates parallel eager-loading worker failures", function() { - if ( supportsParallelEagerLoadingForTest() ) { - expect( function() { - getInstance( "Post" ) - .with( - [ - { - "author" : function( relationship ) { - throw( type = "ExpectedParallelFailure", message = "worker failed" ); - } - }, - "comments" - ], - true - ) - .get(); - } ).toThrow( type = "QuickParallelEagerLoadingException" ); - } - } ); + it( + "uses the application-wide worker limit across builders", + function() { + if ( !supportsParallelEagerLoadingForTest() ) { + return; + } + var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + var acquired = 0; + try { + var availablePermits = coordinator.availablePermits(); + for ( var i = 1; i <= availablePermits; i++ ) { + if ( coordinator.acquire( 1 ) ) { + acquired++; + } + } - it( "times out and cancels unfinished parallel eager-loading workers", function() { - if ( supportsParallelEagerLoadingForTest() ) { - var builder = getInstance( "Post" ).with( - [ - { - "author" : function( relationship ) { - sleep( 100 ); - } - }, - "comments" - ], - true - ); - builder.set_parallelEagerLoadingTimeout( 1 ); + var builder = getInstance( "Post" ).with( [ "author", "comments" ], true ); + builder.set_parallelEagerLoadingTimeout( 5 ); + expect( function() { + builder.get(); + } ).toThrow( type = "QuickParallelEagerLoadingTimeout", regex = "5 milliseconds" ); + } finally { + for ( var permit = 1; permit <= acquired; permit++ ) { + coordinator.release(); + } + } + }, + "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" + } ); - expect( function() { - builder.get(); - } ).toThrow( type = "QuickParallelEagerLoadingTimeout", regex = "1 milliseconds" ); - } + 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(); } ); it( "can eager load a belongs to relationship using a composite key", function() { @@ -1028,7 +1242,43 @@ 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 ); + var activeWorkers = variables.activeWorkers.incrementAndGet(); + while ( + activeWorkers > variables.maxActiveWorkers.get() + && !variables.maxActiveWorkers.compareAndSet( variables.maxActiveWorkers.get(), activeWorkers ) + ) { + } + try { + if ( variables.parallelWorkerDelay > 0 ) { + sleep( variables.parallelWorkerDelay ); + } + if ( variables.failParallelWorker ) { + throw( type = "ExpectedParallelFailure", message = "worker failed" ); + } + } finally { + variables.activeWorkers.decrementAndGet(); + } + } + } + + function quickInstanceReady( + event, + interceptData, + buffer, + rc, + prc + ) { + if ( variables.trackInstanceReady ) { + variables.instanceReadyCount.incrementAndGet(); + } } private array function extractBindingTypes( required struct queryLogEntry ) { @@ -1045,4 +1295,28 @@ component extends="tests.resources.ModuleIntegrationSpec" { 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; + } + } From d7e53b582af5ef97005ac86177257c4a86c6ef45 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 27 Aug 2026 13:51:32 -0600 Subject: [PATCH 21/25] split eager loading test registrations --- .../Relationships/EagerLoadingSpec.cfc | 702 +++++++++--------- 1 file changed, 359 insertions(+), 343 deletions(-) diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index 5ce40edd..af25f53f 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -72,379 +72,395 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( keys ).toHaveLength( 2 ); } ); - 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(); + describe( "parallel eager loading execution", function() { + 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 + ); + }, + "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( 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 ); - }, - "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 ); + } ); - 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( - "delivers lifecycle events once on the returned parallel entities", - function() { - var users = getInstance( "ParallelLifecycleUser" ) - .where( "id", 1 ) - .with( [ "posts", "comments" ], true ) - .get(); + describe( "parallel eager loading relationship state", function() { + 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(); + } + }, + "no-transaction" + ); - 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( - "preserves nested eager loads in parallel relationship graphs", - function() { - var post = getInstance( "Post" ) - .where( "post_pk", 1245 ) - .with( [ "author.country", "comments.author" ], true ) - .firstOrFail(); + 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" + ); - expect( post.getAuthor().isRelationshipLoaded( "country" ) ).toBeTrue(); - for ( var comment in post.getComments() ) { - expect( comment.isRelationshipLoaded( "author" ) ).toBeTrue(); - } - }, - "no-transaction" - ); - - it( - "preserves pivot relationships in parallel relationship graphs", - function() { - var post = getInstance( "Post" ) - .where( "post_pk", 1245 ) - .with( [ "tagsAsSubscriptions", "comments" ], true ) - .firstOrFail(); + 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" + ); - 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(); + it( + "applies relationship global scopes on the calling thread", + function() { + var callingThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); + request.trackParallelScopeThreads = true; + request.parallelScopeThreads = []; - 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" - ); + getInstance( "Post" ).with( [ "scopedAuthor", "comments" ], true ).get(); - it( - "supports parallel eager loading for query results", - function() { - var posts = getInstance( "Post" ) - .with( [ "author", "comments" ], true ) - .asQuery() - .get(); + expect( request.parallelScopeThreads ).notToBeEmpty(); + for ( var scopeThread in request.parallelScopeThreads ) { + expect( scopeThread ).toBe( callingThread ); + } + }, + "no-transaction" + ); + } ); - 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" ); - var availablePermitsBeforeRun = coordinator.availablePermits(); - 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" ); - expect( variables.activeWorkers.get() ).toBe( 0 ); - expect( coordinator.availablePermits() ).toBe( availablePermitsBeforeRun ); - } - }, - "no-transaction" - ); - - it( - "hydrates virtual attributes on the calling thread", - function() { - var post = getInstance( "Post" ) - .where( "post_pk", 1245 ) - .with( - [ - { - "comments" : function( relationship ) { - relationship.addUpperBody(); - } - }, - "author" - ], - true - ) - .firstOrFail(); + describe( "parallel eager loading query execution", function() { + 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" + ); - 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(); + 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( post.getAuthorWithEmptyDefault() ).toBeInstanceOf( "app.models.User" ); - expect( post.getAuthorWithEmptyDefault().isLoaded() ).toBeFalse(); - }, - "no-transaction" - ); + expect( variables.maxActiveWorkers.get() ).toBe( 1 ); + } + }, + "no-transaction" + ); - it( - "preserves parallel eager loading when cloning a builder", - function() { - getInstance( "Post" ) - .with( [ "author", "comments" ], true ) - .clone() - .get(); + 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" + ); - expect( variables.workerQueryThreads.size() ).toBe( supportsParallelEagerLoadingForTest() ? 2 : 0 ); - }, - "no-transaction" - ); + it( + "times out and cancels unfinished parallel eager-loading workers", + function() { + if ( supportsParallelEagerLoadingForTest() ) { + var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + var availablePermitsBeforeRun = coordinator.availablePermits(); + 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" ); + expect( variables.activeWorkers.get() ).toBe( 0 ); + expect( coordinator.availablePermits() ).toBe( availablePermitsBeforeRun ); + } + }, + "no-transaction" + ); + } ); - it( - "clears the parallel flag with eager loads", - function() { - getInstance( "Post" ) - .with( [ "author", "comments" ], true ) - .clearEagerLoads() - .with( [ "author", "comments" ] ) - .get(); + describe( "parallel eager loading hydration state", function() { + it( + "hydrates virtual attributes on the calling thread", + 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" + ); - expect( variables.workerQueryThreads ).toBeEmpty(); - }, - "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( - "clears the parallel flag when without removes every eager load", - function() { - getInstance( "Post" ) - .with( [ "author", "comments" ], true ) - .without( [ "author", "comments" ] ) - .with( [ "author", "comments" ] ) - .get(); + it( + "preserves parallel eager loading when cloning a builder", + function() { + getInstance( "Post" ) + .with( [ "author", "comments" ], true ) + .clone() + .get(); - expect( variables.workerQueryThreads ).toBeEmpty(); - }, - "no-transaction" - ); - - 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( 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" + ); + } ); - expect( request.parallelLifecyclePostLoads ).toHaveLength( 1 ); - }, - "no-transaction" - ); + describe( "parallel eager loading lifecycle and transactions", function() { + 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(); - it( - "uses the application-wide worker limit across builders", - function() { - if ( !supportsParallelEagerLoadingForTest() ) { - return; - } - var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); - var acquired = 0; - try { - var availablePermits = coordinator.availablePermits(); - for ( var i = 1; i <= availablePermits; i++ ) { - if ( coordinator.acquire( 1 ) ) { - acquired++; - } + 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 application-wide worker limit across builders", + function() { + if ( !supportsParallelEagerLoadingForTest() ) { + return; } + var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + var acquired = 0; + try { + var availablePermits = coordinator.availablePermits(); + for ( var i = 1; i <= availablePermits; i++ ) { + if ( coordinator.acquire( 1 ) ) { + acquired++; + } + } - var builder = getInstance( "Post" ).with( [ "author", "comments" ], true ); - builder.set_parallelEagerLoadingTimeout( 5 ); - expect( function() { - builder.get(); - } ).toThrow( type = "QuickParallelEagerLoadingTimeout", regex = "5 milliseconds" ); - } finally { - for ( var permit = 1; permit <= acquired; permit++ ) { - coordinator.release(); + var builder = getInstance( "Post" ).with( [ "author", "comments" ], true ); + builder.set_parallelEagerLoadingTimeout( 5 ); + expect( function() { + builder.get(); + } ).toThrow( type = "QuickParallelEagerLoadingTimeout", regex = "5 milliseconds" ); + } finally { + for ( var permit = 1; permit <= acquired; permit++ ) { + coordinator.release(); + } } - } - }, - "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" - } ); + }, + "no-transaction" + ); - var loadedUser = getInstance( "User" ) - .where( "id", user.getId() ) - .with( [ "posts", "roles" ], true ) - .firstOrFail(); + 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(); + expect( loadedUser.getPosts() ).toHaveLength( 1 ); + expect( loadedUser.getPosts()[ 1 ].getBody() ).toBe( "uncommitted parallel eager load" ); + expect( variables.workerQueryThreads ).toBeEmpty(); + } ); } ); it( "can eager load a belongs to relationship using a composite key", function() { From 94530490e0e1379fa37e80b13fab447dc77bc451 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 27 Aug 2026 14:06:02 -0600 Subject: [PATCH 22/25] avoid Lucee test bytecode verifier failure --- .../Relationships/EagerLoadingSpec.cfc | 1208 +++++++++-------- 1 file changed, 645 insertions(+), 563 deletions(-) diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index af25f53f..9aea10c8 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -13,20 +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( currentSpec ) { - request.quickSkipDatabaseTransactions = specHasLabel( arguments.currentSpec, "no-transaction" ); - variables.queries = []; - variables.workerQueryThreads = 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(); - structDelete( request, "parallelLifecyclePostLoads" ); - structDelete( request, "trackParallelScopeThreads" ); - structDelete( request, "parallelScopeThreads" ); + setupEagerLoadingTestState( arguments.currentSpec ); } ); it( "can eager load a belongs to relationship", function() { @@ -71,396 +76,421 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( keys ).toHaveLength( 2 ); } ); + } ); + } - describe( "parallel eager loading execution", function() { - 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 - ); - }, - "no-transaction" - ); + private void function registerParallelExecutionTests() { + describe( "parallel eager loading execution", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); - it( "keeps a single eager load on the calling thread", function() { - var callingThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); - var eagerThread = ""; - getInstance( "Post" ) + 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 ) { - eagerThread = createObject( "java", "java.lang.Thread" ).currentThread().getName(); + [ + { + "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( eagerThread ).toBe( callingThread ); - } ); + 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 ); + }, + "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(); - 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" - ); + expect( eagerThread ).toBe( callingThread ); } ); - describe( "parallel eager loading relationship state", function() { - 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(); - } - }, - "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 = []; + it( + "delivers lifecycle events once on the returned parallel entities", + function() { + var users = getInstance( "ParallelLifecycleUser" ) + .where( "id", 1 ) + .with( [ "posts", "comments" ], true ) + .get(); - getInstance( "Post" ).with( [ "scopedAuthor", "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" + ); + } ); + } - expect( request.parallelScopeThreads ).notToBeEmpty(); - for ( var scopeThread in request.parallelScopeThreads ) { - expect( scopeThread ).toBe( callingThread ); - } - }, - "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(); - describe( "parallel eager loading query execution", function() { - 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" - ); + expect( post.getAuthor().isRelationshipLoaded( "country" ) ).toBeTrue(); + for ( var comment in post.getComments() ) { + expect( comment.isRelationshipLoaded( "author" ) ).toBeTrue(); + } + }, + "no-transaction" + ); + + it( + "preserves pivot relationships in parallel relationship graphs", + function() { + var post = getInstance( "Post" ) + .where( "post_pk", 1245 ) + .with( [ "tagsAsSubscriptions", "comments" ], true ) + .firstOrFail(); - 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(); + 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( variables.maxActiveWorkers.get() ).toBe( 1 ); - } - }, - "no-transaction" - ); + 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" + ); + } ); + } - 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" - ); + 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(); - it( - "times out and cancels unfinished parallel eager-loading workers", - function() { - if ( supportsParallelEagerLoadingForTest() ) { - var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); - var availablePermitsBeforeRun = coordinator.availablePermits(); - 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" ); - expect( variables.activeWorkers.get() ).toBe( 0 ); - expect( coordinator.availablePermits() ).toBe( availablePermitsBeforeRun ); - } - }, - "no-transaction" - ); + 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" ); + var availablePermitsBeforeRun = coordinator.availablePermits(); + 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" ); + expect( coordinator.availablePermits() ).toBe( availablePermitsBeforeRun ); + } + }, + "no-transaction" + ); + } ); + } + + private void function registerParallelHydrationStateTests() { + describe( "parallel eager loading hydration state", function() { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); } ); + it( + "hydrates virtual attributes on the calling thread", + function() { + var post = getInstance( "Post" ) + .where( "post_pk", 1245 ) + .with( + [ + { + "comments" : function( relationship ) { + relationship.addUpperBody(); + } + }, + "author" + ], + true + ) + .firstOrFail(); - describe( "parallel eager loading hydration state", function() { - it( - "hydrates virtual attributes on the calling thread", - 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" - ); + 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(); - 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" - ); + 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(); + 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" - ); + 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 with eager loads", + function() { + getInstance( "Post" ) + .with( [ "author", "comments" ], true ) + .clearEagerLoads() + .with( [ "author", "comments" ] ) + .get(); - 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" - ); - } ); + expect( variables.workerQueryThreads ).toBeEmpty(); + }, + "no-transaction" + ); - describe( "parallel eager loading lifecycle and transactions", function() { - 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(); + 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(); - variables.instanceReadyCount.set( 0 ); - getInstance( "Post" ).with( [ "author", "comments" ], true ).get(); + expect( variables.workerQueryThreads ).toBeEmpty(); + }, + "no-transaction" + ); + } ); + } - expect( variables.instanceReadyCount.get() ).toBe( serialInstanceCount ); - }, - "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(); - 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" - ); + expect( request.parallelLifecyclePostLoads ).toHaveLength( 1 ); + }, + "no-transaction" + ); - it( - "uses the application-wide worker limit across builders", - function() { - if ( !supportsParallelEagerLoadingForTest() ) { - return; + it( + "uses the application-wide worker limit across builders", + function() { + if ( !supportsParallelEagerLoadingForTest() ) { + return; + } + var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + var acquired = 0; + var availablePermits = coordinator.availablePermits(); + for ( var i = 1; i <= availablePermits; i++ ) { + if ( coordinator.acquire( 1 ) ) { + acquired++; } - var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); - var acquired = 0; - try { - var availablePermits = coordinator.availablePermits(); - for ( var i = 1; i <= availablePermits; i++ ) { - if ( coordinator.acquire( 1 ) ) { - acquired++; - } - } + } - var builder = getInstance( "Post" ).with( [ "author", "comments" ], true ); - builder.set_parallelEagerLoadingTimeout( 5 ); - expect( function() { - builder.get(); - } ).toThrow( type = "QuickParallelEagerLoadingTimeout", regex = "5 milliseconds" ); - } finally { - for ( var permit = 1; permit <= acquired; permit++ ) { - coordinator.release(); - } - } - }, - "no-transaction" - ); + var builder = getInstance( "Post" ).with( [ "author", "comments" ], true ); + builder.set_parallelEagerLoadingTimeout( 5 ); + expect( function() { + builder.get(); + } ).toThrow( type = "QuickParallelEagerLoadingTimeout", regex = "5 milliseconds" ); + for ( var permit = 1; permit <= acquired; permit++ ) { + coordinator.release(); + } + expect( coordinator.availablePermits() ).toBe( availablePermits ); + }, + "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" + } ); - 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(); - 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(); + } ); + } ); + } - 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() { @@ -615,6 +645,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" ) @@ -763,6 +801,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" ) @@ -972,6 +1018,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 ); @@ -1021,232 +1075,247 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( category.getParent() ).toBeInstanceOf( "Category" ); expect( category.getParent().isLoaded() ).toBeFalse(); } ); + } ); + } - 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\." - ); - } ); + private void function registerLazyLoadingTests() { + describe( "handling lazy loading", () => { + beforeEach( function( currentSpec ) { + setupEagerLoadingTestState( arguments.currentSpec ); + } ); - 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 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 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 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 ]; + 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" ); + } } ); } ); } @@ -1272,16 +1341,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { && !variables.maxActiveWorkers.compareAndSet( variables.maxActiveWorkers.get(), activeWorkers ) ) { } - try { - if ( variables.parallelWorkerDelay > 0 ) { - sleep( variables.parallelWorkerDelay ); - } - if ( variables.failParallelWorker ) { - throw( type = "ExpectedParallelFailure", message = "worker failed" ); - } - } finally { + if ( variables.parallelWorkerDelay > 0 ) { + sleep( variables.parallelWorkerDelay ); + } + if ( variables.failParallelWorker ) { variables.activeWorkers.decrementAndGet(); + throw( type = "ExpectedParallelFailure", message = "worker failed" ); } + variables.activeWorkers.decrementAndGet(); } } @@ -1297,6 +1364,21 @@ component extends="tests.resources.ModuleIntegrationSpec" { } } + 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.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(); + structDelete( request, "parallelLifecyclePostLoads" ); + structDelete( request, "trackParallelScopeThreads" ); + structDelete( request, "parallelScopeThreads" ); + } + private array function extractBindingTypes( required struct queryLogEntry ) { return arguments.queryLogEntry.bindings .filter( function( binding ) { From 14402f0cd4f24db3be8d96e2eb2aeb5a72bf7933 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 27 Aug 2026 17:05:29 -0600 Subject: [PATCH 23/25] Use ColdBox executor for parallel eager loading --- ModuleConfig.cfc | 14 ++ models/ParallelEagerLoadingCoordinator.cfc | 51 +++-- models/ParallelEagerLoadingTask.cfc | 51 +++++ models/QuickBuilder.cfc | 211 +++++++++--------- models/Relationships/PolymorphicBelongsTo.cfc | 29 ++- .../Relationships/EagerLoadingSpec.cfc | 44 ++-- 6 files changed, 245 insertions(+), 155 deletions(-) create mode 100644 models/ParallelEagerLoadingTask.cfc diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 8e8b06a1..ae83ba78 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -56,6 +56,15 @@ component { } function onLoad() { + wirebox + .getInstance( "AsyncManager@coldbox" ) + .newExecutor( + name = "quick-parallel-eager-loading", + type = "fixed", + threads = max( 1, int( settings.parallelEagerLoadingMaxThreads ) ), + loadAppContext = true + ); + binder .map( alias = "QuickQB@quick", force = true ) .to( "#moduleMapping#.models.QuickQB" ) @@ -88,6 +97,11 @@ component { } function onUnload() { + var asyncManager = wirebox.getInstance( "AsyncManager@coldbox" ); + if ( asyncManager.hasExecutor( "quick-parallel-eager-loading" ) ) { + asyncManager.deleteExecutor( "quick-parallel-eager-loading" ); + } + var cacheBox = wirebox.getCachebox(); if ( cacheBox.cacheExists( settings.metadataCache.name ) ) { cacheBox.getCache( settings.metadataCache.name ).clearAll(); diff --git a/models/ParallelEagerLoadingCoordinator.cfc b/models/ParallelEagerLoadingCoordinator.cfc index 6c3b26c9..15c738b1 100644 --- a/models/ParallelEagerLoadingCoordinator.cfc +++ b/models/ParallelEagerLoadingCoordinator.cfc @@ -1,24 +1,38 @@ /** - * Provides application-wide admission control for parallel eager-loading work. + * Coordinates parallel eager-loading work on Quick's application-wide executor. */ component singleton { - property - name ="maxWorkers" - default="4" - inject ="box:setting:parallelEagerLoadingMaxThreads@quick"; + property name="executor" inject="executor:quick-parallel-eager-loading"; + property name="controller" inject="coldbox"; + property name="requestService" inject="coldbox:requestService"; function init() { - param variables.maxWorkers = 4; + variables.currentWorker = createObject( "java", "java.lang.ThreadLocal" ).init(); return this; } - function onDIComplete() { - variables.semaphore = createObject( "java", "java.util.concurrent.Semaphore" ).init( - javacast( "int", max( 1, int( variables.maxWorkers ) ) ), - javacast( "boolean", true ) + public any function submit( required any task ) { + return variables.executor.submit( arguments.task, "run" ); + } + + public any function getExecutor() { + return variables.executor; + } + + public any function createWorkerRequestContext() { + var sourceContext = variables.requestService.getContext(); + var workerContext = createObject( "component", "coldbox.system.web.context.RequestContext" ).init( + properties = variables.controller.getConfigSettings(), + controller = variables.controller ); - variables.currentWorker = createObject( "java", "java.lang.ThreadLocal" ).init(); + workerContext.collectionAppend( structCopy( sourceContext.getCollection() ), true ); + workerContext.collectionAppend( + structCopy( sourceContext.getPrivateCollection() ), + true, + true + ); + return workerContext; } public void function enterWorker( required string name ) { @@ -38,19 +52,4 @@ component singleton { return isNull( workerName ) ? "" : workerName; } - public boolean function acquire( required numeric timeout ) { - return variables.semaphore.tryAcquire( - javacast( "long", arguments.timeout ), - createObject( "java", "java.util.concurrent.TimeUnit" ).MILLISECONDS - ); - } - - public void function release() { - variables.semaphore.release(); - } - - public numeric function availablePermits() { - return variables.semaphore.availablePermits(); - } - } diff --git a/models/ParallelEagerLoadingTask.cfc b/models/ParallelEagerLoadingTask.cfc new file mode 100644 index 00000000..768719fe --- /dev/null +++ b/models/ParallelEagerLoadingTask.cfc @@ -0,0 +1,51 @@ +/** + * Retrieves the database rows for one prepared eager-loading relationship. + * Relationship preparation and entity hydration remain on the calling thread. + */ +component { + + function init( + required struct plan, + required string name, + required any coordinator, + required any requestContext, + required any completionQueue + ) { + variables.plan = arguments.plan; + variables.name = arguments.name; + variables.coordinator = arguments.coordinator; + variables.requestContext = arguments.requestContext; + variables.completionQueue = arguments.completionQueue; + return this; + } + + public void function run() { + var requestContextInstalled = false; + var workerEntered = false; + try { + request.cb_requestContext = variables.requestContext; + requestContextInstalled = true; + variables.coordinator.enterWorker( variables.name ); + workerEntered = true; + variables.completionQueue.offer( { + "name" : variables.name, + "success" : true, + "rows" : variables.plan.relation.retrieveEagerRows() + } ); + } 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 1e0c5702..b5875c0d 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -84,7 +84,7 @@ component accessors="true" transientCache="false" { inject ="box:setting:parallelEagerLoadingTimeout@quick"; /** - * Application-wide admission control for parallel eager-loading queries. + * Application-wide coordinator for parallel eager-loading queries. */ property name="_parallelEagerLoadingCoordinator" inject="quick.models.ParallelEagerLoadingCoordinator"; @@ -906,14 +906,13 @@ component accessors="true" transientCache="false" { } /** - * Eager loads independent top-level relationships on separate threads. + * 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 = max( 1, int( variables._parallelEagerLoadingMaxThreads ) ); var timeout = max( 1, int( variables._parallelEagerLoadingTimeout ) ); var targetEntities = arguments.entities; - var threadResults = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); var plans = []; // Relationship resolution, user callbacks, and constraint construction may @@ -929,92 +928,113 @@ component accessors="true" transientCache="false" { } for ( var batchStart = 1; batchStart <= plans.len(); batchStart += maxWorkers ) { - var batchThreadNames = []; - var batchThreadPlans = {}; - var batchEnd = min( plans.len(), batchStart + maxWorkers - 1 ); + 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 threadName = "quick_eager_#replace( createUUID(), "-", "", "all" )#"; - batchThreadNames.append( threadName ); - batchThreadPlans[ threadName ] = plan; - cfthread( - action = "run", - name = threadName, - threadName = threadName, - plan = plan, - results = threadResults, - workerTimeout = timeout - ) { - var acquiredPermit = false; - variables._parallelEagerLoadingCoordinator.enterWorker( threadName ); - try { - if ( plan.hasMatches ) { - acquiredPermit = variables._parallelEagerLoadingCoordinator.acquire( workerTimeout ); - if ( !acquiredPermit ) { - throw( - type = "QuickParallelEagerLoadingTimeout", - message = "Parallel eager loading could not acquire a worker within #workerTimeout# milliseconds." - ); - } - results.put( threadName, plan.relation.retrieveEagerRows() ); - } else { - results.put( threadName, [] ); - } - } finally { - if ( acquiredPermit ) { - variables._parallelEagerLoadingCoordinator.release(); - } - variables._parallelEagerLoadingCoordinator.leaveWorker(); - } + 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(), + 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 + } ); } - cfthread( - action = "join", - name = batchThreadNames.toList(), - timeout = timeout - ); - - var failedThread = ""; - var timedOut = false; - for ( var batchThreadName in batchThreadNames ) { - if ( cfthread[ batchThreadName ].status == "TERMINATED" && failedThread == "" ) { - failedThread = batchThreadName; - } - if ( - cfthread[ batchThreadName ].status != "COMPLETED" && cfthread[ batchThreadName ].status != "TERMINATED" - ) { - timedOut = true; - } + var batchResults = awaitParallelEagerLoadingTasks( batchTasks, completionQueue, timeout ); + for ( var completedTask in batchTasks ) { + finalizeParallelEagerLoad( + completedTask.plan, + batchResults[ completedTask.name ], + targetEntities + ); } - if ( failedThread != "" || timedOut ) { - terminateAndJoinParallelEagerLoadingThreads( batchThreadNames, timeout ); + } + } + + 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." + ); } - if ( failedThread != "" ) { - var threadError = cfthread[ failedThread ].error; - var errorType = threadError.keyExists( "type" ) && threadError.type == "QuickParallelEagerLoadingTimeout" - ? "QuickParallelEagerLoadingTimeout" - : "QuickParallelEagerLoadingException"; + + 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 = errorType, - message = threadError.keyExists( "message" ) ? threadError.message : "A parallel eager-loading thread failed.", - extendedInfo = serializeJSON( threadError ) + type = "QuickParallelEagerLoadingCancellationException", + message = "Parallel eager loading was interrupted while waiting for its workers." ); } - if ( timedOut ) { + + if ( isNull( completion ) ) { + cancelParallelEagerLoadingTasks( arguments.tasks ); throw( type = "QuickParallelEagerLoadingTimeout", - message = "Parallel eager loading did not complete within #timeout# milliseconds." + message = "Parallel eager loading did not complete within #arguments.timeout# milliseconds." ); } - - for ( var completedThreadName in batchThreadNames ) { - finalizeParallelEagerLoad( - batchThreadPlans[ completedThreadName ], - threadResults.get( completedThreadName ), - targetEntities + 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.rows; + } + + return results; + } + + private void function cancelParallelEagerLoadingTasks( required array tasks ) { + for ( var task in arguments.tasks ) { + if ( !task.future.isDone() ) { + task.future.cancel( true ); + } } } @@ -1035,6 +1055,7 @@ component accessors="true" transientCache="false" { relation.initRelation( arguments.entities, arguments.relationName ); if ( hasMatches ) { relation.prepareEagerQuery( variables._asQuery, variables._withAliases ); + applyDefaultDatasourceToParallelEagerLoad( relation ); } return { "hasMatches" : hasMatches, @@ -1043,6 +1064,19 @@ component accessors="true" transientCache="false" { }; } + 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 array rows, @@ -1061,35 +1095,8 @@ component accessors="true" transientCache="false" { } } - private void function terminateAndJoinParallelEagerLoadingThreads( - required array threadNames, - required numeric timeout - ) { - for ( var threadName in arguments.threadNames ) { - if ( cfthread[ threadName ].status != "COMPLETED" && cfthread[ threadName ].status != "TERMINATED" ) { - cfthread( action = "terminate", name = threadName ); - } - } - cfthread( - action = "join", - name = arguments.threadNames.toList(), - timeout = arguments.timeout - ); - for ( var joinedThreadName in arguments.threadNames ) { - if ( - cfthread[ joinedThreadName ].status != "COMPLETED" - && cfthread[ joinedThreadName ].status != "TERMINATED" - ) { - throw( - type = "QuickParallelEagerLoadingCancellationException", - message = "Parallel eager-loading worker [#joinedThreadName#] remained active after cancellation." - ); - } - } - } - /** - * Adobe ColdFusion loses CFC private-method resolution inside cfthread. + * Adobe ColdFusion does not yet support parallel eager-loading execution. */ private boolean function supportsParallelEagerLoading() { return !server.keyExists( "coldfusion" ) || !findNoCase( "ColdFusion", server.coldfusion.productName ); diff --git a/models/Relationships/PolymorphicBelongsTo.cfc b/models/Relationships/PolymorphicBelongsTo.cfc index de512d4b..e41b3373 100644 --- a/models/Relationships/PolymorphicBelongsTo.cfc +++ b/models/Relationships/PolymorphicBelongsTo.cfc @@ -161,20 +161,35 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { 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" : prepareResultsQueryByType( - type, - morphParent, - arguments.asQuery, - arguments.withAliases - ), - "type" : type + "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. * diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index 9aea10c8..3a51a0cf 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -117,6 +117,9 @@ component extends="tests.resources.ModuleIntegrationSpec" { 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" ); @@ -289,7 +292,6 @@ component extends="tests.resources.ModuleIntegrationSpec" { function() { if ( supportsParallelEagerLoadingForTest() ) { var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); - var availablePermitsBeforeRun = coordinator.availablePermits(); variables.parallelWorkerDelay = 100; var builder = getInstance( "Post" ).with( [ "author", "comments" ], true ); builder.set_parallelEagerLoadingTimeout( 1 ); @@ -297,7 +299,11 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( function() { builder.get(); } ).toThrow( type = "QuickParallelEagerLoadingTimeout", regex = "1 milliseconds" ); - expect( coordinator.availablePermits() ).toBe( availablePermitsBeforeRun ); + + variables.parallelWorkerDelay = 0; + var posts = getInstance( "Post" ).with( [ "author", "comments" ], true ).get(); + expect( posts ).notToBeEmpty(); + expect( coordinator.getExecutor().getMaximumPoolSize() ).toBeGT( 0 ); } }, "no-transaction" @@ -436,29 +442,22 @@ component extends="tests.resources.ModuleIntegrationSpec" { ); it( - "uses the application-wide worker limit across builders", + "uses an application-wide fixed worker pool", function() { if ( !supportsParallelEagerLoadingForTest() ) { return; } - var coordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); - var acquired = 0; - var availablePermits = coordinator.availablePermits(); - for ( var i = 1; i <= availablePermits; i++ ) { - if ( coordinator.acquire( 1 ) ) { - acquired++; - } - } + var firstCoordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + var secondCoordinator = getInstance( "quick.models.ParallelEagerLoadingCoordinator" ); + var executor = firstCoordinator.getExecutor(); + var submissionsBefore = executor.getTaskSubmissionCount(); - var builder = getInstance( "Post" ).with( [ "author", "comments" ], true ); - builder.set_parallelEagerLoadingTimeout( 5 ); - expect( function() { - builder.get(); - } ).toThrow( type = "QuickParallelEagerLoadingTimeout", regex = "5 milliseconds" ); - for ( var permit = 1; permit <= acquired; permit++ ) { - coordinator.release(); - } - expect( coordinator.availablePermits() ).toBe( availablePermits ); + expect( firstCoordinator ).toBe( secondCoordinator ); + expect( executor.getMaximumPoolSize() ).toBe( 4 ); + 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" ); @@ -1335,6 +1334,10 @@ component extends="tests.resources.ModuleIntegrationSpec" { if ( coordinator.isWorker() ) { var threadName = coordinator.getWorkerName(); variables.workerQueryThreads.put( threadName, true ); + variables.workerRequestContexts.put( + createObject( "java", "java.lang.System" ).identityHashCode( arguments.event ), + true + ); var activeWorkers = variables.activeWorkers.incrementAndGet(); while ( activeWorkers > variables.maxActiveWorkers.get() @@ -1368,6 +1371,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { request.quickSkipDatabaseTransactions = specHasLabel( arguments.currentSpec, "no-transaction" ); variables.queries = []; variables.workerQueryThreads = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).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; From eb3a1b1ee5500799be383b0c6965a64a678d7790 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 27 Aug 2026 17:10:46 -0600 Subject: [PATCH 24/25] Track parallel worker contexts explicitly --- models/ParallelEagerLoadingCoordinator.cfc | 3 ++- models/QuickBuilder.cfc | 2 +- .../integration/BaseEntity/Relationships/EagerLoadingSpec.cfc | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/models/ParallelEagerLoadingCoordinator.cfc b/models/ParallelEagerLoadingCoordinator.cfc index 15c738b1..4ea2a860 100644 --- a/models/ParallelEagerLoadingCoordinator.cfc +++ b/models/ParallelEagerLoadingCoordinator.cfc @@ -20,7 +20,7 @@ component singleton { return variables.executor; } - public any function createWorkerRequestContext() { + 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(), @@ -32,6 +32,7 @@ component singleton { true, true ); + workerContext.setPrivateValue( "__quickParallelWorkerContextId", arguments.workerName ); return workerContext; } diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index b5875c0d..0ae1d19b 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -942,7 +942,7 @@ component accessors="true" transientCache="false" { plan, taskName, variables._parallelEagerLoadingCoordinator, - variables._parallelEagerLoadingCoordinator.createWorkerRequestContext(), + variables._parallelEagerLoadingCoordinator.createWorkerRequestContext( taskName ), completionQueue ); try { diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index 3a51a0cf..700b1601 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -1335,7 +1335,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { var threadName = coordinator.getWorkerName(); variables.workerQueryThreads.put( threadName, true ); variables.workerRequestContexts.put( - createObject( "java", "java.lang.System" ).identityHashCode( arguments.event ), + arguments.event.getPrivateValue( "__quickParallelWorkerContextId" ), true ); var activeWorkers = variables.activeWorkers.incrementAndGet(); From 8d1092a7b696c50506b966023e6bb0d156cc8c10 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 27 Aug 2026 17:22:54 -0600 Subject: [PATCH 25/25] Isolate BoxLang parallel worker requests --- models/ParallelEagerLoadingTask.cfc | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/models/ParallelEagerLoadingTask.cfc b/models/ParallelEagerLoadingTask.cfc index 768719fe..d523f9a8 100644 --- a/models/ParallelEagerLoadingTask.cfc +++ b/models/ParallelEagerLoadingTask.cfc @@ -20,6 +20,22 @@ component { } 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() { + execute(); + } + ); + return; + } + execute(); + } + + private void function execute() { var requestContextInstalled = false; var workerEntered = false; try {