Skip to content

feat: compute dynamic (actual) cost#1376

Merged
ysmolski merged 20 commits intomasterfrom
yury/eng-8843-engine-compute-dynamic-actual-cost
Feb 19, 2026
Merged

feat: compute dynamic (actual) cost#1376
ysmolski merged 20 commits intomasterfrom
yury/eng-8843-engine-compute-dynamic-actual-cost

Conversation

@ysmolski
Copy link
Copy Markdown
Contributor

@ysmolski ysmolski commented Feb 5, 2026

This PR implements actual cost calculation for GraphQL operations alongside
the existing estimated cost. The actual cost is computed after query execution
using real response data (list sizes, field presence) instead of estimated multipliers
from slicing arguments.

The implementation consolidates estimated and actual cost logic into a unified cost()
method that uses float64 multipliers for actual costs.

Summary by CodeRabbit

  • Refactor

    • Unified cost subsystem: replaced legacy static-cost path with an estimated-cost flow and updated planner/visitor wiring.
  • New Features

    • Added actual-cost computation using observed per-field list sizes and improved cost-tree debug output with per-node paths.
  • Bug Fixes

    • Improved synchronous response handling (errors surfaced immediately) and clarified unknown-operation error text; cached plan cost selection updated to use the new cost API.
  • Tests

    • Expanded tests validating estimated vs actual costs across varied list sizes, nesting, rounding and edge cases.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

Replaces static-cost APIs with estimated and actual cost computation: renames ComputeStaticCost→ComputeEstimatedCost/EstimateCost and Static→Estimated, threads actualListSizes from resolver into cost trees, computes ActualCost after resolution, and updates planner/visitor and plan public APIs accordingly.

Changes

