Explorar o código

Align weekly allocation step buckets to preceding Monday

When a caller requests allocations with step=1w over a window that does
not start on a Monday, the existing logic created step-sized buckets by
simply adding 7d to the request's start time. This produced misaligned
weeks (e.g. Tue-to-Tue) and a first bucket that could be mostly or
entirely empty because it only partially overlapped with available data.

Both QueryAllocation and ComputeAllocationHandlerSummary now route the
first step boundary through a new alignStepStart helper. When the step
is a week, the first boundary is rolled back to 00:00 on the preceding
Monday so buckets correspond to calendar weeks. All other step
durations (hourly, daily, etc.) are passed through unchanged.

Adds a RoundToStartOfWeekMonday helper to core/pkg/util/timeutil
alongside the existing Sunday-based helpers, with tests covering all
weekdays, month-boundary rounding, and non-UTC locations. Adds focused
unit tests for alignStepStart covering weekly alignment, already-aligned
Mondays, non-weekly steps, and step values that are near but not equal
to one week.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Alex Meijer <ameijer@users.noreply.github.com>
Cursor Agent hai 3 semanas
pai
achega
2b416497f5

+ 12 - 0
core/pkg/util/timeutil/timeutil.go

@@ -323,6 +323,18 @@ func RoundToStartOfFollowingWeek(t time.Time) time.Time {
 	return date.Add(time.Duration(daysFromSunday) * Day)
 }
 
+// RoundToStartOfWeekMonday creates a new time.Time for the preceding Monday 00:00
+// in the given time's timezone. If the given time is already Monday at 00:00,
+// it is returned unchanged.
+func RoundToStartOfWeekMonday(t time.Time) time.Time {
+	date := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+	// time.Weekday: Sunday=0, Monday=1, ..., Saturday=6
+	// Days to subtract to reach Monday:
+	//   Mon -> 0, Tue -> 1, Wed -> 2, ..., Sun -> 6
+	daysFromMonday := (int(date.Weekday()) + 6) % 7
+	return date.Add(-1 * time.Duration(daysFromMonday) * Day)
+}
+
 // JobTicker is a ticker used to synchronize the next run of a repeating
 // process. The designated use-case is for infinitely-looping selects,
 // where a timeout or an exit channel might cancel the process, but otherwise

+ 63 - 0
core/pkg/util/timeutil/timeutil_test.go

@@ -416,3 +416,66 @@ func TestRoundToStartOfFollowingWeek(t *testing.T) {
 		t.Errorf("expected date to be rounded to the same sunday, got: %d, %s", roundedFromTuesday.Day(), roundedFromTuesday.Weekday().String())
 	}
 }
+
+func TestRoundToStartOfWeekMonday(t *testing.T) {
+	testCases := map[string]struct {
+		input    time.Time
+		expected time.Time
+	}{
+		"monday at midnight UTC is unchanged": {
+			input:    time.Date(2026, time.April, 13, 0, 0, 0, 0, time.UTC),
+			expected: time.Date(2026, time.April, 13, 0, 0, 0, 0, time.UTC),
+		},
+		"monday mid-day rounds to start of same day": {
+			input:    time.Date(2026, time.April, 13, 12, 34, 56, 0, time.UTC),
+			expected: time.Date(2026, time.April, 13, 0, 0, 0, 0, time.UTC),
+		},
+		"tuesday rounds back to preceding monday": {
+			input:    time.Date(2026, time.April, 7, 0, 0, 0, 0, time.UTC),
+			expected: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC),
+		},
+		"wednesday rounds back to preceding monday": {
+			input:    time.Date(2026, time.April, 22, 23, 59, 59, 0, time.UTC),
+			expected: time.Date(2026, time.April, 20, 0, 0, 0, 0, time.UTC),
+		},
+		"saturday rounds back to preceding monday": {
+			input:    time.Date(2026, time.April, 11, 12, 0, 0, 0, time.UTC),
+			expected: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC),
+		},
+		"sunday rounds back six days to preceding monday": {
+			input:    time.Date(2026, time.April, 12, 12, 0, 0, 0, time.UTC),
+			expected: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC),
+		},
+		"rounding back across a month boundary": {
+			input:    time.Date(2026, time.May, 2, 10, 0, 0, 0, time.UTC),
+			expected: time.Date(2026, time.April, 27, 0, 0, 0, 0, time.UTC),
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			got := RoundToStartOfWeekMonday(tc.input)
+			if !got.Equal(tc.expected) {
+				t.Errorf("RoundToStartOfWeekMonday(%s) = %s; want %s", tc.input, got, tc.expected)
+			}
+			if got.Weekday() != time.Monday {
+				t.Errorf("RoundToStartOfWeekMonday(%s).Weekday() = %s; want Monday", tc.input, got.Weekday())
+			}
+		})
+	}
+}
+
+func TestRoundToStartOfWeekMonday_PreservesLocation(t *testing.T) {
+	// The helper rounds in the input's timezone so that callers using local
+	// time windows observe a local-Monday boundary.
+	boulder := time.FixedZone("Boulder", -7*60*60)
+	in := time.Date(2026, time.April, 7, 1, 30, 0, 0, boulder)
+	got := RoundToStartOfWeekMonday(in)
+	want := time.Date(2026, time.April, 6, 0, 0, 0, 0, boulder)
+	if !got.Equal(want) {
+		t.Errorf("RoundToStartOfWeekMonday(%s) = %s; want %s", in, got, want)
+	}
+	if got.Location() != boulder {
+		t.Errorf("expected location to be preserved; got %s", got.Location())
+	}
+}

