From 2b98322418dc4dbf8e93c9e1f34a8d8c44ffe4f1 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 06:06:54 -0600 Subject: [PATCH 1/3] Add optional parallel eager loading Closes #55 --- models/QuickBuilder.cfc | 76 ++++++++++++++++--- .../Relationships/EagerLoadingSpec.cfc | 34 +++++++++ 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 3e0d798e..04ef207d 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -89,6 +89,7 @@ component accessors="true" transientCache="false" { function init() { variables._eagerLoad = []; + variables._parallelEagerLoading = false; variables._globalScopesApplied = false; variables._globalScopeExcludeAll = false; variables._asMemento = false; @@ -528,9 +529,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; } @@ -540,6 +543,7 @@ component accessors="true" transientCache="false" { arrayWrap( arguments.relationName ), true ); + variables._parallelEagerLoading = variables._parallelEagerLoading || arguments.parallel; return this; } @@ -575,17 +579,71 @@ component accessors="true" transientCache="false" { } } - structEach( denestEagerLoads( variables._eagerLoad ), function( relationName, nestedEagerLoads ) { - entities = eagerLoadRelation( - relationName, - nestedEagerLoads, - entities - ); - } ); + var eagerLoads = denestEagerLoads( variables._eagerLoad ); + if ( variables._parallelEagerLoading && eagerLoads.count() > 1 ) { + eagerLoadRelationsInParallel( eagerLoads, arguments.entities ); + } else { + structEach( eagerLoads, function( relationName, nestedEagerLoads ) { + entities = eagerLoadRelation( + relationName, + nestedEagerLoads, + 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") @@ -731,7 +789,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 f9024786..4f368326 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -34,6 +34,40 @@ component extends="tests.resources.ModuleIntegrationSpec" { } } ); + 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 af7972c772f7fc42266772da3604dd591a784e50 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 08:20:18 -0600 Subject: [PATCH 2/3] 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 04ef207d..c441e6d3 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -580,7 +580,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 { structEach( eagerLoads, function( relationName, nestedEagerLoads ) { @@ -599,20 +599,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 @@ -630,9 +635,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" ) { @@ -641,9 +646,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 4f368326..73f30106 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -63,9 +63,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 feae4e242810c51ea5acdc333c220e526af97b81 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 08:30:25 -0600 Subject: [PATCH 3/3] 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 c441e6d3..b18ffd10 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -604,10 +604,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( @@ -615,7 +612,7 @@ component accessors="true" transientCache="false" { name = threadName, relationName = relationName, eagerLoadConfig = arguments.eagerLoads[ relationName ], - entities = threadEntities + entities = targetEntities ) { thread.entities = eagerLoadRelation( attributes.relationName,