Cohort / File(s) Summary
Execution & Request wiring
execution/engine/execution_engine.go, execution/graphql/request.go
Switches from static→estimated/actual cost: ComputeStaticCostComputeEstimatedCost/EstimateCost, staticCostestimatedCost, adds actualCost and ComputeActualCost; execution engine captures resolver ActualListSizes, computes actual cost post-resolution, and updates cached cost-calculator access to GetCostCalculator.
Tests
execution/engine/execution_engine_test.go
Test structs and helpers renamed/extended: expectedStaticCostexpectedEstimatedCost, added expectedActualCost; computeStaticCost()computeCosts(); assertions updated to check EstimatedCost and ActualCost.
Plan API & Planner
v2/pkg/engine/plan/plan.go, v2/pkg/engine/plan/planner.go, v2/pkg/engine/plan/configuration.go
Public API renames for plan and config: GetStaticCostCalculator/SetStaticCostCalculatorGetCostCalculator/SetCostCalculator; config flag ComputeStaticCostComputeCosts; planner now wires CostVisitor when enabled.
Cost core & visitor
v2/pkg/engine/plan/cost.go, v2/pkg/engine/plan/cost_visitor.go
Reworks cost model to support estimation and actual modes: adds EstimateCost and ActualCost, threads actualListSizes through computations, introduces jsonPath on cost nodes, changes multipliers to float rounding/flooring, updates debug printing; renames StaticCostVisitorCostVisitor.
Resolver: list-size propagation
v2/pkg/engine/resolve/resolvable.go, v2/pkg/engine/resolve/resolve.go
Adds actualListSizes to Resolvable, records per-field list sizes during resolution, exposes ActualListSizes on GraphQLResolveInfo so actual costs can be computed after response construction.
Visitor internals & state
v2/pkg/engine/plan/visitor.go
Adds exported NewVisitor constructor, updates AllowVisitor to accept CostVisitor, and adjusts visitor initialization/state lifecycle.
Minor / formatting
execution/engine/..., assorted files
Small message/text changes and consistent renames from static→cost/estimated across other files; formatting/alignment tweaks.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant Engine as ExecutionEngine
  participant Planner as Planner
  participant Resolver as Resolver
  participant Calc as CostCalculator

  Client->>Engine: Send GraphQL request
  Engine->>Planner: Build plan (attach CostCalculator if ComputeCosts)
  Planner->>Calc: EstimateCost (build estimated cost tree)
  Engine->>Resolver: Execute plan / resolve response
  Resolver->>Resolver: Record ActualListSizes per-field
  Resolver-->>Engine: Return response + ActualListSizes
  Engine->>Calc: ActualCost(config, ActualListSizes)
  Engine-->>Client: Return response with estimatedCost and actualCost
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • feat: compute static costs #1359 — Evolves the static-cost API that this change renames/extends into an estimated/actual-cost flow; touches planner/visitor, cost calculator, and execution/request wiring.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: compute dynamic (actual) cost' clearly and concisely describes the main objective of the PR, which is to implement actual cost computation for GraphQL operations alongside estimated cost.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch yury/eng-8843-engine-compute-dynamic-actual-cost

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@execution/graphql/request.go`:
- Around line 213-220: The ComputeActualCost method currently sets r.actualCost
= 0 when actualListSizes is nil; instead, if calc != nil you should call
calc.ActualCost with an empty map when actualListSizes == nil so non-list costs
are still computed. Update Request.ComputeActualCost to build a zero-length
map[string]int (or equivalent) and pass that to
plan.CostCalculator.ActualCost(config, variables, actualListSizes) when the
incoming actualListSizes is nil; keep the existing behavior of setting
r.actualCost = 0 only when calc is nil.
🧹 Nitpick comments (1)
execution/engine/execution_engine_test.go (1)

339-347: Zero-cost expectations currently won’t be asserted.
The != 0 guards skip validation for legitimate zero-cost cases (e.g., negative weights). Consider a *int or explicit hasExpectedCost flag so zero can be asserted.

Comment thread execution/graphql/request.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
execution/engine/execution_engine_test.go (1)

203-217: ⚠️ Potential issue | 🟡 Minor

Allow asserting zero expected costs.

The != 0 guards skip legitimate zero expectations (e.g., the negative-weights test), so those checks never run. Consider explicit flags (or pointer fields) to signal “assert even when zero.”

✅ Proposed fix (explicit expectation flags)
 type ExecutionEngineTestCase struct {
 	schema           *graphql.Schema
 	operation        func(t *testing.T) graphql.Request
 	dataSources      []plan.DataSource
 	fields           plan.FieldConfigurations
 	engineOptions    []ExecutionOptions
 	customResolveMap map[string]resolve.CustomResolve
 	skipReason       string
 	indentJSON       bool
 
 	expectedResponse      string
 	expectedJSONResponse  string
 	expectedFixture       string
 	expectedEstimatedCost int
 	expectedActualCost    int
+	expectEstimatedCost   bool
+	expectActualCost      bool
 }
-			if testCase.expectedEstimatedCost != 0 {
+			if testCase.expectedEstimatedCost != 0 || testCase.expectEstimatedCost {
 				gotCost := operation.EstimatedCost()
 				require.Equal(t, testCase.expectedEstimatedCost, gotCost)
 			}
 
-			if testCase.expectedActualCost != 0 {
+			if testCase.expectedActualCost != 0 || testCase.expectActualCost {
 				gotActualCost := operation.ActualCost()
 				require.Equal(t, testCase.expectedActualCost, gotActualCost)
 			}
 			t.Run("negative weights - cost is never negative", runWithoutError(
 				ExecutionEngineTestCase{
 					// ...
 					expectedEstimatedCost: 0,
+					expectEstimatedCost:   true,
 				},
 				computeCosts(),
 			))

Also applies to: 339-347, 5769-5827

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@v2/pkg/engine/resolve/resolve.go`:
- Line 321: Import ordering in the file containing the symbol ActualListSizes is
failing the gci linter; fix it by running gci to auto-reorder the import block
(or manually reorder imports into standard library, blank line, third-party,
blank line, local project imports) so the import groups and spacing match the
project's gci configuration, then stage the updated import block and re-run
tests/linter.
🧹 Nitpick comments (1)
v2/pkg/engine/resolve/resolvable.go (1)

