Skip to content

make accumulate be able to govern windows - #3758

Merged
ameijer merged 10 commits into
developfrom
atm/fix-accumulate-by
Apr 29, 2026
Merged

make accumulate be able to govern windows#3758
ameijer merged 10 commits into
developfrom
atm/fix-accumulate-by

Conversation

@ameijer

@ameijer ameijer commented Apr 28, 2026

Copy link
Copy Markdown
Member

Description

enhances accumulate parameter to govern step sizes. maintains backward compatibility

Related Issues

User Impact

Testing

Copilot AI review requested due to automatic review settings April 28, 2026 14:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Enhances allocation “accumulate/accumulateBy” behavior so accumulation can govern step sizing and query window rounding (e.g., calendar-week/month boundaries), aiming to keep existing behavior working while enabling more intuitive time-bucketed results.

Changes:

  • Add helpers to resolve accumulation option, derive an appropriate default step, and round the query window to accumulation boundaries.
  • Update allocation query paths (handler + costmodel) to use the rounded “query window” for fetching data, then trim back to the originally requested window after accumulation.
  • Add unit tests covering option/step/window resolution and trimming behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
pkg/costmodel/costmodel.go Queries allocations over a resolved/rounded query window for accumulation modes and trims results back to the requested window.
pkg/costmodel/aggregation.go Adds accumulate/step/window resolution helpers; updates HTTP handlers to use them and to trim after accumulation.
pkg/costmodel/aggregation_test.go Adds unit tests for the new resolution + trimming logic.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/costmodel/aggregation.go Outdated
// Accumulate is an optional parameter, defaulting to false, which if true
// sums each Set in the Range, producing one Set.
accumulate := qp.GetBool("accumulate", false)
accumulate := opencost.ParseAccumulate(qp.Get("accumulate", ""))

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

opencost.ParseAccumulate does not preserve the previous qp.GetBool("accumulate", false) behavior: e.g. query values like "1", "t", "TRUE" previously parsed as true via strconv.ParseBool, but now resolve to AccumulateOptionNone and disable accumulation. To maintain backwards compatibility, consider falling back to qp.GetBool("accumulate", false) when ParseAccumulate returns None but the raw param is truthy, or extend parsing to accept strconv.ParseBool-style values.

Suggested change
accumulate := opencost.ParseAccumulate(qp.Get("accumulate", ""))
rawAccumulate := qp.Get("accumulate", "")
accumulate := opencost.ParseAccumulate(rawAccumulate)
if accumulate == opencost.AccumulateOptionNone && qp.GetBool("accumulate", false) {
accumulate = opencost.AccumulateOptionAll
}

Copilot uses AI. Check for mistakes.
Comment thread pkg/costmodel/aggregation.go Outdated
Comment on lines +321 to +329
// Accumulate is an optional parameter, defaulting to false, which if true
// sums each Set in the Range, producing one Set.
accumulate := qp.GetBool("accumulate", false)
accumulate := opencost.ParseAccumulate(qp.Get("accumulate", ""))

// Accumulate is an optional parameter that accumulates an AllocationSetRange
// by the resolution of the given time duration.
// Defaults to 0. If a value is not passed then the parameter is not used.
accumulateBy := opencost.AccumulateOption(qp.Get("accumulateBy", ""))

// if accumulateBy is not explicitly set, and accumulate is true, ensure result is accumulated
if accumulateBy == opencost.AccumulateOptionNone && accumulate {
accumulateBy = opencost.AccumulateOptionAll
accumulateBy := resolveAccumulateOption(accumulate, qp.Get("accumulateBy", ""))
step, err := resolveStepFromQuery(qp, window, accumulateBy)

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

Same backward-compatibility issue as in the summary handler: switching from qp.GetBool("accumulate", false) to opencost.ParseAccumulate drops support for truthy values like "1"/"t"/"TRUE" (accepted by strconv.ParseBool). This can silently change behavior for existing clients relying on those forms.

Copilot uses AI. Check for mistakes.
Comment thread pkg/costmodel/aggregation.go Outdated
Comment on lines +61 to +68
func resolveAccumulateOption(accumulate opencost.AccumulateOption, accumulateBy string) opencost.AccumulateOption {
accumulateByOpt := opencost.AccumulateOption(accumulateBy)
if accumulateByOpt != opencost.AccumulateOptionNone {
return accumulateByOpt
}

return accumulate
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

resolveAccumulateOption treats any non-empty accumulateBy string as valid, including values like "none" or different casing (e.g. "Week"), which will later cause AllocationSetRange.Accumulate/Window.GetAccumulateWindows to error and can surface as a 500. Consider normalizing case and validating accumulateBy against the supported options (e.g. via opencost.ParseAccumulate plus an explicit invalid-value check) and returning a 400 for invalid inputs.

Copilot uses AI. Check for mistakes.
Comment on lines +157 to +170
func trimAllocationSetRangeToRequestWindow(asr *opencost.AllocationSetRange, requestWindow opencost.Window) *opencost.AllocationSetRange {
if asr == nil {
return nil
}

trimmed := opencost.NewAllocationSetRange()
for _, as := range asr.Slice() {
// Keep only sets that overlap the originally requested window.
if as.Start().Before(*requestWindow.End()) && as.End().After(*requestWindow.Start()) {
trimmed.Append(as)
}
}

return trimmed

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

trimAllocationSetRangeToRequestWindow iterates via asr.Slice(), which deep-clones every AllocationSet in the range (and all contained allocations) before filtering. For large allocation responses this is a significant and unnecessary CPU/memory cost just to drop non-overlapping sets. Consider iterating asr.Allocations directly (or filtering in-place) and preserve asr.FromStore on the returned range if that field is meaningful to callers.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@ameijer Copilot got one right I think -- unless there is any reason to preserve the original asr *opencost.AllocationSetRange I think just iterating over asr.Allocations is better -- Slice() seems to be quite the footgun 😨

})
}
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

