Skip to content

Conversation

@turip
Copy link
Member

@turip turip commented Oct 27, 2025

Overview

This patch makes sure that we are only fetching subscriptions as needed. This should reduce DB load by ~20% in realistic scenarios.

Notes for reviewer

Entitlements never return the actual subscription IDs, so we are fine to not fetch that.

The API is not changed as on the httpdriver we require explicit expand.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 27, 2025

📝 Walkthrough

Walkthrough

This PR introduces an expansion-driven architecture for loading customer subscriptions. Instead of eagerly fetching subscriptions in all cases, subscription loading is now gated by an Expands parameter in input structs. The system introduces new Expand and Expands types with an ExpandSubscriptions constant, and changes ActiveSubscriptionIDs from a simple slice to an mo.Option type to distinguish between "not expanded" and "expanded but empty" states.

Changes

Cohort / File(s) Summary
Expansion Framework & Core Types
openmeter/customer/customer.go
Introduced Expand and Expands types with ExpandSubscriptions constant. Added Expands field to GetCustomerByUsageAttributionInput, ListCustomersInput, and GetCustomerInput. Changed ActiveSubscriptionIDs from []string to mo.Option[[]string]. Added Expands.Validate() method and updated validation in input structs.
Customer Adapter Mapping
openmeter/customer/adapter/entitymapping.go
Updated CustomerFromDBEntity signature to accept expands parameter. Extracted subscription resolution to new resolveActiveSubscriptionIDs helper function. Subscription IDs now conditionally populated only when ExpandSubscriptions is requested.
Customer Adapter Query Logic
openmeter/customer/adapter/customer.go
Added slices package import. Updated ListCustomers, GetCustomer, and GetCustomerByUsageAttribution to gate subscription loading and filtering based on ExpandSubscriptions in input. All CustomerFromDBEntity calls now pass input.Expands.
HTTP Driver Mapping & Types
openmeter/customer/httpdriver/apimapping.go, openmeter/customer/httpdriver/customer.go
Updated CustomerToAPI signature to accept customer.Expands instead of []api.CustomerExpand. Changed ListCustomersRequest to alias customer.ListCustomersInput. Updated mapCustomerWithSubscriptionsToAPI to accept []customer.Expand. Implemented subscription pre-fetching and expansion-driven mapping in list/get handlers.
Service & Entitlement Layers
openmeter/customer/service/customer.go, openmeter/entitlement/adapter/entitlement.go
Updated customer deletion validation to use expanded subscription IDs with IsAbsent() and OrEmpty() checks. Removed eager subscription loading from entitlement queries via WithActiveSubscriptions calls. Updated CustomerFromDBEntity invocations with new signature.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–75 minutes

  • Type signature cascading: Changes to CustomerFromDBEntity and CustomerToAPI propagate across multiple layers; verify all call sites are updated consistently.
  • Option type semantics: Verify correct usage of mo.Option[[]string] for ActiveSubscriptionIDs, especially distinction between "not expanded" (IsAbsent()) and "expanded but empty" (OrEmpty()).
  • Subscription loading gating: Confirm that conditional subscription loading (gated by ExpandSubscriptions presence) is correctly implemented in adapter, service, and entitlement query paths.
  • Validation flow: Check that Expands.Validate() is properly invoked in all input structs and error handling is appropriate.
  • HTTP mapping integration: Verify subscription pre-fetching logic in ListCustomers handler and error propagation in mapping functions.
  • Entitlement layer changes: Confirm that removing WithActiveSubscriptions calls does not break existing entitlement query behavior.

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title "chore: reduce customer subscription fetching" directly and accurately describes the primary change in the changeset. The modifications throughout the codebase implement a pattern where subscriptions are fetched conditionally based on the presence of ExpandSubscriptions in the input, rather than being eagerly loaded in all cases. This aligns precisely with the PR objectives stating that "subscriptions are fetched only when needed," and the title uses specific, clear language that would allow a teammate scanning history to immediately understand the optimization focus. The title avoids vague terms and accurately represents the substantive changes made across multiple files.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch chore/reduce-subscription-fetching

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@turip turip marked this pull request as ready for review October 27, 2025 17:15
@turip turip requested a review from a team as a code owner October 27, 2025 17:15
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
openmeter/customer/httpdriver/customer.go (1)