895-904: Consider using a pooled buffer for path construction.

The helper allocates a new slice on each call via append. While acceptable for current usage, if this becomes a hot path, consider using pool.BytesBuffer (already imported via pool package) similar to renderFieldPath().

💡 Optional optimization using pooled buffer
 // Helper to build JSON path (field names only, no array indices)
 func (r *Resolvable) currentFieldPath() string {
-	var parts []string
+	buf := pool.BytesBuffer.Get()
+	defer pool.BytesBuffer.Put(buf)
+	first := true
 	for _, elem := range r.path {
 		if elem.Name != "" {
-			parts = append(parts, elem.Name)
+			if !first {
+				buf.WriteByte('.')
+			}
+			buf.WriteString(elem.Name)
+			first = false
 		}
 	}
-	return strings.Join(parts, ".")
+	return buf.String()
 }

Comment thread v2/pkg/engine/resolve/resolve.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@v2/pkg/engine/resolve/resolve.go`:
- Around line 355-356: The ArenaResolveGraphQLResponse path forgets to propagate
actual list sizes: after calling t.resolvable.Resolve() and before returning the
ArenaResolveGraphQLResponse, set resp.ActualListSizes =
t.resolvable.actualListSizes (same as ResolveGraphQLResponse does) so that
ArenaResolveGraphQLResponse includes the actual list size data for correct cost
calculations; locate the assignment near the return in the
ArenaResolveGraphQLResponse function and add the same assignment referencing
t.resolvable.actualListSizes.

Comment thread v2/pkg/engine/resolve/resolve.go
Comment thread v2/pkg/engine/resolve/resolvable.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@execution/engine/execution_engine_test.go`:
- Around line 347-355: The test currently skips cost assertions when
expectedEstimatedCost or expectedActualCost are zero due to `!= 0` guards, so
change the test to use explicit expectation flags (e.g., add bool fields
`expectEstimatedCost` and `expectActualCost` to the testCase struct) or use
pointers for `expectedEstimatedCost`/`expectedActualCost` so nil means "don't
assert" and zero is assertable; then update the assertion logic to check the
flag/pointer (call operation.EstimatedCost() and operation.ActualCost() when the
corresponding `expect...` is true or the pointer is non-nil) and update all test
case entries (including the negative-weights test) to set the new flags or
provide pointers so zero costs are actually asserted.

In `@v2/pkg/engine/plan/cost.go`:
- Around line 508-520: ActualCost currently computes a non-zero value when
actualListSizes is nil, which can misrepresent a reset/unused actual cost;
update the CostCalculator.ActualCost method to guard for a nil actualListSizes
and return 0 immediately (preserving existing behavior only when actualListSizes
!= nil) before building costConfigs and calling c.tree.cost with actualCostMode
so stale multipliers aren't used.

Comment thread execution/engine/execution_engine_test.go
Comment thread v2/pkg/engine/plan/cost.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
v2/pkg/engine/plan/visitor.go (1)

1083-1085: ⚠️ Potential issue | 🔴 Critical

Reset visitor caches on each Plan() call to prevent stale data when Planner is reused.

When a Planner instance is reused to plan multiple operations (as shown in planner_test.go where sharedPlanner.Plan() is called twice with different operations), the Visitor's cache maps (pathCache, fieldPlanners, plannerFields, fieldConfigs, exportedVariables, skipIncludeOnFragments, indirectInterfaceFields) retain stale data from the previous operation. Since CostVisitor holds a direct reference to fieldPlanners (line 166: p.costVisitor.fieldPlanners = p.planningVisitor.fieldPlanners), clearing in place rather than creating new maps is necessary to preserve this reference.