Given the stated goal of backward compatibility, add test coverage for the legacy truthy forms of the accumulate query param (e.g. "1" or "t") to ensure they still enable accumulation after switching to ParseAccumulate (or whatever compatibility fix is applied).

Suggested change
func TestParseAccumulate_LegacyTruthyValues(t *testing.T) {
tests := []struct {
name string
input string
}{
{
name: "true remains supported",
input: "true",
},
{
name: "1 remains supported for backward compatibility",
input: "1",
},
{
name: "t remains supported for backward compatibility",
input: "t",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseAccumulate(tc.input)
if err != nil {
t.Fatalf("unexpected error parsing %q: %s", tc.input, err)
}
if got != opencost.AccumulateOptionAll {
t.Fatalf("expected %q for %q, got %q", opencost.AccumulateOptionAll, tc.input, got)
}
})
}
}

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 28, 2026 14:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/costmodel/aggregation.go Outdated
Comment on lines +104 to +112
case opencost.AccumulateOptionWeek, opencost.AccumulateOptionMonth:
// week accumulation supports either daily or already-weekly sets
if accumulateBy == opencost.AccumulateOptionWeek && step == week {
return week
}
return day
default:
return step
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

resolveStepForAccumulate does not special-case AccumulateOptionQuarter. As a result, callers can request accumulateBy=quarter&step=week (or another non-daily duration) and this function will return the non-daily step, despite the comments below stating quarter accumulation operates on daily inputs. Add AccumulateOptionQuarter to the month/week branch (returning daily) so quarter accumulation always queries daily inputs unless/ until a dedicated quarterly step is supported.

Copilot uses AI. Check for mistakes.
Comment thread pkg/costmodel/aggregation.go Outdated
// quarter accumulation operates on daily inputs and calendar-rounded query windows
return resolveStepForAccumulate(24*time.Hour, accumulateBy), nil
default:
step := qp.GetDuration("step", window.Duration())

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

resolveStepFromQuery is declared to return (time.Duration, error) but it never returns a non-nil error (it relies on qp.GetDuration, which falls back to the default on parse failure). Either (a) remove the error return and simplify the call sites, or (b) explicitly validate/parsing step and return a BadRequest error on invalid values so the new error handling paths are meaningful.

Suggested change
step := qp.GetDuration("step", window.Duration())
step, err := time.ParseDuration(stepRaw)
if err != nil {
return 0, fmt.Errorf("invalid step %q", stepRaw)
}

