Skip to content

Commit 66c3390

Browse files
authored
fix: totalAvailableGrantAmount (#4304)
1 parent f553b3c commit 66c3390

8 files changed

Lines changed: 668 additions & 58 deletions

File tree

openmeter/credit/engine/burnphase.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ type phasePlan struct {
3131
//
3232
// Note that grant balance does not effect the burndown order if we simply ignore grants that don't
3333
// have balance while burning down.
34-
func (e *engine) getPhases(grants []grant.Grant, period timeutil.ClosedPeriod) (phasePlan, error) {
34+
func (e *engine) getPhases(grants []grant.Grant, period timeutil.ClosedPeriod, recurrenceEndBoundaryBehavior timeutil.Boundary) (phasePlan, error) {
3535
activityChanges := e.getGrantActivityChanges(grants, period)
36-
recurrenceTimes, err := e.getGrantRecurrenceTimes(grants, period)
36+
recurrenceTimes, err := e.getGrantRecurrenceTimes(grants, period, recurrenceEndBoundaryBehavior)
3737
if err != nil {
3838
return phasePlan{}, fmt.Errorf("failed to get grant recurrence times: %w", err)
3939
}
@@ -68,6 +68,10 @@ func (e *engine) getPhases(grants []grant.Grant, period timeutil.ClosedPeriod) (
6868
phaseFrom := period.From
6969

7070
appendPhase := func(phase burnPhase) {
71+
if !phase.to.After(phase.from) {
72+
return
73+
}
74+
7175
phases = append(phases, phase)
7276
phaseFrom = phase.to
7377
}
@@ -102,10 +106,7 @@ func (e *engine) getPhases(grants []grant.Grant, period timeutil.ClosedPeriod) (
102106
rtI++
103107
}
104108

105-
// If it's a valid phase (non-zero duration), we save it and break
106-
if phase.to.After(phase.from) {
107-
appendPhase(phase)
108-
}
109+
appendPhase(phase)
109110
}
110111

111112
// order here doesn't matter as one or both of them is empty
@@ -133,6 +134,25 @@ func (e *engine) getPhases(grants []grant.Grant, period timeutil.ClosedPeriod) (
133134
})
134135
}
135136

137+
if len(phases) > 0 {
138+
lastPhase := phases[len(phases)-1]
139+
terminalEvent := lastPhase.to.Equal(period.To) &&
140+
len(lastPhase.grantsRecurredAtEnd) > 0
141+
if terminalEvent {
142+
phases = append(phases, burnPhase{
143+
from: period.To,
144+
to: period.To,
145+
})
146+
}
147+
}
148+
149+
if len(phases) == 0 {
150+
phases = append(phases, burnPhase{
151+
from: period.From,
152+
to: period.To,
153+
})
154+
}
155+
136156
return phasePlan{
137157
phases: phases,
138158
grantsRecurredAtStart: grantsRecurredAtStart,

openmeter/credit/engine/engine.go

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -56,36 +56,14 @@ type RunResult struct {
5656
RunParams RunParams
5757
}
5858

59-
// TotalAvailableGrantAmount is the total amount of grants either currently active + the used amount of currently inactive grants.
60-
func (r RunResult) TotalAvailableGrantAmount() float64 {
61-
// First, let's calculate the total amount of grants active at the end of the period.
62-
activeAmount := lo.Reduce(r.RunParams.Grants, func(agg alpacadecimal.Decimal, grant grant.Grant, _ int) alpacadecimal.Decimal {
63-
if !grant.ActiveAt(r.RunParams.Until) {
64-
return agg
65-
}
66-
67-
return agg.Add(alpacadecimal.NewFromFloat(grant.Amount))
68-
}, alpacadecimal.NewFromFloat(0))
69-
70-
// Second, let's calculate the used-up amount of since inactive grants.
71-
inactiveGrants := lo.Filter(r.RunParams.Grants, func(grant grant.Grant, _ int) bool {
72-
return !grant.ActiveAt(r.RunParams.Until)
73-
})
74-
75-
usedInactive := alpacadecimal.NewFromFloat(0)
76-
if len(inactiveGrants) > 0 {
77-
for _, seg := range r.History.Segments() {
78-
for _, usage := range seg.GrantUsages {
79-
if lo.SomeBy(inactiveGrants, func(grant grant.Grant) bool {
80-
return grant.ID == usage.GrantID
81-
}) {
82-
usedInactive = usedInactive.Add(alpacadecimal.NewFromFloat(usage.Usage))
83-
}
84-
}
85-
}
86-
}
87-
88-
return activeAmount.Add(usedInactive).InexactFloat64()
59+
// TotalAvailableGrantAmountAtLastPeriod is the total grant amount available in the run period:
60+
// grant-covered usage plus remaining balances still available at the end.
61+
func (r RunResult) TotalAvailableGrantAmountAtLastPeriod() float64 {
62+
lastUsagePeriodHistory, _ := lo.Last(r.History.ChunkByResets())
63+
64+
return lastUsagePeriodHistory.TotalGrantUsage().
65+
Add(alpacadecimal.NewFromFloat(r.Snapshot.Balance())).
66+
InexactFloat64()
8967
}
9068

9169
type Engine interface {

openmeter/credit/engine/grant.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,27 @@ import (
1616
// An activity change is a grant becoming active or a grant expiring.
1717
func (e *engine) getGrantActivityChanges(grants []grant.Grant, period timeutil.ClosedPeriod) []time.Time {
1818
activityChanges := []time.Time{}
19+
1920
for _, grant := range grants {
2021
// grants that take effect in the period
21-
if grant.EffectiveAt.After(period.From) && (grant.EffectiveAt.Before(period.To)) {
22+
if period.Contains(grant.EffectiveAt) {
2223
activityChanges = append(activityChanges, grant.EffectiveAt)
2324
}
2425
// grants that expire in the period
2526
if grant.ExpiresAt != nil {
26-
if grant.ExpiresAt.After(period.From) && (grant.ExpiresAt.Before(period.To)) {
27+
if period.Contains(*grant.ExpiresAt) {
2728
activityChanges = append(activityChanges, *grant.ExpiresAt)
2829
}
2930
}
3031
// grants that are deleted in the period
3132
if grant.DeletedAt != nil {
32-
if grant.DeletedAt.After(period.From) && (grant.DeletedAt.Before(period.To)) {
33+
if period.Contains(*grant.DeletedAt) {
3334
activityChanges = append(activityChanges, *grant.DeletedAt)
3435
}
3536
}
3637
// grants that are voided in the period
3738
if grant.VoidedAt != nil {
38-
if grant.VoidedAt.After(period.From) && (grant.VoidedAt.Before(period.To)) {
39+
if period.Contains(*grant.VoidedAt) {
3940
activityChanges = append(activityChanges, *grant.VoidedAt)
4041
}
4142
}
@@ -62,7 +63,7 @@ func (e *engine) getGrantActivityChanges(grants []grant.Grant, period timeutil.C
6263
}
6364

6465
// Get all times grants recurr in the period.
65-
func (e *engine) getGrantRecurrenceTimes(grants []grant.Grant, period timeutil.ClosedPeriod) ([]struct {
66+
func (e *engine) getGrantRecurrenceTimes(grants []grant.Grant, period timeutil.ClosedPeriod, endBoundaryBehavior timeutil.Boundary) ([]struct {
6667
time time.Time
6768
grantIDs []string
6869
}, error,
@@ -86,12 +87,29 @@ func (e *engine) getGrantRecurrenceTimes(grants []grant.Grant, period timeutil.C
8687
if err != nil {
8788
return nil, err
8889
}
89-
// Write all recurrence times in [period.From, period.To).
90+
91+
// Write all recurrence times in [period.From, period.To]).
9092
// For a zero-length period [T, T], include T so a recurrence at the
9193
// start of that period can still be applied.
94+
// Include period.To for the final run period so a run ending exactly on
95+
// a recurrence produces a zero-length terminal phase where it is applied.
9296
inPeriod := func(at time.Time) bool {
93-
return at.Before(period.To) || (period.From.Equal(period.To) && at.Equal(period.From))
97+
behavior := endBoundaryBehavior
98+
99+
if period.IsEmpty() && at.Equal(period.From) {
100+
behavior = timeutil.Inclusive
101+
}
102+
103+
switch behavior {
104+
case timeutil.Inclusive:
105+
return period.ContainsInclusive(at) // [from, to]
106+
case timeutil.Exclusive:
107+
return period.Contains(at) // [from, to)
108+
default:
109+
return false
110+
}
94111
}
112+
95113
for inPeriod(it.At) && grant.ActiveAt(it.At) {
96114
times = append(times, struct {
97115
time time.Time

openmeter/credit/engine/history.go

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import (
44
"encoding/json"
55
"fmt"
66
"sort"
7+
"time"
8+
9+
"github.com/alpacahq/alpacadecimal"
710

811
"github.com/openmeterio/openmeter/openmeter/credit/balance"
912
"github.com/openmeterio/openmeter/pkg/timeutil"
@@ -144,10 +147,7 @@ func (g *GrantBurnDownHistory) GetUsageInPeriodUntilSegment(segmentIndex int) (b
144147
// We need the segment right after the last reset
145148
if lastResetSegmentIndex+1 < len(g.segments) {
146149
firstSeg := g.segments[lastResetSegmentIndex+1]
147-
usage = balance.SnapshottedUsage{
148-
Since: firstSeg.From,
149-
Usage: 0.0,
150-
}
150+
usage = usageAtReset(firstSeg.From)
151151
}
152152
}
153153

@@ -163,6 +163,54 @@ func (g *GrantBurnDownHistory) Segments() []GrantBurnDownHistorySegment {
163163
return g.segments
164164
}
165165

166+
func (g GrantBurnDownHistory) ChunkByResets() []GrantBurnDownHistory {
167+
if len(g.segments) == 0 {
168+
return nil
169+
}
170+
171+
chunks := make([]GrantBurnDownHistory, 0, 1)
172+
current := GrantBurnDownHistory{
173+
usageAtStart: g.usageAtStart,
174+
segments: make([]GrantBurnDownHistorySegment, 0, len(g.segments)),
175+
}
176+
177+
for _, seg := range g.segments {
178+
current.segments = append(current.segments, seg)
179+
if seg.TerminationReasons.UsageReset {
180+
chunks = append(chunks, current)
181+
current = GrantBurnDownHistory{
182+
usageAtStart: usageAtReset(seg.To),
183+
segments: make([]GrantBurnDownHistorySegment, 0, len(g.segments)),
184+
}
185+
}
186+
}
187+
188+
if len(current.segments) > 0 {
189+
chunks = append(chunks, current)
190+
}
191+
192+
return chunks
193+
}
194+
195+
func (g GrantBurnDownHistory) TotalGrantUsage() alpacadecimal.Decimal {
196+
total := alpacadecimal.NewFromFloat(0)
197+
198+
for _, seg := range g.segments {
199+
for _, usage := range seg.GrantUsages {
200+
total = total.Add(alpacadecimal.NewFromFloat(usage.Usage))
201+
}
202+
}
203+
204+
return total
205+
}
206+
207+
func usageAtReset(at time.Time) balance.SnapshottedUsage {
208+
return balance.SnapshottedUsage{
209+
Since: at,
210+
Usage: 0.0,
211+
}
212+
}
213+
166214
func (g *GrantBurnDownHistory) TotalUsageInHistory() float64 {
167215
var total float64
168216
for _, s := range g.segments {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package engine_test
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/openmeterio/openmeter/openmeter/credit/balance"
11+
"github.com/openmeterio/openmeter/openmeter/credit/engine"
12+
"github.com/openmeterio/openmeter/pkg/timeutil"
13+
)
14+
15+
func TestGrantBurnDownHistory_ChunkByResets(t *testing.T) {
16+
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
17+
usageAtStart := balance.SnapshottedUsage{
18+
Since: start.Add(-time.Hour),
19+
Usage: 7,
20+
}
21+
22+
segment := func(idx int, grantUsage float64, reset bool) engine.GrantBurnDownHistorySegment {
23+
from := start.Add(time.Duration(idx) * time.Hour)
24+
25+
return engine.GrantBurnDownHistorySegment{
26+
ClosedPeriod: timeutil.ClosedPeriod{
27+
From: from,
28+
To: from.Add(time.Hour),
29+
},
30+
TotalUsage: grantUsage + 1,
31+
GrantUsages: []engine.GrantUsage{
32+
{
33+
GrantID: "grant-1",
34+
Usage: grantUsage,
35+
},
36+
},
37+
TerminationReasons: engine.SegmentTerminationReason{
38+
UsageReset: reset,
39+
},
40+
}
41+
}
42+
43+
t.Run("Empty history returns no chunks", func(t *testing.T) {
44+
history, err := engine.NewGrantBurnDownHistory(nil, usageAtStart)
45+
require.NoError(t, err)
46+
47+
assert.Empty(t, history.ChunkByResets())
48+
})
49+
50+
t.Run("History without resets stays as one chunk", func(t *testing.T) {
51+
history, err := engine.NewGrantBurnDownHistory([]engine.GrantBurnDownHistorySegment{
52+
segment(0, 10, false),
53+
segment(1, 20, false),
54+
}, usageAtStart)
55+
require.NoError(t, err)
56+
57+
chunks := history.ChunkByResets()
58+
require.Len(t, chunks, 1)
59+
assert.Len(t, chunks[0].Segments(), 2)
60+
assert.Equal(t, 30.0, chunks[0].TotalGrantUsage().InexactFloat64())
61+
62+
usage, err := chunks[0].GetUsageInPeriodUntilSegment(0)
63+
require.NoError(t, err)
64+
assert.Equal(t, usageAtStart, usage)
65+
})
66+
67+
t.Run("History is chunked after reset segments", func(t *testing.T) {
68+
history, err := engine.NewGrantBurnDownHistory([]engine.GrantBurnDownHistorySegment{
69+
segment(0, 10, false),
70+
segment(1, 20, true),
71+
segment(2, 30, false),
72+
segment(3, 40, true),
73+
segment(4, 50, false),
74+
}, usageAtStart)
75+
require.NoError(t, err)
76+
77+
chunks := history.ChunkByResets()
78+
require.Len(t, chunks, 3)
79+
80+
assert.Len(t, chunks[0].Segments(), 2)
81+
assert.Equal(t, 30.0, chunks[0].TotalGrantUsage().InexactFloat64())
82+
assertChunkUsageAtStart(t, chunks[0], usageAtStart)
83+
84+
assert.Len(t, chunks[1].Segments(), 2)
85+
assert.Equal(t, 70.0, chunks[1].TotalGrantUsage().InexactFloat64())
86+
assertChunkUsageAtStart(t, chunks[1], balance.SnapshottedUsage{
87+
Since: start.Add(2 * time.Hour),
88+
Usage: 0,
89+
})
90+
91+
assert.Len(t, chunks[2].Segments(), 1)
92+
assert.Equal(t, 50.0, chunks[2].TotalGrantUsage().InexactFloat64())
93+
assertChunkUsageAtStart(t, chunks[2], balance.SnapshottedUsage{
94+
Since: start.Add(4 * time.Hour),
95+
Usage: 0,
96+
})
97+
})
98+
99+
t.Run("Final reset does not create empty trailing chunk", func(t *testing.T) {
100+
history, err := engine.NewGrantBurnDownHistory([]engine.GrantBurnDownHistorySegment{
101+
segment(0, 10, true),
102+
}, usageAtStart)
103+
require.NoError(t, err)
104+
105+
chunks := history.ChunkByResets()
106+
require.Len(t, chunks, 1)
107+
assert.Len(t, chunks[0].Segments(), 1)
108+
assert.Equal(t, 10.0, chunks[0].TotalGrantUsage().InexactFloat64())
109+
})
110+
}
111+
112+
func assertChunkUsageAtStart(t *testing.T, history engine.GrantBurnDownHistory, expected balance.SnapshottedUsage) {
113+
t.Helper()
114+
115+
usage, err := history.GetUsageInPeriodUntilSegment(0)
116+
require.NoError(t, err)
117+
assert.Equal(t, expected, usage)
118+
}

0 commit comments

Comments
 (0)