🛠️ Suggested fix (clear maps in place)
+func (v *Visitor) resetPerDocument() {
+	// slices
+	v.objects = v.objects[:0]
+	v.currentFields = v.currentFields[:0]
+	v.currentField = nil
+
+	// maps (clear in place to keep shared references)
+	for k := range v.fieldConfigs { delete(v.fieldConfigs, k) }
+	for k := range v.exportedVariables { delete(v.exportedVariables, k) }
+	for k := range v.skipIncludeOnFragments { delete(v.skipIncludeOnFragments, k) }
+	for k := range v.indirectInterfaceFields { delete(v.indirectInterfaceFields, k) }
+	for k := range v.pathCache { delete(v.pathCache, k) }
+	for k := range v.plannerFields { delete(v.plannerFields, k) }
+	for k := range v.fieldPlanners { delete(v.fieldPlanners, k) }
+	for k := range v.fieldEnclosingTypeNames { delete(v.fieldEnclosingTypeNames, k) }
+}
+
 func (v *Visitor) EnterDocument(operation, definition *ast.Document) {
+	v.resetPerDocument()
 	v.Operation, v.Definition = operation, definition
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/plan/visitor.go` around lines 1083 - 1085,
Visitor.EnterDocument currently only sets v.Operation and v.Definition but does
not clear the Visitor's cache maps, causing stale state when a Planner is
reused; in EnterDocument clear the maps in place (not by assigning new map
objects) for pathCache, fieldPlanners, plannerFields, fieldConfigs,
exportedVariables, skipIncludeOnFragments, and indirectInterfaceFields so
existing references (notably p.costVisitor.fieldPlanners which points to
v.fieldPlanners) remain valid and see the cleared state before planning the new
operation.
v2/pkg/engine/plan/cost.go (1)

257-286: ⚠️ Potential issue | 🟠 Major

Actual‑cost rounding at each node can undercount sparse nested lists.

Because actual mode uses average multipliers (<1), rounding inside each node before parent scaling can zero out non‑zero totals (e.g., 1 item across 3 parents → avg 0.33 → rounds to 0, then parent multiplies → 0). Consider carrying float costs through actual‑mode recursion and rounding once at the root (or deferring rounding until after parent scaling).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/plan/cost.go` around lines 257 - 286, The current per-node
rounding in the cost calculation (in the block using node.costsAndMultiplier,
childrenCost loop, multiplier handling and final cost +=
int(math.RoundToEven(...))) causes undercounting for actual-mode average
multipliers; modify the cost computation to propagate float64 costs through
recursion: change local cost/childrenCost/fieldCost/argsCost/directivesCost to
float64 (or keep a float64 accumulator), compute cost += (childrenCost +
fieldCost) * multiplier as a float without calling math.RoundToEven at each
node, and only round/convert to int at the top-level root return point (or in
the public Cost() wrapper) so rounding is deferred until after parent scaling;
ensure multiplier enforcement for non-list fields remains (set multiplier=1 as
float64) and keep the non-negative clamp after final rounding.
🧹 Nitpick comments (2)
v2/pkg/engine/plan/cost_visitor.go (1)

22-23: Align the comment with the non-pointer field.
The field is now a map value, so “Pointer” is misleading.

Suggested comment tweak
- // Pointer to Visitor.fieldPlanners. We capture this early before this map is actually filled.
+ // Reference to Visitor.fieldPlanners. We capture this early before this map is actually filled.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/plan/cost_visitor.go` around lines 22 - 23, The comment for the
fieldPlanners map is misleading because it says "Pointer" while the field is a
map value; update the comment for the Visitor.fieldPlanners declaration to
reflect that it's a map value captured early (e.g., "Map to
Visitor.fieldPlanners. We capture this early before this map is actually
filled.") and ensure it references the field name fieldPlanners (type
map[int][]int) so it matches the code.
v2/pkg/engine/plan/cost.go (1)

589-600: Debug output multiplier should mirror non‑list override.