Copilot uses AI. Check for mistakes.
Comment thread pkg/costmodel/aggregation.go Outdated
Comment on lines +218 to +222
// Accumulate is an optional parameter, defaulting to false, which if true
// sums each Set in the Range, producing one Set.
accumulate := qp.GetBool("accumulate", false)
accumulate := resolveAccumulateFromQuery(qp)
accumulateBy, err := resolveAccumulateOption(accumulate, qp.Get("accumulateBy", ""))
if err != nil {

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

In ComputeAllocationHandlerSummary, the comment above accumulate says it “sums each Set in the Range, producing one Set”, but the new accumulateBy behavior can produce multiple calendar-window sets (e.g. weekly). Also accumulate is now an opencost.AccumulateOption (not a bool), which is confusing given the name and the existing comment. Update the comment and consider renaming accumulate to something like accumulateOpt to reflect the new type/semantics.

Copilot uses AI. Check for mistakes.
Comment thread pkg/costmodel/aggregation.go Outdated
Comment on lines +345 to +352
// Accumulate is an optional parameter, defaulting to false, which if true
// sums each Set in the Range, producing one Set.
accumulate := qp.GetBool("accumulate", false)
accumulate := resolveAccumulateFromQuery(qp)

// Accumulate is an optional parameter that accumulates an AllocationSetRange
// by the resolution of the given time duration.
// Defaults to 0. If a value is not passed then the parameter is not used.
accumulateBy := opencost.AccumulateOption(qp.Get("accumulateBy", ""))

// if accumulateBy is not explicitly set, and accumulate is true, ensure result is accumulated
if accumulateBy == opencost.AccumulateOptionNone && accumulate {
accumulateBy = opencost.AccumulateOptionAll
accumulateBy, err := resolveAccumulateOption(accumulate, qp.Get("accumulateBy", ""))

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

In ComputeAllocationHandler, accumulate is now an opencost.AccumulateOption rather than a bool, but surrounding comments still describe it as a boolean flag and describe accumulateBy as a “time duration”. This is now inaccurate and makes the parameter semantics harder to understand/maintain. Update the comments (and consider renaming accumulate similarly to the summary handler) so they match the new option-based accumulation behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +74
if accumulateByRaw == "none" {
return opencost.AccumulateOptionNone, nil
}

accumulateByOpt := opencost.ParseAccumulate(accumulateByRaw)
if accumulateByOpt == opencost.AccumulateOptionNone {
return opencost.AccumulateOptionNone, fmt.Errorf("invalid accumulateBy option: %s", accumulateBy)
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

resolveAccumulateOption rejects accumulateBy=all because opencost.ParseAccumulate("all") returns AccumulateOptionNone (it only maps "true" -> all). Previously the handler accepted accumulateBy=all (it was cast directly to opencost.AccumulateOption), so this breaks backward compatibility. Handle "all" explicitly here (similar to "none") or extend ParseAccumulate to support "all".

Copilot uses AI. Check for mistakes.
@peatey

peatey commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Looks good — covers most of the original review. Two things from Copilot's review that I'd want addressed before merge:

Reader-mode ReadString (buffer.go:372) and NewFileStringTableReaderFrom (support.go.tmpl) still allocate based on attacker-controlled length with no hard cap. After the v0.2 length prefix change, ReadString can be asked to allocate ~4 GB. Add a MaxStringLength / MaxStringTableEntries constant checked unconditionally before make.
The runtime string-table errors are still panics. Generate() now recovers panics, but those are generator panics — a bad input file at runtime still crashes the host process. Either fix here or file a follow-up explicitly.

H2 (sticky errors threaded through generated unmarshallers) is genuinely structural and worth its own PR, just want to make sure it doesn't get marked as done by association with Buffer.Err().

Copilot AI review requested due to automatic review settings April 28, 2026 19:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1606 to +1609
queryWindow, err := resolveQueryWindowForAccumulate(window, accumulateBy)
if err != nil {
return nil, fmt.Errorf("invalid accumulation configuration: %w", err)
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

resolveQueryWindowForAccumulate() expands the queried window to calendar boundaries, but the subsequent trimAllocationSetRangeToRequestWindow() only filters overlapping sets (it doesn’t prorate/contract boundary sets). This means responses can include costs from outside the originally requested window when the request isn’t aligned to the accumulation boundary (e.g. mid-week to mid-week with weekly accumulation). If the API contract is that window bounds the costs returned, consider contracting/prorating boundary sets to the requested window (or avoid expanding the query window and instead allow partial edge buckets).

Suggested change
queryWindow, err := resolveQueryWindowForAccumulate(window, accumulateBy)
if err != nil {
return nil, fmt.Errorf("invalid accumulation configuration: %w", err)
}
// Validate the accumulation configuration, but keep the actual query
// bounded to the caller-requested window. Expanding to calendar-aligned
// boundaries here can cause ComputeAllocation/ComputeAssets to include
// costs that fall outside the requested window when the request is not
// aligned to the accumulation period.
if _, err := resolveQueryWindowForAccumulate(window, accumulateBy); err != nil {
return nil, fmt.Errorf("invalid accumulation configuration: %w", err)
}
queryWindow := window

Copilot uses AI. Check for mistakes.
Comment thread pkg/costmodel/aggregation.go Outdated
Comment on lines +238 to +242
queryWindow, err := resolveQueryWindowForAccumulate(window, accumulateBy)
if err != nil {
proto.WriteError(w, proto.BadRequest(fmt.Sprintf("Invalid accumulation configuration: %s", err)))
return
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

queryWindow expansion (calendar-rounded) plus the later trimAllocationSetRangeToRequestWindow() (overlap filter) means the handler can return full calendar buckets that include time/cost outside the originally requested window. If that’s intentional, please document it near the queryWindow/trim usage; if not, trimming likely needs to contract/prorate boundary buckets instead of only filtering overlaps.

Copilot uses AI. Check for mistakes.
Comment thread pkg/costmodel/aggregation_test.go Outdated
@mbolt35

mbolt35 commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Reader-mode ReadString (buffer.go:372) and NewFileStringTableReaderFrom (support.go.tmpl) still allocate based on attacker-controlled length with no hard cap. After the v0.2 length prefix change, ReadString can be asked to allocate ~4 GB. Add a MaxStringLength / MaxStringTableEntries constant checked unconditionally before make.
The runtime string-table errors are still panics. Generate() now recovers panics, but those are generator panics — a bad input file at runtime still crashes the host process. Either fix here or file a follow-up explicitly.

@peatey Will you make a ticket for these and assign to me? From just a quick once over, these seem mostly accurate, and I'd like to set aside some time to get them addressed.

Also worth noting that I think these were from my changeset, just merged into Alex's :)

@ameijer
ameijer enabled auto-merge April 29, 2026 12:11
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Alex Meijer <ameijer@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 29, 2026 13:27
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Alex Meijer <ameijer@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +158 to +162
step, err := time.ParseDuration(stepRaw)
if err != nil {
return 0, fmt.Errorf("invalid step %q: must be a Go duration or one of hour, day, week, month, quarter: %w", stepRaw, err)
}
return resolveStepForAccumulate(step, accumulateBy), nil

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

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

resolveStepFromQuery drops the underlying time.ParseDuration error details, making it hard for callers to understand what was wrong with the provided step. Consider wrapping the parse error (and/or mentioning the accepted keyword values) so the API returns an actionable message when a user passes an invalid duration.

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +90
func resolveAccumulateFromQuery(qp httputil.QueryParams) opencost.AccumulateOption {
rawAccumulate := strings.TrimSpace(qp.Get("accumulate", ""))
accumulate := opencost.ParseAccumulate(rawAccumulate)
if accumulate == opencost.AccumulateOptionNone && qp.GetBool("accumulate", false) {
return opencost.AccumulateOptionAll
}

return accumulate

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

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

resolveAccumulateFromQuery uses opencost.ParseAccumulate, which does not recognize the string "all" (it only maps "true" to AccumulateOptionAll). That means requests like ?accumulate=all (which this PR’s docstring implies is valid) will silently behave as none. Consider explicitly handling "all" (and maybe "none") here before calling ParseAccumulate and add a test for accumulate=all to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +166 to +180
func resolveQueryWindowForAccumulate(window opencost.Window, accumulateBy opencost.AccumulateOption) (opencost.Window, error) {
switch accumulateBy {
case opencost.AccumulateOptionHour, opencost.AccumulateOptionDay, opencost.AccumulateOptionWeek, opencost.AccumulateOptionMonth, opencost.AccumulateOptionQuarter:
windows, err := window.GetAccumulateWindows(accumulateBy)
if err != nil {
return opencost.Window{}, err
}
if len(windows) == 0 {
return opencost.Window{}, fmt.Errorf("no query windows for accumulate option %s", accumulateBy)
}

return opencost.NewClosedWindow(*windows[0].Start(), *windows[len(windows)-1].End()), nil
default:
return window, nil
}

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

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

This code path allows accumulateBy=quarter (via ParseAccumulate) and also rounds the query window for AccumulateOptionQuarter, but core/pkg/opencost/allocation.go’s (*AllocationSetRange).Accumulate does not implement AccumulateOptionQuarter and will return an error ("invalid accumulateByType: quarter"). Either add quarter support in AllocationSetRange.Accumulate, or reject/translate quarter in the costmodel layer so the API doesn’t advertise an option that cannot succeed.

Copilot uses AI. Check for mistakes.

@mbolt35 mbolt35 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

@ameijer
ameijer added this pull request to the merge queue Apr 29, 2026
@ameijer
ameijer removed this pull request from the merge queue due to a manual request Apr 29, 2026
@ameijer
ameijer enabled auto-merge April 29, 2026 15:08
@ameijer
ameijer added this pull request to the merge queue Apr 29, 2026
@sonarqubecloud

Copy link
Copy Markdown

Merged via the queue into develop with commit f38e71a Apr 29, 2026
19 of 21 checks passed
@ameijer
ameijer deleted the atm/fix-accumulate-by branch April 29, 2026 15:22
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.

4 participants