Skip to content

Commit 4a83477

Browse files
authored
feat: unit config conversion support for entitlements - OM-400 (#4712)
1 parent e73a574 commit 4a83477

69 files changed

Lines changed: 1220 additions & 395 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

openmeter/billing/generate.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
package billing
22

3-
//go:generate go run github.com/awalterschulze/goderive
3+
// GODEBUG=gotypesalias=0 keeps type aliases transparent for goderive's go/types
4+
// analysis. On Go 1.22+ an alias (e.g. productcatalog.UnitConfig, which aliases
5+
// unitconfig.UnitConfig) is a first-class types.Alias node that this goderive
6+
// version does not unwrap; without the flag it fails to see the aliased type's
7+
// hand-written Equal method, auto-derives equality instead, and recurses into
8+
// alpacadecimal.Decimal until it OOMs. Remove once goderive unwraps aliases.
9+
//go:generate env GODEBUG=gotypesalias=0 go run github.com/awalterschulze/goderive
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
package stddetailedline
22

3-
//go:generate go run github.com/awalterschulze/goderive
3+
// GODEBUG=gotypesalias=0 keeps type aliases transparent for goderive's go/types
4+
// analysis (Go 1.22+ types.Alias nodes this goderive version does not unwrap).
5+
// See openmeter/billing/generate.go for the full rationale.
6+
//go:generate env GODEBUG=gotypesalias=0 go run github.com/awalterschulze/goderive

openmeter/credit/adapter/balance_snapshot.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ func (b *balanceSnapshotRepo) Save(ctx context.Context, owner models.NamespacedI
6969
SetGrantBalances(balance.Balances).
7070
SetOverage(balance.Overage).
7171
SetUsage(&balance.Usage)
72+
// Record the conversion regime this snapshot was computed under (OM-400) so
73+
// the resume path can refuse to reuse it under a different regime. Pointer
74+
// GoType has no SetNillable*, so nil-guard the set (nil = raw).
75+
if balance.UnitConfig != nil {
76+
command = command.SetUnitConfig(balance.UnitConfig)
77+
}
7278
commands = append(commands, command)
7379
}
7480
_, err := rep.db.BalanceSnapshot.CreateBulk(commands...).Save(ctx)
@@ -85,5 +91,8 @@ func mapBalanceSnapshotEntity(entity *db.BalanceSnapshot) balance.Snapshot {
8591
if entity.Usage != nil {
8692
s.Usage = *entity.Usage
8793
}
94+
if entity.UnitConfig != nil {
95+
s.UnitConfig = entity.UnitConfig
96+
}
8897
return s
8998
}

openmeter/credit/balance.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,11 @@ func (m *connector) getBalanceSinceSnapshot(ctx context.Context, ownerID models.
109109
// TODO: it might be the case that we don't save any snapshots as they require a history breakpoint. To solve this,
110110
// we should introduce artificial history breakpoints in the engine, but that would result in more streaming.Query calls, so first lets improve the visibility of what's happening.
111111
if err := m.snapshotEngineResult(ctx, snapshotParams{
112-
grants: grants,
113-
owner: ownerID,
114-
notAfter: m.getSnapshotNotAfter(periodStart, clock.Now()),
115-
meter: owner.Meter,
112+
grants: grants,
113+
owner: ownerID,
114+
notAfter: m.getSnapshotNotAfter(periodStart, clock.Now()),
115+
meter: owner.Meter,
116+
unitConfig: owner.UnitConfig,
116117
}, result); err != nil {
117118
return def, fmt.Errorf("failed to snapshot engine result: %w", err)
118119
}
@@ -317,10 +318,11 @@ func (m *connector) ResetUsageForOwner(ctx context.Context, ownerID models.Names
317318

318319
// Let's save the snapshot
319320
snap, err = m.saveSnapshot(ctx, snapshotParams{
320-
grants: grants,
321-
owner: ownerID,
322-
notAfter: at,
323-
meter: owner.Meter,
321+
grants: grants,
322+
owner: ownerID,
323+
notAfter: at,
324+
meter: owner.Meter,
325+
unitConfig: owner.UnitConfig,
324326
}, snap)
325327
if err != nil {
326328
return nil, fmt.Errorf("failed to save snapshot: %w", err)

openmeter/credit/balance/balance.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"time"
55

66
"github.com/openmeterio/openmeter/openmeter/credit/grant"
7+
"github.com/openmeterio/openmeter/openmeter/productcatalog/unitconfig"
78
)
89

910
func NewStartingMap(grants []grant.Grant, at time.Time) Map {
@@ -82,6 +83,13 @@ type Snapshot struct {
8283
Balances Map
8384
Overage float64
8485
At time.Time
86+
87+
// UnitConfig records the conversion regime this snapshot's Usage/Balances were
88+
// computed under (OM-400). The resume path only reuses a snapshot whose UnitConfig
89+
// matches the owner's current one; a mismatch (e.g. a future backfill that sets an
90+
// entitlement's unit_config after raw-unit snapshots already exist) forces a
91+
// recompute rather than mixing raw and converted units. Nil = raw (no conversion).
92+
UnitConfig *unitconfig.UnitConfig
8593
}
8694

8795
func (g Snapshot) Balance() float64 {

openmeter/credit/balance/usage.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func (u *usageQuerier) QueryUsage(ctx context.Context, ownerID models.Namespaced
100100
vTo := alpacadecimal.NewFromFloat(valueTo)
101101
vFrom := alpacadecimal.NewFromFloat(valueFrom)
102102

103-
return vTo.Sub(vFrom).InexactFloat64(), nil
103+
return owner.ConvertUsage(vTo.Sub(vFrom).InexactFloat64()), nil
104104

105105
// For other aggregation types we can simply query the meter
106106
case meter.MeterAggregationSum, meter.MeterAggregationCount, meter.MeterAggregationLatest:
@@ -118,7 +118,16 @@ func (u *usageQuerier) QueryUsage(ctx context.Context, ownerID models.Namespaced
118118
return 0.0, fmt.Errorf("failed to query meter %s: %w", owner.Meter.Key, err)
119119
}
120120

121-
return u.getValueFromRows(rows)
121+
usage, err := u.getValueFromRows(rows)
122+
if err != nil {
123+
return 0.0, err
124+
}
125+
126+
// Convert raw metered usage into the entitlement's billing unit (OM-400).
127+
// Identity when the owner has no UnitConfig. Both the engine's usage source
128+
// and the snapshot service's backfill querier route through here, so converted
129+
// and raw usage can never be mixed for a given owner.
130+
return owner.ConvertUsage(usage), nil
122131
default:
123132
return 0.0, fmt.Errorf("unsupported aggregation %s", owner.Meter.Aggregation)
124133
}

openmeter/credit/grant/owner_connector.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import (
55
"fmt"
66
"time"
77

8+
"github.com/alpacahq/alpacadecimal"
9+
810
"github.com/openmeterio/openmeter/openmeter/meter"
11+
"github.com/openmeterio/openmeter/openmeter/productcatalog/unitconfig"
912
"github.com/openmeterio/openmeter/openmeter/streaming"
1013
"github.com/openmeterio/openmeter/pkg/models"
1114
"github.com/openmeterio/openmeter/pkg/timeutil"
@@ -17,6 +20,27 @@ type Owner struct {
1720
DefaultQueryParams streaming.QueryParams
1821
ResetBehavior ResetBehavior
1922
StreamingCustomer streaming.Customer
23+
24+
// UnitConfig, when set, is the rate card's UnitConfig snapshotted onto this
25+
// owner's entitlement. ConvertUsage uses it to map raw metered usage into the
26+
// unit the entitlement's limit and grants are authored in. Nil means identity
27+
// (no unit_config, or the unitConfig feature is disabled), so balances are
28+
// byte-identical to pre-UnitConfig behavior. It is the leaf unitconfig DTO, not
29+
// productcatalog, so credit can reference it without an import cycle.
30+
UnitConfig *unitconfig.UnitConfig
31+
}
32+
33+
// ConvertUsage maps a raw metered usage value into the owner's billing unit using
34+
// its UnitConfig — conversion only, no rounding, so balance checks always use the
35+
// precise value. Returns the raw value unchanged when the owner has no UnitConfig.
36+
func (o Owner) ConvertUsage(rawUsage float64) float64 {
37+
if o.UnitConfig == nil {
38+
return rawUsage
39+
}
40+
41+
converted, _ := o.UnitConfig.Apply(alpacadecimal.NewFromFloat(rawUsage))
42+
43+
return converted.InexactFloat64()
2044
}
2145

2246
type EndCurrentUsagePeriodParams struct {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package grant_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/alpacahq/alpacadecimal"
7+
"github.com/stretchr/testify/assert"
8+
9+
"github.com/openmeterio/openmeter/openmeter/credit/grant"
10+
"github.com/openmeterio/openmeter/openmeter/productcatalog/unitconfig"
11+
)
12+
13+
// TestOwnerConvertUsage covers the OM-400 conversion at the credit boundary: identity
14+
// when the owner has no UnitConfig, and conversion without rounding (balance checks
15+
// always use the precise value) when it does.
16+
func TestOwnerConvertUsage(t *testing.T) {
17+
t.Run("no unit config is identity", func(t *testing.T) {
18+
owner := grant.Owner{}
19+
assert.Equal(t, 99_300_000_000.0, owner.ConvertUsage(99_300_000_000))
20+
})
21+
22+
t.Run("divide converts", func(t *testing.T) {
23+
owner := grant.Owner{
24+
UnitConfig: &unitconfig.UnitConfig{
25+
Operation: unitconfig.UnitConfigOperationDivide,
26+
ConversionFactor: alpacadecimal.NewFromInt(1_000_000_000),
27+
},
28+
}
29+
assert.InDelta(t, 99.3, owner.ConvertUsage(99_300_000_000), 1e-6)
30+
})
31+
32+
t.Run("rounding is ignored — balance uses the precise converted value", func(t *testing.T) {
33+
owner := grant.Owner{
34+
UnitConfig: &unitconfig.UnitConfig{
35+
Operation: unitconfig.UnitConfigOperationDivide,
36+
ConversionFactor: alpacadecimal.NewFromInt(1_000_000_000),
37+
Rounding: unitconfig.UnitConfigRoundingModeCeiling,
38+
},
39+
}
40+
// ceiling would invoice 100; the balance check must see 99.3.
41+
assert.InDelta(t, 99.3, owner.ConvertUsage(99_300_000_000), 1e-6)
42+
})
43+
}

openmeter/credit/helper.go

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/openmeterio/openmeter/openmeter/credit/engine"
1616
"github.com/openmeterio/openmeter/openmeter/credit/grant"
1717
"github.com/openmeterio/openmeter/openmeter/meter"
18+
"github.com/openmeterio/openmeter/openmeter/productcatalog/unitconfig"
1819
"github.com/openmeterio/openmeter/openmeter/streaming"
1920
"github.com/openmeterio/openmeter/pkg/framework/transaction"
2021
"github.com/openmeterio/openmeter/pkg/models"
@@ -33,29 +34,56 @@ func (m *connector) GetLastValidSnapshotAt(ctx context.Context, owner models.Nam
3334
// if no snapshot is found we have to calculate from start of time on all grants and usage
3435
m.Logger.Debug("no saved balance found for owner, calculating from start of time", "owner", owner.ID, "at", at)
3536

36-
startOfMeasurement, err := m.OwnerConnector.GetStartOfMeasurement(ctx, owner)
37-
if err != nil {
38-
return bal, err
39-
}
37+
return m.startOfMeasurementSnapshot(ctx, owner, at)
38+
}
4039

41-
grants, err := m.GrantRepo.ListActiveGrantsBetween(ctx, owner, startOfMeasurement, at)
42-
if err != nil {
43-
return bal, err
44-
}
40+
return bal, fmt.Errorf("failed to get latest valid grant balance at %s for owner %s: %w", at, owner.ID, err)
41+
}
4542

46-
bal = balance.Snapshot{
47-
At: startOfMeasurement,
48-
Balances: balance.NewStartingMap(grants, startOfMeasurement),
49-
Overage: 0.0, // There cannot be overage at the start of measurement
50-
}
51-
} else {
52-
return bal, fmt.Errorf("failed to get latest valid grant balance at %s for owner %s: %w", at, owner.ID, err)
53-
}
43+
// OM-400: only resume from a snapshot computed under the owner's CURRENT conversion
44+
// regime. If they differ — e.g. a future backfill set the entitlement's unit_config
45+
// after raw-unit snapshots already existed — resuming would combine raw baseline
46+
// state with converted incremental usage. Recompute from the start of measurement in
47+
// the current regime instead. This is the enforced invariant behind Option A's
48+
// "an entitlement converts for its whole life or never" (it also covers a flag flip).
49+
regime, err := m.OwnerConnector.DescribeOwner(ctx, owner)
50+
if err != nil {
51+
return bal, fmt.Errorf("failed to describe owner %s: %w", owner.ID, err)
52+
}
53+
54+
if !bal.UnitConfig.Equal(regime.UnitConfig) {
55+
m.Logger.WarnContext(ctx, "balance snapshot unit_config regime does not match owner; recomputing from start of measurement",
56+
"owner", owner.ID, "at", at)
57+
58+
return m.startOfMeasurementSnapshot(ctx, owner, at)
5459
}
5560

5661
return bal, nil
5762
}
5863

64+
// startOfMeasurementSnapshot builds a fresh zero snapshot at the owner's start of
65+
// measurement, used when no usable snapshot exists (none saved, or the latest one was
66+
// computed under a different unit_config regime — see GetLastValidSnapshotAt). Its
67+
// UnitConfig is left nil because it carries no usage yet; the recomputed result saved
68+
// afterwards is stamped with the current regime (see saveSnapshot / snapshotParams).
69+
func (m *connector) startOfMeasurementSnapshot(ctx context.Context, owner models.NamespacedID, at time.Time) (balance.Snapshot, error) {
70+
startOfMeasurement, err := m.OwnerConnector.GetStartOfMeasurement(ctx, owner)
71+
if err != nil {
72+
return balance.Snapshot{}, err
73+
}
74+
75+
grants, err := m.GrantRepo.ListActiveGrantsBetween(ctx, owner, startOfMeasurement, at)
76+
if err != nil {
77+
return balance.Snapshot{}, err
78+
}
79+
80+
return balance.Snapshot{
81+
At: startOfMeasurement,
82+
Balances: balance.NewStartingMap(grants, startOfMeasurement),
83+
Overage: 0.0, // There cannot be overage at the start of measurement
84+
}, nil
85+
}
86+
5987
func (m *connector) runEngineInSpan(ctx context.Context, eng engine.Engine, runParams engine.RunParams) (engine.RunResult, error) {
6088
ctx, span := m.Tracer.Start(ctx, "credit.runEngine", cTrace.WithEngineParams(runParams))
6189
defer span.End()
@@ -170,6 +198,9 @@ type snapshotParams struct {
170198
owner models.NamespacedID
171199
// Snapshot is saved if the segment is not after this time & the start of the current usage period (at time of snapshot)
172200
notAfter time.Time
201+
// unitConfig is the owner's current conversion regime, stamped onto the saved
202+
// snapshot so the resume path can detect a later regime change (OM-400). Nil = raw.
203+
unitConfig *unitconfig.UnitConfig
173204
}
174205

175206
// It is assumed that there are no snapshots persisted during the length of the history (as engine.Run starts with a snapshot that should be the last valid snapshot)
@@ -228,6 +259,10 @@ func (m *connector) saveSnapshot(ctx context.Context, params snapshotParams, sna
228259
return snap, fmt.Errorf("failed to remove inactive grants from snapshot: %w", err)
229260
}
230261

262+
// Stamp the regime this snapshot was computed under so the resume path can reject it
263+
// if the owner's unit_config later changes (OM-400).
264+
snap.UnitConfig = params.unitConfig
265+
231266
if err := m.BalanceSnapshotService.Save(ctx, params.owner, []balance.Snapshot{snap}); err != nil {
232267
return snap, fmt.Errorf("failed to save snapshot: %w", err)
233268
}

openmeter/ent/db/addonratecard.go

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)