Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CORE-2016: support scheduled rate and quota default changes. #63

Merged
merged 29 commits into from
Dec 16, 2024

Conversation

slr71
Copy link
Collaborator

@slr71 slr71 commented Oct 29, 2024

This change allows both rates and default quota values to be updated for a plan. Plan rates are a new feature. The rate associated with the plan is associated with the subscription plan. Multiple rates can be associated with the plan, and the currently active plan rate is always the one with the most recent effective date that is on or before the current date. Plan quota defaults are implemented in the same way, but there's a different active plan quota default for each resource type.

slr71 and others added 17 commits October 1, 2024 17:16
…plans/:plan-id/active-quota-defaults endpoints
…nd multiple scheduled plan quota default updates
Comment on lines +1 to +5
#!/usr/bin/env bash
QMS_DATABASE_URI=postgres://de@localhost/qms?sslmode=disable
QMS_DATABASE_MIGRATE=false
QMS_DATABASE_REINIT=false
QMS_USERNAME_SUFFIX=iplantcollaborative.org
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've found direnv to be extremely useful for development testing, so I thought I'd set it up for this repository.

@@ -152,11 +153,10 @@ func (s Server) AddPlan(ctx echo.Context) error {

log.Debugf("adding plan quota default resource %s to plan %s", resourceType.Name, plan.Name)
}

log.Debug("adding plan to the database")
log.Debugf("translated plan: %+v", dbPlan)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on the fence about leaving this log message in place.

@@ -133,11 +125,20 @@ func (s Server) AddPlan(ctx echo.Context) error {
}

log = log.WithFields(logrus.Fields{"plan": plan.Name})
log.Debug("adding plan to the database")
log.Debugf("adding a new plan to the database: %+v", plan)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on the fence about logging all of the plan details in this message.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that for this and the other log messages, as long as they're on Debug level anyway, I'm not that worried. They won't be running with that in prod anyway.

Comment on lines +24 to +28
Preload("PlanQuotaDefaults", func(db *gorm.DB) *gorm.DB {
return db.
Joins("INNER JOIN resource_types ON plan_quota_defaults.resource_type_id = resource_types.id").
Order("plan_quota_defaults.effective_date asc, resource_types.name asc")
}).
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of this is just to set the sort order. I was a little afraid that the join would cause problems with field value resolution, but it seems to be working.

Comment on lines +141 to +142
Select("DISTINCT ON (resource_type_id) resource_type_id", "id", "plan_id", "quota_value", "effective_date").
Where("effective_date <= CURRENT_TIMESTAMP AND plan_id = ?", planID).
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to do what I want it to do, but SQL sanity checks are always a good thing. 😄

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like I'm not quite following what this does. I'd expect from the name for it to return only one default for each resource type in the active plan, but it seems like if there were to be two defaults with different effective dates but both in the past, it'd return both of them. But there's possibly something clever I'm missing, heh.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fortunately, it's only grabbing the most recent one that has an effective date before the current date, which is what I want it to do. If I'm understanding it correctly, it works because the DISTINCT ON forces it to grab only the first matching row satisfying the conditions for each resource type. The sort order then ensures that the most recent quota default for each resource type is the one that gets chosen.

Copy link
Collaborator

@ianmcorvidae ianmcorvidae Nov 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes, I see. I confirmed with the PostgreSQL docs and it does explicitly say there that it's the first row and which row is first should be controlled by ORDER BY, so seems perfect.

LGTM now that I wrapped my head around it 😁

@@ -6,6 +6,6 @@ BEGIN;

SET search_path = public, pg_catalog;

ALTER TABLE resource_types DROP IF EXISTS consumable CASCADE;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd forgotten to add the existence check here before.

@@ -6,7 +6,7 @@ BEGIN;

SET search_path = public, pg_catalog;

ALTER TABLE resource_types ADD IF NOT EXISTS consumable boolean DEFAULT FALSE;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd forgotten to add the existence check here before as well.

Comment on lines -48 to -49
// Lists all of the active user plans.
users.GET("/all_active_users", s.GetAllActiveSubscriptions)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint has never been used as far as I'm aware.

Comment on lines -60 to -64
// GET /:username/resources/overages returns summaries of any usages that exceed the quota for the corresponding resource.
users.GET("/:username/resources/overages", s.GetUserOverages)

// GET /:username/resources/:resource-name/overage returns whether the usage exceeds the quota for the resource.
users.GET("/:username/resources/:resource-name/in-overage", s.InOverage)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overage endpoints are now implemented in the subscriptions service.

Comment on lines -80 to -81
// Adds or updates the quota defaults of a plan.
plans.POST("/quota-defaults", s.AddPlanQuotaDefault)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old endpoint to add plan quota defaults was a little clunky, so I reimplemented it.

@slr71 slr71 marked this pull request as ready for review October 31, 2024 00:11
Copy link
Collaborator