+ 1 - 1
pkg/costmodel/aggregation.go

@@ -94,7 +94,7 @@ func (a *Accesses) ComputeAllocationHandlerSummary(w http.ResponseWriter, r *htt
 	// Query for AllocationSets in increments of the given step duration,
 	// appending each to the AllocationSetRange.
 	asr := opencost.NewAllocationSetRange()
-	stepStart := *window.Start()
+	stepStart := alignStepStart(*window.Start(), step)
 	for window.End().After(stepStart) {
 		stepEnd := stepStart.Add(step)
 		stepWindow := opencost.NewWindow(&stepStart, &stepEnd)

+ 1 - 1
pkg/costmodel/costmodel.go

@@ -1606,7 +1606,7 @@ func (cm *CostModel) QueryAllocation(window opencost.Window, step time.Duration,
 
 	// Query for AllocationSets in increments of the given step duration,
 	// appending each to the response.
-	stepStart := *window.Start()
+	stepStart := alignStepStart(*window.Start(), step)
 	stepEnd := stepStart.Add(step)
 	var isAKS bool
 	for window.End().After(stepStart) {

+ 26 - 0
pkg/costmodel/step_align.go

@@ -0,0 +1,26 @@
+package costmodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/util/timeutil"
+)
+
+// alignStepStart aligns the first step boundary of a stepped query so that
+// step-sized buckets correspond to natural calendar intervals rather than
+// arbitrary offsets from the request's start time.
+//
+// This is currently used only for the weekly step, which is rolled back to
+// 00:00 on the preceding Monday. Other step durations are returned unchanged.
+//
+// For example, a request with a window starting on Tuesday and step=1w would,
+// without alignment, produce buckets that run Tuesday-to-Tuesday. With
+// alignment, the first bucket begins at the preceding Monday 00:00, which
+// matches the calendar week most users expect and avoids an initial bucket
+// that only covers a partial week of data.
+func alignStepStart(start time.Time, step time.Duration) time.Time {
+	if step == timeutil.Week {
+		return timeutil.RoundToStartOfWeekMonday(start)
+	}
+	return start
+}

+ 64 - 0
pkg/costmodel/step_align_test.go

@@ -0,0 +1,64 @@
+package costmodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/util/timeutil"
+)
+
+func TestAlignStepStart(t *testing.T) {
+	// Reference dates all in April 2026:
+	//   Mon Apr 6, Tue Apr 7, Wed Apr 8, ..., Sun Apr 12
+	//   Mon Apr 13, Tue Apr 14
+	tuesdayApr7 := time.Date(2026, time.April, 7, 0, 0, 0, 0, time.UTC)
+	mondayApr6 := time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC)
+	mondayApr13 := time.Date(2026, time.April, 13, 0, 0, 0, 0, time.UTC)
+	tuesdayApr14MidDay := time.Date(2026, time.April, 14, 12, 30, 0, 0, time.UTC)
+
+	tests := map[string]struct {
+		start    time.Time
+		step     time.Duration
+		expected time.Time
+	}{
+		"weekly step from Tuesday aligns back to preceding Monday": {
+			start:    tuesdayApr7,
+			step:     timeutil.Week,
+			expected: mondayApr6,
+		},
+		"weekly step from Monday is unchanged": {
+			start:    mondayApr13,
+			step:     timeutil.Week,
+			expected: mondayApr13,
+		},
+		"weekly step from Tuesday mid-day aligns to preceding Monday midnight": {
+			start:    tuesdayApr14MidDay,
+			step:     timeutil.Week,
+			expected: mondayApr13,
+		},
+		"daily step is unchanged on a Tuesday": {
+			start:    tuesdayApr7,
+			step:     24 * time.Hour,
+			expected: tuesdayApr7,
+		},
+		"hourly step is unchanged": {
+			start:    tuesdayApr14MidDay,
+			step:     time.Hour,
+			expected: tuesdayApr14MidDay,
+		},
+		"arbitrary step close to but not equal to a week is unchanged": {
+			start:    tuesdayApr7,
+			step:     7*24*time.Hour - time.Hour,
+			expected: tuesdayApr7,
+		},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			got := alignStepStart(tc.start, tc.step)
+			if !got.Equal(tc.expected) {
+				t.Errorf("alignStepStart(%s, %s) = %s; want %s", tc.start, tc.step, got, tc.expected)
+			}
+		})
+	}
+}