cost() forces multiplier=1 for non‑list fields, but debug output prints multiplier=0, which can mislead. Consider applying the same override before printing.

Suggested tweak
 fieldCost, argsCost, dirsCost, multiplier := node.costsAndMultiplier(configs, variables, defaultListSize, actualListSizes)
+if multiplier == 0 && !node.returnsListType {
+    multiplier = 1.0
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/plan/cost.go` around lines 589 - 600, The debug printing block
uses the raw multiplier returned from node.costsAndMultiplier but cost()
overrides multiplier to 1 for non-list fields, causing the debug output to show
0; update the code that prints the debug info (the fmt.Fprintf block using
fieldCost, argsCost, dirsCost, multiplier) to apply the same non-list override
logic used in cost() (i.e., if the field is not a list and multiplier == 0 then
set multiplier = 1) before formatting, so the logged multiplier mirrors the
actual cost calculation; reference node.costsAndMultiplier and the cost()
override behavior when locating where to apply the fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@execution/engine/execution_engine.go`:
- Around line 235-243: The current guard skips calling
operation.ComputeActualCost when resp.ActualListSizes is nil, leaving stale
actualCost values on reused Request objects; in the SynchronousResponsePlan
handling (after e.resolver.ResolveGraphQLResponse returns resp), always invoke
operation.ComputeActualCost(costCalculator, e.config.plannerConfig,
execContext.resolveContext.Variables, resp.ActualListSizes) unconditionally
(remove the "resp != nil && resp.ActualListSizes != nil" check) so
ComputeActualCost runs even when ActualListSizes is nil and resets costs; keep
the ResolveGraphQLResponse error handling and return behavior unchanged.

In `@v2/pkg/engine/plan/cost.go`:
- Around line 417-434: The current empty-list branch in the cost calculation
(inside the logic that reads actualListSizes for node.jsonPath) sets multiplier
= 1.0 which still multiplies childrenCost; change this so children of an empty
list are not charged: when totalCount == 0 set multiplier = 0 (so childrenCost
becomes zero) and separately add the base resolver cost (e.g., add fieldCost or
the equivalent one-time cost for node) so the field itself is charged once;
alternatively propagate a boolean (e.g., skipChildren or isEmptyList) from this
block to the caller that computes childrenCost and use it to skip adding
childrenCost when true. Ensure you update references to multiplier,
childrenCost, and fieldCost (and any callers expecting multiplier behavior)
accordingly.

---

Outside diff comments:
In `@v2/pkg/engine/plan/cost.go`:
- Around line 257-286: The current per-node rounding in the cost calculation (in
the block using node.costsAndMultiplier, childrenCost loop, multiplier handling
and final cost += int(math.RoundToEven(...))) causes undercounting for
actual-mode average multipliers; modify the cost computation to propagate
float64 costs through recursion: change local
cost/childrenCost/fieldCost/argsCost/directivesCost to float64 (or keep a
float64 accumulator), compute cost += (childrenCost + fieldCost) * multiplier as
a float without calling math.RoundToEven at each node, and only round/convert to
int at the top-level root return point (or in the public Cost() wrapper) so
rounding is deferred until after parent scaling; ensure multiplier enforcement
for non-list fields remains (set multiplier=1 as float64) and keep the
non-negative clamp after final rounding.

In `@v2/pkg/engine/plan/visitor.go`:
- Around line 1083-1085: Visitor.EnterDocument currently only sets v.Operation
and v.Definition but does not clear the Visitor's cache maps, causing stale
state when a Planner is reused; in EnterDocument clear the maps in place (not by
assigning new map objects) for pathCache, fieldPlanners, plannerFields,
fieldConfigs, exportedVariables, skipIncludeOnFragments, and
indirectInterfaceFields so existing references (notably
p.costVisitor.fieldPlanners which points to v.fieldPlanners) remain valid and
see the cleared state before planning the new operation.

---

Duplicate comments:
In `@execution/engine/execution_engine_test.go`:
- Around line 347-355: The test currently skips assertions when
expectedEstimatedCost or expectedActualCost equal 0 because of the "!= 0"
guards; change the test case fields to allow explicit zero expectations (e.g.,
make expectedEstimatedCost and expectedActualCost pointers like *int or add
boolean flags such as hasExpectedEstimatedCost/hasExpectedActualCost) and update
the test logic to check for nil/flag rather than != 0, then compare
operation.EstimatedCost() and operation.ActualCost() to the dereferenced pointer
(or asserted value) so zero-cost expectations are asserted correctly.

---

Nitpick comments:
In `@v2/pkg/engine/plan/cost_visitor.go`:
- Around line 22-23: The comment for the fieldPlanners map is misleading because
it says "Pointer" while the field is a map value; update the comment for the
Visitor.fieldPlanners declaration to reflect that it's a map value captured
early (e.g., "Map to Visitor.fieldPlanners. We capture this early before this
map is actually filled.") and ensure it references the field name fieldPlanners
(type map[int][]int) so it matches the code.

In `@v2/pkg/engine/plan/cost.go`:
- Around line 589-600: The debug printing block uses the raw multiplier returned
from node.costsAndMultiplier but cost() overrides multiplier to 1 for non-list
fields, causing the debug output to show 0; update the code that prints the
debug info (the fmt.Fprintf block using fieldCost, argsCost, dirsCost,
multiplier) to apply the same non-list override logic used in cost() (i.e., if
the field is not a list and multiplier == 0 then set multiplier = 1) before
formatting, so the logged multiplier mirrors the actual cost calculation;
reference node.costsAndMultiplier and the cost() override behavior when locating
where to apply the fix.

Comment thread execution/engine/execution_engine.go
Comment thread v2/pkg/engine/plan/cost.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@execution/graphql/request.go`:
- Around line 213-220: In ComputeActualCost, add a nil guard for the
actualListSizes parameter so that when actualListSizes == nil the method resets
r.actualCost = 0 and returns immediately (clearing stale cost when reusing
Request objects); otherwise keep the current behavior of calling
calc.ActualCost(config, actualListSizes) when calc != nil. Ensure you reference
the Request.ComputeActualCost method, the r.actualCost field, and the
actualListSizes parameter in the change.

In `@v2/pkg/engine/plan/cost.go`:
- Around line 417-434: When actualListSizes[node.jsonPath] reports totalCount ==
0 we should not charge children but still charge the field's resolver call;
change the single multiplier logic in the block handling !isEstimation to split
into two values (e.g., fieldMultiplier and childrenMultiplier) instead of using
multiplier for both. Keep fieldMultiplier = 1.0 when totalCount == 0 (to charge
the resolver) and set childrenMultiplier = 0.0 (to avoid charging children), and
update the cost aggregation sites that currently use multiplier (where
childrenCost and the field's own cost are combined) to apply childrenMultiplier
to childrenCost and fieldMultiplier to the field's base cost; reference
isEstimation, actualListSizes, node.jsonPath, multiplier (replace) and
childrenCost to find and update usages.

@ysmolski ysmolski merged commit d6dc786 into master Feb 19, 2026
11 checks passed
@ysmolski ysmolski deleted the yury/eng-8843-engine-compute-dynamic-actual-cost branch February 19, 2026 16:02
ysmolski pushed a commit that referenced this pull request Feb 19, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.0.0-rc.253](v2.0.0-rc.252...v2.0.0-rc.253)
(2026-02-19)


### Features

* compute dynamic (actual) cost
([#1376](#1376))
([d6dc786](d6dc786))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
jensneuse pushed a commit that referenced this pull request Feb 20, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.9.0](execution/v1.8.1...execution/v1.9.0)
(2026-02-20)


### Features

* compute dynamic (actual) cost
([#1376](#1376))
([d6dc786](d6dc786))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Added dynamic cost computation capability

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants