@@ -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+
5987func (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 }
0 commit comments