86-104: Subscriptions are fetched even when not requested; gate by expand.

This defeats the PR’s goal. Fetch only if subscriptions are expanded.

Apply:

- // Get the customer's subscriptions
- var customerSubscriptions map[string][]subscription.Subscription
-
- if len(resp.Items) > 0 {
+ // Get the customer's subscriptions only if requested
+ var customerSubscriptions map[string][]subscription.Subscription
+ if len(resp.Items) > 0 && lo.Contains([]customer.Expand(request.Expands), customer.ExpandSubscriptions) {
   customerIDs := lo.Map(resp.Items, func(item customer.Customer, _ int) string {
     return item.ID
   })
-  subscriptions, err := h.subscriptionService.List(ctx, subscription.ListSubscriptionsInput{
-    Namespaces:  []string{request.Namespace},
-    CustomerIDs: customerIDs,
-    ActiveAt:    lo.ToPtr(time.Now()),
-  })
+  subscriptions, err := h.subscriptionService.List(ctx, subscription.ListSubscriptionsInput{
+    Namespaces:  []string{request.Namespace},
+    CustomerIDs: customerIDs,
+    ActiveAt:    lo.ToPtr(clock.Now()),
+  })
   if err != nil {
     return ListCustomersResponse{}, err
   }
   customerSubscriptions = lo.GroupBy(subscriptions.Items, func(item subscription.Subscription) string {
     return item.CustomerId
   })
 }

Note: if you adopt clock.Now, remove unused time import.

🧹 Nitpick comments (8)
openmeter/customer/httpdriver/apimapping.go (1)

150-161: Prefer ActiveSubscriptionIDs for currentSubscriptionId; avoid relying on slice order

When subscriptions aren’t expanded, the slice is often empty; when expanded, we already carry ActiveSubscriptionIDs on customer.Customer. Prefer IDs from c.ActiveSubscriptionIDs (if present) and fall back to the slice only if needed.

Apply this focused diff:

-	// Map the subscriptions to the API Subscriptions
-	if len(subscriptions) > 0 {
-		// Let's find the active one
-		// FIXME: this will only work with single subscription per customer
-		apiCustomer.CurrentSubscriptionId = lo.ToPtr(subscriptions[0].ID)
-
-		// Map the subscriptions to the API Subscriptions if the expand is set
-		if lo.Contains(expand, customer.ExpandSubscriptions) {
-			apiCustomer.Subscriptions = lo.ToPtr(lo.Map(subscriptions, func(s subscription.Subscription, _ int) api.Subscription {
-				return subscriptionhttp.MapSubscriptionToAPI(s)
-			}))
-		}
-	}
+	// Current subscription: prefer expanded IDs to avoid relying on slice order
+	if lo.Contains(expand, customer.ExpandSubscriptions) {
+		ids := c.ActiveSubscriptionIDs.OrEmpty()
+		if len(ids) > 0 {
+			apiCustomer.CurrentSubscriptionId = lo.ToPtr(ids[0])
+		}
+	}
+	// Fallback to first subscription if provided explicitly
+	if apiCustomer.CurrentSubscriptionId == nil && len(subscriptions) > 0 {
+		apiCustomer.CurrentSubscriptionId = lo.ToPtr(subscriptions[0].ID)
+	}
+	// Map full subscriptions if expanded
+	if lo.Contains(expand, customer.ExpandSubscriptions) && len(subscriptions) > 0 {
+		apiCustomer.Subscriptions = lo.ToPtr(lo.Map(subscriptions, func(s subscription.Subscription, _ int) api.Subscription {
+			return subscriptionhttp.MapSubscriptionToAPI(s)
+		}))
+	}
openmeter/customer/service/customer.go (1)

80-83: Return a typed validation error for non-expanded subscriptions

This is a client/usage error; use the validation wrapper for consistent 4xx mapping.

-			if cus.ActiveSubscriptionIDs.IsAbsent() {
-				return fmt.Errorf("customer subscriptions are not expanded")
-			}
+			if cus.ActiveSubscriptionIDs.IsAbsent() {
+				return models.NewGenericValidationError(fmt.Errorf("customer subscriptions are not expanded"))
+			}
openmeter/customer/adapter/customer.go (1)

40-42: Good gating: only load subscriptions when expanded

Conditionally calling WithActiveSubscriptions reduces load as intended. Nice.

If you want to reduce duplication, consider a local withSubs := slices.Contains(input.Expands, customer.ExpandSubscriptions) in each method to reuse.

Also applies to: 394-396, 472-474

openmeter/customer/adapter/entitymapping.go (1)

80-99: Add context to “subscriptions must be loaded” error

Including namespace/id eases debugging when edges aren’t preloaded.

-import "errors"
+import (
+	"errors"
+	"fmt"
+)
...
-		if db.IsNotLoaded(err) {
-			return nil, errors.New("subscriptions must be loaded for customer")
-		}
+		if db.IsNotLoaded(err) {
+			return nil, fmt.Errorf("subscriptions must be loaded for customer [namespace=%s customer.id=%s]", e.Namespace, e.ID)
+		}

Optionally mirror the same pattern in subjectKeysFromDBEntity for consistency.

openmeter/customer/customer.go (2)

27-35: Avoid double-wrapping validation errors from Expands.Validate.

Expands.Validate already returns a models.GenericValidationError, but its callers wrap it again. Return a plain error here and let callers wrap once.

Apply:

- if expand != ExpandSubscriptions {
-   return models.NewGenericValidationError(fmt.Errorf("invalid expand: %s", expand))
- }
+ if expand != ExpandSubscriptions {
+   return fmt.Errorf("invalid expand: %q", expand)
+ }

Callers that do models.NewGenericValidationError(i.Expands.Validate()) can stay as-is. Based on learnings.


18-21: Make Expands interop-friendly (lo.Contains) or provide a Has helper.

Named slice type Expands may not match []T for generic helpers like lo.Contains. Prefer either:

  • Alias: type Expands = []Expand, or
  • Add func (e Expands) Has(x Expand) bool and use if !e.Has(ExpandSubscriptions) { ... }.

This avoids scattered conversions and type frictions across packages.

openmeter/customer/httpdriver/customer.go (2)

91-95: Use clock.Now for time sourcing.

Prefer clock.Now over time.Now for testability and consistency.

Apply:

- ActiveAt:    lo.ToPtr(time.Now()),
+ ActiveAt:    lo.ToPtr(clock.Now()),

and

- ActiveAt:    lo.ToPtr(time.Now()),
+ ActiveAt:    lo.ToPtr(clock.Now()),

Remove the unused time import if no longer referenced.

Also applies to: 479-482


115-117: Minor: clarify error message.

“failed to cast customer customer” → “failed to map customer”.

Apply:

- return item, fmt.Errorf("failed to cast customer customer: %w", err)
+ return item, fmt.Errorf("failed to map customer: %w", err)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0d417 and 772a210.

📒 Files selected for processing (7)
  • openmeter/customer/adapter/customer.go (7 hunks)
  • openmeter/customer/adapter/entitymapping.go (3 hunks)
  • openmeter/customer/customer.go (8 hunks)
  • openmeter/customer/httpdriver/apimapping.go (2 hunks)
  • openmeter/customer/httpdriver/customer.go (7 hunks)
  • openmeter/customer/service/customer.go (2 hunks)
  • openmeter/entitlement/adapter/entitlement.go (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
openmeter/customer/httpdriver/apimapping.go (1)
openmeter/customer/customer.go (3)
  • Customer (40-52)
  • Expands (20-20)
  • ExpandSubscriptions (24-24)
openmeter/customer/adapter/customer.go (2)
openmeter/customer/customer.go (2)
  • Expands (20-20)
  • ExpandSubscriptions (24-24)
openmeter/customer/adapter/entitymapping.go (1)
  • CustomerFromDBEntity (15-78)
openmeter/entitlement/adapter/entitlement.go (2)
openmeter/customer/adapter/entitymapping.go (1)
  • CustomerFromDBEntity (15-78)
openmeter/customer/customer.go (2)
  • Expands (20-20)
  • Customer (40-52)
openmeter/customer/customer.go (2)
pkg/models/validator.go (1)
  • Validate (16-26)
pkg/models/errors.go (1)
  • NewGenericValidationError (138-140)
openmeter/customer/service/customer.go (3)
openmeter/customer/customer.go (2)
  • Expands (20-20)
  • ExpandSubscriptions (24-24)
pkg/models/errors.go (1)
  • NewGenericPreConditionFailedError (233-235)
openmeter/customer/errors.go (1)
  • NewErrDeletingCustomerWithActiveSubscriptions (116-118)
openmeter/customer/httpdriver/customer.go (3)
openmeter/customer/customer.go (5)
  • ListCustomersInput (239-259)
  • Expands (20-20)
  • Expand (19-19)
  • Customer (40-52)
  • ExpandSubscriptions (24-24)
openmeter/subscription/list.go (2)
  • OrderBy (14-14)
  • ListSubscriptionsInput (29-39)
openmeter/customer/httpdriver/apimapping.go (1)
  • CustomerToAPI (113-165)
openmeter/customer/adapter/entitymapping.go (3)
openmeter/customer/customer.go (3)
  • Customer (40-52)
  • Expands (20-20)
  • ExpandSubscriptions (24-24)
openmeter/ent/db/customer.go (2)
  • Customer (20-64)
  • Customer (142-157)
openmeter/ent/db/ent.go (1)
  • IsNotLoaded (341-347)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Build
  • GitHub Check: Migration Checks
  • GitHub Check: Lint
  • GitHub Check: Analyze (go)
🔇 Additional comments (7)
openmeter/customer/httpdriver/apimapping.go (1)

113-113: Signature update aligns with expands model

Accepting customer.Expands here is consistent with the new gating approach. No issues.

openmeter/entitlement/adapter/entitlement.go (1)

151-154: LGTM: expands-aware mapping without loading subscriptions

Passing customer.Expands{} is correct here; entitlement paths don’t need subscription IDs. Consistent with the new gating.

Also applies to: 550-552

openmeter/customer/adapter/customer.go (1)

116-117: Propagating expands to CustomerFromDBEntity

This ensures ActiveSubscriptionIDs is set only when requested. Looks good.

Also applies to: 446-447, 491-492

openmeter/customer/adapter/entitymapping.go (1)

15-16: Expands-aware population of ActiveSubscriptionIDs

Gating via slices.Contains(expands, customer.ExpandSubscriptions) is correct; Option[[]string] differentiates absent vs empty. Good.

Also applies to: 52-59

openmeter/customer/service/customer.go (1)

62-66: ****

The code at lines 62–66 is correct. The parameter input has type customer.DeleteCustomerInput, which is a type alias for customer.CustomerID (itself an alias for models.NamespacedID). Therefore, &input already produces the correct type *customer.CustomerID expected by GetCustomerInput.CustomerID.

The suggested fix would be redundant, as it unnecessarily reconstructs the same value. Other usages in the codebase that access &input.CustomerID do so because their input is a struct with a CustomerID field—a different context from the DeleteCustomer method's function signature.

Likely an incorrect or invalid review comment.

openmeter/customer/httpdriver/customer.go (1)

320-323: LGTM: consistent mapping of query expand to internal type.

The code correctly:

  • Maps api.CustomerExpand to customer.Expand consistently across both GetCustomer (line 320) and ListCustomer (line 66) endpoints
  • Defaults to subscriptions expand via lo.FromPtrOr, preserving backward compatibility
  • Includes clear TODO[v2] comment (line 65) noting this is a breaking change to disable by default later

The lo.Contains(expand, customer.ExpandSubscriptions) usage pattern in the codebase confirms the expand parameter is properly handled as []customer.Expand throughout the adapter and HTTP driver layers.

openmeter/customer/customer.go (1)

51-51: Review comment is incorrect and should be disregarded.

The concern assumes customer.Customer could be JSON-encoded directly, but the HTTP handler at openmeter/customer/httpdriver/customer.go:106 explicitly maps it to api.Customer via CustomerToAPI() before returning any response. All HTTP responses use the generated api.Customer type with proper JSON tags, not the internal customer.Customer type. The mo.Option field without a JSON tag poses no risk because customer.Customer is never directly marshaled.

Likely an incorrect or invalid review comment.

@turip turip merged commit fd7fbd4 into main Oct 28, 2025
33 of 35 checks passed
@turip turip deleted the chore/reduce-subscription-fetching branch October 28, 2025 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants