Skip to content

Commit 7c567bf

Browse files
easyCZroboquat
authored andcommitted
[usage] Refactor credit calculation into a WorkspacePricer
1 parent 70150e2 commit 7c567bf

File tree

9 files changed

+123
-82
lines changed

9 files changed

+123
-82
lines changed

components/usage/cmd/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func run() *cobra.Command {
6464
if err != nil {
6565
log.WithError(err).Fatal("Failed to initialize Stripe client.")
6666
}
67-
billingController = controller.NewStripeBillingController(c)
67+
billingController = controller.NewStripeBillingController(c, controller.DefaultWorkspacePricer)
6868
}
6969

7070
ctrl, err := controller.New(schedule, controller.NewUsageReconciler(conn, billingController))

components/usage/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ require (
5858
github.com/gitpod-io/gitpod/common-go v0.0.0-00010101000000-000000000000
5959
github.com/go-sql-driver/mysql v1.6.0
6060
github.com/google/uuid v1.1.2
61+
github.com/prometheus/client_golang v1.12.1
6162
github.com/relvacode/iso8601 v1.1.0
6263
github.com/robfig/cron v1.2.0
6364
github.com/sirupsen/logrus v1.8.1
@@ -84,7 +85,6 @@ require (
8485
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
8586
github.com/opentracing/opentracing-go v1.2.0 // indirect
8687
github.com/pmezard/go-difflib v1.0.0 // indirect
87-
github.com/prometheus/client_golang v1.12.1 // indirect
8888
github.com/prometheus/client_model v0.2.0 // indirect
8989
github.com/prometheus/common v0.32.1 // indirect
9090
github.com/prometheus/procfs v0.7.3 // indirect

components/usage/pkg/controller/billing.go

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ package controller
66

77
import (
88
"context"
9+
"fmt"
910
"github.com/gitpod-io/gitpod/usage/pkg/stripe"
11+
"math"
1012
"time"
1113
)
1214

@@ -19,14 +21,53 @@ type NoOpBillingController struct{}
1921
func (b *NoOpBillingController) Reconcile(_ context.Context, _ time.Time, _ UsageReport) {}
2022

2123
type StripeBillingController struct {
22-
sc *stripe.Client
24+
pricer *WorkspacePricer
25+
sc *stripe.Client
2326
}
2427

25-
func NewStripeBillingController(sc *stripe.Client) *StripeBillingController {
26-
return &StripeBillingController{sc: sc}
28+
func NewStripeBillingController(sc *stripe.Client, pricer *WorkspacePricer) *StripeBillingController {
29+
return &StripeBillingController{
30+
sc: sc,
31+
pricer: pricer,
32+
}
2733
}
2834

2935
func (b *StripeBillingController) Reconcile(ctx context.Context, now time.Time, report UsageReport) {
30-
runtimeReport := report.RuntimeSummaryForTeams(now)
36+
runtimeReport := report.CreditSummaryForTeams(b.pricer, now)
3137
b.sc.UpdateUsage(runtimeReport)
3238
}
39+
40+
const (
41+
defaultWorkspaceClass = "default"
42+
)
43+
44+
var (
45+
DefaultWorkspacePricer, _ = NewWorkspacePricer(map[string]float64{
46+
// 1 credit = 6 minutes
47+
"default": float64(1) / float64(6),
48+
})
49+
)
50+
51+
func NewWorkspacePricer(creditMinutesByWorkspaceClass map[string]float64) (*WorkspacePricer, error) {
52+
if _, ok := creditMinutesByWorkspaceClass[defaultWorkspaceClass]; !ok {
53+
return nil, fmt.Errorf("credits per minute not defined for expected workspace class 'default'")
54+
}
55+
56+
return &WorkspacePricer{creditMinutesByWorkspaceClass: creditMinutesByWorkspaceClass}, nil
57+
}
58+
59+
type WorkspacePricer struct {
60+
creditMinutesByWorkspaceClass map[string]float64
61+
}
62+
63+
func (p *WorkspacePricer) Credits(workspaceClass string, runtimeInSeconds int64) int64 {
64+
inMinutes := float64(runtimeInSeconds) / 60
65+
return int64(math.Ceil(p.CreditsPerMinuteForClass(workspaceClass) * inMinutes))
66+
}
67+
68+
func (p *WorkspacePricer) CreditsPerMinuteForClass(workspaceClass string) float64 {
69+
if creditsForClass, ok := p.creditMinutesByWorkspaceClass[workspaceClass]; ok {
70+
return creditsForClass
71+
}
72+
return p.creditMinutesByWorkspaceClass[defaultWorkspaceClass]
73+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package controller
6+
7+
import (
8+
"github.com/stretchr/testify/require"
9+
"testing"
10+
)
11+
12+
func TestWorkspacePricer_Default(t *testing.T) {
13+
testCases := []struct {
14+
Name string
15+
Seconds int64
16+
ExpectedCredits int64
17+
}{
18+
{
19+
Name: "0 seconds",
20+
Seconds: 0,
21+
ExpectedCredits: 0,
22+
},
23+
{
24+
Name: "1 second",
25+
Seconds: 1,
26+
ExpectedCredits: 1,
27+
},
28+
{
29+
Name: "60 seconds",
30+
Seconds: 60,
31+
ExpectedCredits: 1,
32+
},
33+
{
34+
Name: "90 seconds",
35+
Seconds: 90,
36+
ExpectedCredits: 1,
37+
},
38+
{
39+
Name: "6 minutes",
40+
Seconds: 360,
41+
ExpectedCredits: 1,
42+
},
43+
{
44+
Name: "6 minutes and 1 second",
45+
Seconds: 361,
46+
ExpectedCredits: 2,
47+
},
48+
{
49+
Name: "1 hour",
50+
Seconds: 3600,
51+
ExpectedCredits: 10,
52+
},
53+
}
54+
55+
for _, tc := range testCases {
56+
t.Run(tc.Name, func(t *testing.T) {
57+
actualCredits := DefaultWorkspacePricer.Credits(defaultWorkspaceClass, tc.Seconds)
58+
59+
require.Equal(t, tc.ExpectedCredits, actualCredits)
60+
})
61+
}
62+
}

components/usage/pkg/controller/reconciler.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,24 +116,26 @@ func (u *UsageReconciler) ReconcileTimeRange(ctx context.Context, from, to time.
116116

117117
type UsageReport map[db.AttributionID][]db.WorkspaceInstance
118118

119-
func (u UsageReport) RuntimeSummaryForTeams(maxStopTime time.Time) map[string]int64 {
120-
attributedUsage := map[string]int64{}
119+
func (u UsageReport) CreditSummaryForTeams(pricer *WorkspacePricer, maxStopTime time.Time) map[string]int64 {
120+
creditsPerTeamID := map[string]int64{}
121121

122122
for attribution, instances := range u {
123123
entity, id := attribution.Values()
124124
if entity != db.AttributionEntity_Team {
125125
continue
126126
}
127127

128-
var runtime uint64
128+
var credits int64
129129
for _, instance := range instances {
130-
runtime += instance.WorkspaceRuntimeSeconds(maxStopTime)
130+
runtime := instance.WorkspaceRuntimeSeconds(maxStopTime)
131+
class := "default"
132+
credits += pricer.Credits(class, runtime)
131133
}
132134

133-
attributedUsage[id] = int64(runtime)
135+
creditsPerTeamID[id] = credits
134136
}
135137

136-
return attributedUsage
138+
return creditsPerTeamID
137139
}
138140

139141
type invalidWorkspaceInstance struct {

components/usage/pkg/controller/reconciler_test.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,6 @@ func TestUsageReconciler_ReconcileTimeRange(t *testing.T) {
4646
}),
4747
}
4848

49-
expectedRuntime := instances[0].WorkspaceRuntimeSeconds(scenarioRunTime) + instances[1].WorkspaceRuntimeSeconds(scenarioRunTime)
50-
5149
conn := dbtest.ConnectForTests(t)
5250
dbtest.CreateWorkspaceInstances(t, conn, instances...)
5351

@@ -66,7 +64,4 @@ func TestUsageReconciler_ReconcileTimeRange(t *testing.T) {
6664
WorkspaceInstances: 2,
6765
InvalidWorkspaceInstances: 1,
6866
}, status)
69-
require.Equal(t, map[string]int64{
70-
teamID.String(): int64(expectedRuntime),
71-
}, report.RuntimeSummaryForTeams(scenarioRunTime))
7267
}

components/usage/pkg/db/workspace_instance.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ type WorkspaceInstance struct {
4747

4848
// WorkspaceRuntimeSeconds computes how long this WorkspaceInstance has been running.
4949
// If the instance is still running (no stop time set), maxStopTime is used to to compute the duration - this is an upper bound on stop
50-
func (i *WorkspaceInstance) WorkspaceRuntimeSeconds(maxStopTime time.Time) uint64 {
50+
func (i *WorkspaceInstance) WorkspaceRuntimeSeconds(maxStopTime time.Time) int64 {
5151
start := i.CreationTime.Time()
5252
stop := maxStopTime
5353

@@ -57,7 +57,7 @@ func (i *WorkspaceInstance) WorkspaceRuntimeSeconds(maxStopTime time.Time) uint6
5757
}
5858
}
5959

60-
return uint64(stop.Sub(start).Round(time.Second).Seconds())
60+
return int64(stop.Sub(start).Round(time.Second).Seconds())
6161
}
6262

6363
// TableName sets the insert table name for this struct type

components/usage/pkg/stripe/stripe.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ package stripe
66

77
import (
88
"fmt"
9-
"math"
109
"strings"
1110

1211
"github.com/gitpod-io/gitpod/common-go/log"
@@ -30,9 +29,9 @@ func New(config ClientConfig) (*Client, error) {
3029

3130
// UpdateUsage updates teams' Stripe subscriptions with usage data
3231
// `usageForTeam` is a map from team name to total workspace seconds used within a billing period.
33-
func (c *Client) UpdateUsage(usageForTeam map[string]int64) error {
34-
teamIds := make([]string, 0, len(usageForTeam))
35-
for k := range usageForTeam {
32+
func (c *Client) UpdateUsage(creditsPerTeam map[string]int64) error {
33+
teamIds := make([]string, 0, len(creditsPerTeam))
34+
for k := range creditsPerTeam {
3635
teamIds = append(teamIds, k)
3736
}
3837
queries := queriesForCustomersWithTeamIds(teamIds)
@@ -62,7 +61,7 @@ func (c *Client) UpdateUsage(usageForTeam map[string]int64) error {
6261
continue
6362
}
6463

65-
creditsUsed := workspaceSecondsToCredits(usageForTeam[customer.Metadata["teamId"]])
64+
creditsUsed := creditsPerTeam[customer.Metadata["teamId"]]
6665

6766
subscriptionItemId := subscription.Items.Data[0].ID
6867
log.Infof("registering usage against subscriptionItem %q", subscriptionItemId)
@@ -99,9 +98,3 @@ func queriesForCustomersWithTeamIds(teamIds []string) []string {
9998

10099
return queries
101100
}
102-
103-
// workspaceSecondsToCredits converts seconds (of workspace usage) into Stripe credits.
104-
// (1 credit = 6 minutes, rounded up)
105-
func workspaceSecondsToCredits(seconds int64) int64 {
106-
return int64(math.Ceil(float64(seconds) / (60 * 6)))
107-
}

components/usage/pkg/stripe/stripe_test.go

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -83,55 +83,3 @@ func TestCustomerQueriesForTeamIds_MultipleQueries(t *testing.T) {
8383
})
8484
}
8585
}
86-
87-
func TestWorkspaceSecondsToCreditsCalcuation(t *testing.T) {
88-
testCases := []struct {
89-
Name string
90-
Seconds int64
91-
ExpectedCredits int64
92-
}{
93-
{
94-
Name: "0 seconds",
95-
Seconds: 0,
96-
ExpectedCredits: 0,
97-
},
98-
{
99-
Name: "1 second",
100-
Seconds: 1,
101-
ExpectedCredits: 1,
102-
},
103-
{
104-
Name: "60 seconds",
105-
Seconds: 60,
106-
ExpectedCredits: 1,
107-
},
108-
{
109-
Name: "90 seconds",
110-
Seconds: 90,
111-
ExpectedCredits: 1,
112-
},
113-
{
114-
Name: "6 minutes",
115-
Seconds: 360,
116-
ExpectedCredits: 1,
117-
},
118-
{
119-
Name: "6 minutes and 1 second",
120-
Seconds: 361,
121-
ExpectedCredits: 2,
122-
},
123-
{
124-
Name: "1 hour",
125-
Seconds: 3600,
126-
ExpectedCredits: 10,
127-
},
128-
}
129-
130-
for _, tc := range testCases {
131-
t.Run(tc.Name, func(t *testing.T) {
132-
actualCredits := workspaceSecondsToCredits(tc.Seconds)
133-
134-
require.Equal(t, tc.ExpectedCredits, actualCredits)
135-
})
136-
}
137-
}

0 commit comments

Comments
 (0)