Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace HotChocolate.Fusion.Planning;

/// <summary>
/// Controls how aggressively structurally-identical operations are merged
/// to reduce the number of downstream requests.
/// </summary>
public enum OperationMergePolicy
{
/// <summary>
/// Merge only when canonical signature matches and the operations share the
/// same dependency depth. This avoids any risk of over-serialization by
/// ensuring merged operations were already at equivalent execution levels.
/// </summary>
Conservative = 0,

/// <summary>
/// Merge when canonical signature matches and cycle-safe, but reject merges
/// where the depth difference between candidates exceeds a single level.
/// This provides a middle ground between request reduction and serialization risk.
/// </summary>
Balanced = 1,

/// <summary>
/// Merge whenever canonical signature matches and cycle-safe, regardless of
/// depth or dependency differences. This maximizes request-count reduction.
/// </summary>
Aggressive = 2
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ private OperationPlan BuildExecutionPlan(
planSteps = TransformPlanSteps(planSteps, operationDefinition);
IndexDependencies(planSteps, ctx);
BuildExecutionNodes(planSteps, ctx, _schema, hasVariables);
MergeAndBatchOperations(ctx, _options.EnableRequestGrouping);
MergeAndBatchOperations(ctx, _options.EnableRequestGrouping, _options.MergePolicy);
WireExecutionDependencies(ctx);

var rootNodes = planSteps
Expand Down Expand Up @@ -347,10 +347,11 @@ private static OperationExecutionNode CreateOperationExecutionNode(

private static void MergeAndBatchOperations(
ExecutionPlanBuildContext ctx,
bool enableRequestGrouping)
bool enableRequestGrouping,
OperationMergePolicy mergePolicy)
{
var nodeFieldBoundCache = new Dictionary<int, bool>();
var mergeResults = MergeStructurallyIdenticalOperations(ctx, nodeFieldBoundCache);
var mergeResults = MergeStructurallyIdenticalOperations(ctx, nodeFieldBoundCache, mergePolicy);

// Capture each node's dependency identifiers now, because the batching
// step below will rewrite the dependency lookup as it merges nodes.
Expand All @@ -375,7 +376,8 @@ private static void MergeAndBatchOperations(
/// </summary>
private static Dictionary<int, MergeResult> MergeStructurallyIdenticalOperations(
ExecutionPlanBuildContext ctx,
Dictionary<int, bool> nodeFieldBoundCache)
Dictionary<int, bool> nodeFieldBoundCache,
OperationMergePolicy mergePolicy)
{
var candidates = new Dictionary<string, List<OperationExecutionNode>>(StringComparer.Ordinal);

Expand Down Expand Up @@ -411,7 +413,8 @@ private static Dictionary<int, MergeResult> MergeStructurallyIdenticalOperations
continue;
}

foreach (var group in PartitionIntoMergeableGroups(equivalentNodes, ctx.DependenciesByStepId))
foreach (var group in PartitionIntoMergeableGroups(
equivalentNodes, ctx.DependenciesByStepId, mergePolicy))
{
if (group.Count <= 1)
{
Expand Down Expand Up @@ -1154,12 +1157,30 @@ private static string ApplyPrefixReplacements(
/// Partitions structurally identical operations into groups that can
/// each be safely merged. Two operations cannot share a group if one
/// transitively depends on the other, because merging them would
/// create a cycle in the dependency graph.
/// create a cycle in the dependency graph. The <paramref name="mergePolicy"/>
/// further restricts which candidates may share a group based on their
/// dependency depth.
/// </summary>
private static List<List<OperationExecutionNode>> PartitionIntoMergeableGroups(
List<OperationExecutionNode> candidates,
Dictionary<int, HashSet<int>> dependenciesByStepId)
Dictionary<int, HashSet<int>> dependenciesByStepId,
OperationMergePolicy mergePolicy)
{
// Pre-compute dependency depths when the policy needs them.
Dictionary<int, int>? depthLookup = null;

if (mergePolicy is OperationMergePolicy.Conservative
or OperationMergePolicy.Balanced)
{
depthLookup = [];
var recursionStack = new HashSet<int>();

foreach (var candidate in candidates)
{
GetDependencyDepth(candidate.Id, dependenciesByStepId, depthLookup, recursionStack);
}
}

var groups = new List<List<OperationExecutionNode>>();
var visited = new HashSet<int>();

Expand All @@ -1171,22 +1192,46 @@ private static List<List<OperationExecutionNode>> PartitionIntoMergeableGroups(
{
var canJoin = true;

foreach (var existing in group)
// Policy-specific depth checks (applied before the more
// expensive transitive-reachability walk).
if (depthLookup is not null)
{
visited.Clear();
var candidateDepth = depthLookup[candidate.Id];
var referenceDepth = depthLookup[group[0].Id];

if (IsTransitivelyReachable(candidate.Id, existing.Id, dependenciesByStepId, visited))
switch (mergePolicy)
{
canJoin = false;
break;
}
case OperationMergePolicy.Conservative
when candidateDepth != referenceDepth:
canJoin = false;
break;

visited.Clear();
case OperationMergePolicy.Balanced
when Math.Abs(candidateDepth - referenceDepth) > 1:
canJoin = false;
break;
}
}

if (IsTransitivelyReachable(existing.Id, candidate.Id, dependenciesByStepId, visited))
if (canJoin)
{
foreach (var existing in group)
{
canJoin = false;
break;
visited.Clear();

if (IsTransitivelyReachable(candidate.Id, existing.Id, dependenciesByStepId, visited))
{
canJoin = false;
break;
}

visited.Clear();

if (IsTransitivelyReachable(existing.Id, candidate.Id, dependenciesByStepId, visited))
{
canJoin = false;
break;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ public bool EnableRequestGrouping
}
} = true;

/// <summary>
/// Gets or sets how aggressively structurally-identical operations are merged
/// to reduce downstream request count. Cycle safety is always enforced regardless
/// of this setting.
/// </summary>
public OperationMergePolicy MergePolicy
{
get;
set
{
ExpectMutableOptions();
field = value;
}
} = OperationMergePolicy.Aggressive;

/// <summary>
/// Gets or sets the weight applied for each operation beyond the fan-out penalty threshold.
/// </summary>
Expand Down
Loading
Loading