@ianmcorvidae ianmcorvidae left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel like I totally grok how gorm works, but other than a few comments on style stuff, this appears basically fine to me. I'm not really following the spot you asked for SQL feedback, so I might be able to review that more carefully with a bit more understanding.

}

// GetDefaultQuotaValue returns the default quota value associated with the resource type with the given name.
// GetDefaultQuotaValue returns the default quota value associated with the resource type with the given name. This
// funciton assumes that the plan quota defaults ar sorted in ascending order by effective date.
func (p *Plan) GetDefaultQuotaValue(resourcetypeName string) float64 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is helpful or not, but it'd at least cut down lines of code if this function was implemented using the newly-added GetDefaultQuotaValues instead. Really the only thing this function is doing differently is a conditional on the resource type name rather than using that name as a key, and I believe it'll look at the same collection of quota defaults since they both just range over it and break when they pass time.Now().

(the comment also has a couple typos, which doesn't really matter at all)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely helpful. Thanks for the suggestion!

@@ -133,11 +125,20 @@ func (s Server) AddPlan(ctx echo.Context) error {
}

log = log.WithFields(logrus.Fields{"plan": plan.Name})
log.Debug("adding plan to the database")
log.Debugf("adding a new plan to the database: %+v", plan)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that for this and the other log messages, as long as they're on Debug level anyway, I'm not that worried. They won't be running with that in prod anyway.

Comment on lines +141 to +142
Select("DISTINCT ON (resource_type_id) resource_type_id", "id", "plan_id", "quota_value", "effective_date").
Where("effective_date <= CURRENT_TIMESTAMP AND plan_id = ?", planID).
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like I'm not quite following what this does. I'd expect from the name for it to return only one default for each resource type in the active plan, but it seems like if there were to be two defaults with different effective dates but both in the past, it'd return both of them. But there's possibly something clever I'm missing, heh.

Comment on lines +25 to +35
currentIndex := 0
for _, quotaDefault := range pqds {
quotaValue := quotaDefault.QuotaValue
if quotaDefault.ResourceType.Consumable {
quotaValue *= float64(periods)
}
result[i] = model.Quota{
result[currentIndex] = model.Quota{
Quota: quotaValue,
ResourceTypeID: quotaDefault.ResourceTypeID,
}
currentIndex++
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm missing if there's a benefit to managing the currentIndex outside of the loop, since it appears to unconditionally increment it at the end of the loop iteration. It seems like this could be done more cleanly with for currentIndex, quotaDefault := range pqds (more similarly to the old code)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason in this case is because the plan quota defaults are now in a map to ensure that we only have one plan quota default for each resource type.

I considered making another call to the database to get the active plan quota defaults, which would turn the data structure back into an array, but since we already have everything we need in memory, finding the correct plan quota defaults in code seemed to be the better option just to avoid another round trip to the database. I'm still on the fence about this decision, though. If enough plan quota defaults are added then the time saved by avoiding a round trip to the database might eventually become negligible (or be eliminated entirely).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! That makes sense. I had failed to notice it was a map, and thus wouldn't have indices to use.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I'd forgotten about it too and noticed it when I started to update the code. 😄

Copy link
Collaborator Author

@slr71 slr71 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review!

Comment on lines +141 to +142
Select("DISTINCT ON (resource_type_id) resource_type_id", "id", "plan_id", "quota_value", "effective_date").
Where("effective_date <= CURRENT_TIMESTAMP AND plan_id = ?", planID).
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fortunately, it's only grabbing the most recent one that has an effective date before the current date, which is what I want it to do. If I'm understanding it correctly, it works because the DISTINCT ON forces it to grab only the first matching row satisfying the conditions for each resource type. The sort order then ensures that the most recent quota default for each resource type is the one that gets chosen.

Comment on lines +25 to +35
currentIndex := 0
for _, quotaDefault := range pqds {
quotaValue := quotaDefault.QuotaValue
if quotaDefault.ResourceType.Consumable {
quotaValue *= float64(periods)
}
result[i] = model.Quota{
result[currentIndex] = model.Quota{
Quota: quotaValue,
ResourceTypeID: quotaDefault.ResourceTypeID,
}
currentIndex++
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason in this case is because the plan quota defaults are now in a map to ensure that we only have one plan quota default for each resource type.

I considered making another call to the database to get the active plan quota defaults, which would turn the data structure back into an array, but since we already have everything we need in memory, finding the correct plan quota defaults in code seemed to be the better option just to avoid another round trip to the database. I'm still on the fence about this decision, though. If enough plan quota defaults are added then the time saved by avoiding a round trip to the database might eventually become negligible (or be eliminated entirely).

@slr71
Copy link
Collaborator Author

slr71 commented Dec 16, 2024

Thanks for the review!

@slr71 slr71 merged commit 20f9dc4 into cyverse:main Dec 16, 2024
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants