File size: 15,480 Bytes
16cdcb7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 | package service
import (
"context"
"fmt"
"github.com/samber/lo"
"github.com/openmeterio/openmeter/openmeter/entitlement"
"github.com/openmeterio/openmeter/openmeter/subscription"
"github.com/openmeterio/openmeter/pkg/convert"
"github.com/openmeterio/openmeter/pkg/framework/transaction"
"github.com/openmeterio/openmeter/pkg/models"
)
// Sync manages the synchronization of a subscription with a new spec.
// It consists of 3 steps:
// 1. Remove anything that's changed or got removed
// 2. Create anything that's been changed
// 3. Create anything that's new
//
// Some remarks:
// 1. Change comparison is done on the relevant create inputs.
// 2. Things being deleted are marked via a `touched` map.
//
// TODO: localize error so phase and item keys are always included (alongside subscription reference)
// TODO (OM-1074): clean up this control flow
func (s *service) sync(ctx context.Context, view subscription.SubscriptionView, newSpec subscription.SubscriptionSpec) (subscription.Subscription, error) {
return transaction.Run(ctx, s.TransactionManager, func(ctx context.Context) (subscription.Subscription, error) {
var def subscription.Subscription
// Some sanity checks for good measure
if view.Subscription.CustomerId != newSpec.CustomerId {
return def, fmt.Errorf("cannot change customer id")
}
if !view.Subscription.PlanRef.NilEqual(newSpec.Plan) {
return def, fmt.Errorf("cannot change plan")
}
if !view.Subscription.ActiveFrom.Equal(newSpec.ActiveFrom) {
return def, fmt.Errorf("cannot change subscription start")
}
if view.Subscription.SettlementMode != newSpec.SettlementMode {
return def, fmt.Errorf("cannot change settlement mode")
}
dirty := make(touched)
// Let's make sure the Subscription Cadence is up to date
if !view.Subscription.CadencedModel.Equal(models.CadencedModel{ActiveFrom: newSpec.ActiveFrom, ActiveTo: newSpec.ActiveTo}) {
_, err := s.SubscriptionRepo.SetEndOfCadence(ctx, view.Subscription.NamespacedID, newSpec.ActiveTo)
if err != nil {
return def, fmt.Errorf("failed to set end of cadence: %w", err)
}
}
// 1. Let's remove anything that's changed or got removed
newSortedPhaseSpecs := newSpec.GetSortedPhases()
for _, currentPhaseView := range view.Phases {
// Let's try find a matching phase in the new spec
matchingPhaseFromNewSpec, found := lo.Find(newSortedPhaseSpecs, func(s *subscription.SubscriptionPhaseSpec) bool {
return s.PhaseKey == currentPhaseView.SubscriptionPhase.Key
})
// If there's no equivalent found in the current spec, we need to delete the phase
if !found {
if err := s.deletePhase(ctx, currentPhaseView); err != nil {
return def, fmt.Errorf("failed to delete phase: %w", err)
}
dirty.mark(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key))
// There's nothing more to be done for this phase, so lets skip to the next one
continue
}
// sanity check
if matchingPhaseFromNewSpec == nil {
return def, fmt.Errorf("failed to find matching phase in new spec but no error was returned")
}
// Let's get the cadence of the current phase
cadenceOfCurrentPhaseBasedOnSpec, err := view.Spec.GetPhaseCadence(currentPhaseView.SubscriptionPhase.Key)
if err != nil {
return def, fmt.Errorf("failed to get cadence for current phase %s: %w", currentPhaseView.SubscriptionPhase.Key, err)
}
// Let's get the cadence of the new phase
cadenceOfNewPhaseBasedOnSpec, err := newSpec.GetPhaseCadence(matchingPhaseFromNewSpec.PhaseKey)
if err != nil {
return def, fmt.Errorf("failed to get cadence for new phase %s: %w", matchingPhaseFromNewSpec.PhaseKey, err)
}
// Lets figure out when the new phase should start
newPhaseStartTime, _ := matchingPhaseFromNewSpec.StartAfter.AddTo(view.Subscription.ActiveFrom)
curr := currentPhaseView.Spec.ToCreateSubscriptionPhaseEntityInput(view.Subscription, currentPhaseView.SubscriptionPhase.ActiveFrom)
new := matchingPhaseFromNewSpec.ToCreateSubscriptionPhaseEntityInput(view.Subscription, newPhaseStartTime)
// If the phase has any changes, we need to recreate it. That means also all sub-resources of it have to be relinked.
if !curr.Equal(new) {
// This means deleting the phase with all its sub-resources
if err := s.deletePhase(ctx, currentPhaseView); err != nil {
return def, fmt.Errorf("failed to delete phase: %w", err)
}
dirty.mark(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key))
// The phase is deleted, there's nothing more to be done
continue
}
// Sanity check, the current phase cannot be dirty
if dirty.isTouched(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) {
return def, fmt.Errorf("current phase is dirty but should not be")
}
// Now let's iterate through all items in the phase
for currentItemViewsKey, currentItemViews := range currentPhaseView.ItemsByKey {
// Let's try find a matching item in the new spec
// Here as we do an update, we rely on the previously verified integrity of both view and spec
// Due to this, we use a simple matching based on the index of the item under the given key
matchingItemsByKeyFromNewSpec, found := matchingPhaseFromNewSpec.ItemsByKey[currentItemViewsKey]
// If no matching key is found in the new spec lets delete everything
if !found || len(matchingItemsByKeyFromNewSpec) == 0 {
for _, currentItemView := range currentItemViews {
if err := s.deleteItem(ctx, currentItemView); err != nil {
return def, fmt.Errorf("failed to delete item: %w", err)
}
dirty.mark(subscription.NewItemPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey))
}
// There's nothing more to be done for this item(key), so lets skip to the next one
continue
}
for currentItemIdx, currentItemView := range currentItemViews {
// Let's get the item with the same index from the new spec
if currentItemIdx >= len(matchingItemsByKeyFromNewSpec) {
// Let's delete the item as it's not present in the new spec
if err := s.deleteItem(ctx, currentItemView); err != nil {
return def, fmt.Errorf("failed to delete item: %w", err)
}
dirty.mark(subscription.NewItemVersionPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx))
// There's nothing more to be done for this item, so lets skip to the next one
continue
}
matchingItemFromNewSpec := matchingItemsByKeyFromNewSpec[currentItemIdx]
// First, let's check if the item itself needs to be changed
curr, err := currentItemView.Spec.ToCreateSubscriptionItemEntityInput(
currentPhaseView.SubscriptionPhase.NamespacedID,
cadenceOfCurrentPhaseBasedOnSpec,
convert.SafeDeRef(currentItemView.Entitlement, func(s subscription.SubscriptionEntitlement) *entitlement.Entitlement {
return &s.Entitlement.Entitlement
}),
)
if err != nil {
return def, fmt.Errorf("failed to convert item to entity input: %w", err)
}
// Here we don't preamptively know all the properties but fortunately all we need to know is whether they'd change or not
// We're prepopulating changing fields with invalid values, which is a lie and a bad method, but it's necessary for now due to the hard linking
newPhaseID := currentPhaseView.SubscriptionPhase.NamespacedID
if dirty.isTouched(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) {
newPhaseID = impossibleNamespacedId
}
newOnlyForComparisonWithInvalidProperties, err := matchingItemFromNewSpec.ToCreateSubscriptionItemEntityInput(
newPhaseID,
cadenceOfNewPhaseBasedOnSpec,
// Without the "new" entitlement already present we cannot properly compare the two create inputs.
// To work around this, we'll reuse the current entitlement for comparison.
// This won't cause an issue as all relevant properties of the entitlement are specced on the Item (as part of RateCard)
// FIXME: This is a lie
convert.SafeDeRef(currentItemView.Entitlement, func(s subscription.SubscriptionEntitlement) *entitlement.Entitlement {
return &s.Entitlement.Entitlement
}),
)
if err != nil {
return def, fmt.Errorf("failed to convert item to entity input: %w", err)
}
doesItemNeedToBeChanged := !curr.Equal(newOnlyForComparisonWithInvalidProperties)
if doesItemNeedToBeChanged {
// This means deleting the item with all its sub-resources
if err := s.deleteItem(ctx, currentItemView); err != nil {
return def, fmt.Errorf("failed to delete item: %w", err)
}
dirty.mark(subscription.NewItemVersionPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx))
// There's nothing more to be done here, so lets skip to the next one
continue
}
}
}
}
// 2. Let's create anything that's been changed
for _, currentPhaseView := range view.Phases {
// Let's try find a matching phase in the new spec
matchingPhaseFromNewSpec, found := lo.Find(newSortedPhaseSpecs, func(s *subscription.SubscriptionPhaseSpec) bool {
return s.PhaseKey == currentPhaseView.SubscriptionPhase.Key
})
if !found {
// If the phase wasn't found there's nothing to create
continue
}
// Sanity check
if matchingPhaseFromNewSpec == nil {
return def, fmt.Errorf("failed to find matching phase in new spec but no error was returned")
}
newPhaseCadence, err := newSpec.GetPhaseCadence(matchingPhaseFromNewSpec.PhaseKey)
if err != nil {
return def, fmt.Errorf("failed to get cadence for phase %s: %w", matchingPhaseFromNewSpec.PhaseKey, err)
}
// If the phase got deleted, we can create it as a whole
if dirty.isTouched(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) {
if _, err := s.createPhase(ctx, view.Customer, *matchingPhaseFromNewSpec, view.Subscription, newPhaseCadence); err != nil {
return def, fmt.Errorf("failed to create phase: %w", err)
}
// There's nothing more to be done for this phase, so lets skip to the next one
continue
}
// Now let's check each of the items in the phase
for currentItemViewsKey, currentItemViews := range currentPhaseView.ItemsByKey {
// Let's try find a matching item in the new spec
// Here as we do an update, we rely on the previously verified integrity of both view and spec
// Due to this, we use a simple matching based on the index of the item under the given key
matchingItemsByKeyFromNewSpec, found := matchingPhaseFromNewSpec.ItemsByKey[currentItemViewsKey]
if !found {
// If the item wasn't found there's nothing to create
continue
}
for currentItemIdx, currentItemView := range currentItemViews {
// Let's get the item with the same index from the new spec
if currentItemIdx >= len(matchingItemsByKeyFromNewSpec) {
// We went out of bounds, these items are not present in the new spec so we can just break
break
}
matchingItemFromNewSpec := matchingItemsByKeyFromNewSpec[currentItemIdx]
// If the item got deleted, we can create it as a whole
if dirty.isTouched(subscription.NewItemVersionPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx)) {
if _, err := s.createItem(ctx, createItemOptions{
cust: view.Customer,
sub: view.Subscription,
phase: currentPhaseView.SubscriptionPhase,
phaseCadence: newPhaseCadence,
itemSpec: *matchingItemFromNewSpec,
}); err != nil {
return def, fmt.Errorf("failed to create item: %w", err)
}
// There's nothing more to be done for this item, so lets skip to the next one
continue
}
}
}
}
// 3. Finally, let's create anything that's new
for _, phase := range newSpec.GetSortedPhases() {
// Sanity check
if phase == nil {
return def, fmt.Errorf("phase is nil")
}
// Let's see if the phase was present in the current view
matchingPhaseInCurrentView, foundMatchingPhaseInCurrentView := lo.Find(view.Phases, func(p subscription.SubscriptionPhaseView) bool {
return p.SubscriptionPhase.Key == phase.PhaseKey
})
if !foundMatchingPhaseInCurrentView {
phaseCadence, err := newSpec.GetPhaseCadence(phase.PhaseKey)
if err != nil {
return def, fmt.Errorf("failed to get cadence for phase %s: %w", phase.PhaseKey, err)
}
if _, err := s.createPhase(ctx, view.Customer, *phase, view.Subscription, phaseCadence); err != nil {
return def, fmt.Errorf("failed to create phase: %w", err)
}
continue
}
// Now lets check all the items in the phase
for key, itemsByKey := range phase.ItemsByKey {
matchingItemsByKeyInCurrentView, foundMatchingItemsByKeyInCurrentView := matchingPhaseInCurrentView.ItemsByKey[key]
for itemIdx, item := range itemsByKey {
phaseCadence, err := newSpec.GetPhaseCadence(phase.PhaseKey)
if err != nil {
return def, fmt.Errorf("failed to get cadence for phase %s: %w", phase.PhaseKey, err)
}
// If we didn't find a matching key in the current view, we need to create the item
if !foundMatchingItemsByKeyInCurrentView {
if _, err := s.createItem(ctx, createItemOptions{
cust: view.Customer,
sub: view.Subscription,
phase: matchingPhaseInCurrentView.SubscriptionPhase,
phaseCadence: phaseCadence,
itemSpec: *item,
}); err != nil {
return def, fmt.Errorf("failed to create item: %w", err)
}
// There's nothing left to do for this item
continue
} else if itemIdx >= len(matchingItemsByKeyInCurrentView) {
// If there's a matching key, then in the previous step we've taken care of all indexes
// present in the current phase
// The rest we create
if _, err := s.createItem(ctx, createItemOptions{
cust: view.Customer,
sub: view.Subscription,
phase: matchingPhaseInCurrentView.SubscriptionPhase,
phaseCadence: phaseCadence,
itemSpec: *item,
}); err != nil {
return def, fmt.Errorf("failed to create item: %w", err)
}
}
}
}
}
// 4. Finally we're done with syncing everything, we should just re-fetch the subscription
return s.Get(ctx, view.Subscription.NamespacedID)
})
}
// touched is a map of touched paths (honoring sub-resource relationships)
type touched map[subscription.SpecPath]bool
// Mark a given path as touched
func (t touched) mark(key subscription.SpecPath) {
t[key] = true
}
// Check if a given path has been touched
// If path X has been touched, then all sub-resources of X have been touched
func (t touched) isTouched(key subscription.SpecPath) bool {
for k := range t {
// IsParentOf check returns true for identity as well
if k.IsParentOf(key) {
return true
}
}
return false
}
// NewItemVersionPath returns an invalid PatchPath thats still usable for IsParentOf checks
// FIXME: this is a hack. For instance, is featureKey were to contain `/` it would completely break (though that exact scenario is otherwise prohibited)
// an ID that can never occur in normal control flow
var impossibleNamespacedId = models.NamespacedID{
ID: "impossible",
Namespace: "impossible",
}
|