mirror of
https://github.com/orris-inc/orris.git
synced 2026-05-06 21:44:01 +08:00
fix: unify subscription traffic cycle window and reduce log noise
Replace direct uses of sub.CurrentPeriodStart/End with subscription.ResolveTrafficPeriod across the quota cache, forward usage endpoints, and DTOs. For calendar_month plans these windows diverge, causing the node hub real-time enforcer to query usage over the wrong window after a calendar reset and forward usage endpoints to return mis-aligned traffic figures. SubscriptionDTO and DashboardSubscriptionDTO now expose currentTrafficCycleStart/End plus the upload/download breakdown, so the frontend pairs figures with the matching window and skips a redundant traffic-stats request. Also demote context.Canceled / DeadlineExceeded inside Errorw/Error to WARN. Repository read paths previously logged client cancellations as ERRORs (e.g. dashboard 30s timeout produced an ERROR per repo on the call chain), which polluted alerts.
This commit is contained in:
@@ -134,10 +134,13 @@ func (uc *GetSubscriptionForwardUsageUseCase) Execute(ctx context.Context, query
|
||||
return nil, fmt.Errorf("failed to get rule count: %w", err)
|
||||
}
|
||||
|
||||
// Query traffic usage from Redis (recent) and MySQL stats (historical)
|
||||
// Query traffic usage from Redis (recent) and MySQL stats (historical).
|
||||
// Use the resolved *traffic cycle* (calendar_month or billing_cycle) so
|
||||
// the displayed traffic_used matches what quota enforcement counts.
|
||||
var trafficUsed uint64
|
||||
periodStart := sub.CurrentPeriodStart()
|
||||
periodEnd := biztime.EndOfDayUTC(sub.CurrentPeriodEnd())
|
||||
cycle := subscription.ResolveTrafficPeriod(plan, sub)
|
||||
periodStart := cycle.Start
|
||||
periodEnd := biztime.EndOfDayUTC(cycle.End)
|
||||
now := biztime.NowUTC()
|
||||
|
||||
// Use start of yesterday's business day as batch/speed boundary (Lambda architecture)
|
||||
|
||||
@@ -124,10 +124,14 @@ func (uc *GetUserForwardUsageUseCase) Execute(ctx context.Context, query GetUser
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect forward subscription ID and period range
|
||||
// Collect forward subscription ID and traffic cycle range.
|
||||
// Use ResolveTrafficPeriod so the aggregated traffic_used matches the
|
||||
// cycle that quota enforcement actually counts (calendar_month or
|
||||
// billing_cycle), not the subscription billing period.
|
||||
forwardSubscriptionIDs = append(forwardSubscriptionIDs, sub.ID())
|
||||
periodStart := sub.CurrentPeriodStart()
|
||||
periodEnd := sub.CurrentPeriodEnd()
|
||||
cycle := subscription.ResolveTrafficPeriod(plan, sub)
|
||||
periodStart := cycle.Start
|
||||
periodEnd := cycle.End
|
||||
if firstSub || periodStart.Before(earliestFrom) {
|
||||
earliestFrom = periodStart
|
||||
}
|
||||
|
||||
@@ -29,12 +29,20 @@ type SubscriptionDTO struct {
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
// Subscription billing period (used by billing/renewal flows).
|
||||
CurrentPeriodStart time.Time `json:"current_period_start"`
|
||||
CurrentPeriodEnd time.Time `json:"current_period_end"`
|
||||
// Current traffic cycle window (matches DataUsedBytes / CurrentCycle*Bytes).
|
||||
// For calendar_month reset mode this is the business-timezone calendar month
|
||||
// and differs from CurrentPeriodStart/End. For billing_cycle mode it matches.
|
||||
CurrentTrafficCycleStart time.Time `json:"current_traffic_cycle_start"`
|
||||
CurrentTrafficCycleEnd time.Time `json:"current_traffic_cycle_end"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
IsActive bool `json:"is_active"`
|
||||
DataUsedBytes uint64 `json:"data_used_bytes"` // Current traffic used in bytes
|
||||
DataUsedBytes uint64 `json:"data_used_bytes"` // Total traffic used in current cycle (= upload + download, plus admin adjustment)
|
||||
DataLimitBytes uint64 `json:"data_limit_bytes"` // Traffic limit in bytes (0=unlimited)
|
||||
CurrentCycleUploadBytes uint64 `json:"current_cycle_upload_bytes"` // Upload traffic in current cycle (raw, no adjustment)
|
||||
CurrentCycleDownloadBytes uint64 `json:"current_cycle_download_bytes"` // Download traffic in current cycle (raw, no adjustment)
|
||||
OnlineDeviceCount int `json:"online_device_count"` // Current online device count
|
||||
DeviceLimit int `json:"device_limit"` // Max concurrent devices (0=unlimited)
|
||||
CancelledAt *time.Time `json:"cancelled_at,omitempty"`
|
||||
@@ -172,6 +180,29 @@ func WithDataUsage(used, limit uint64) SubscriptionDTOOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithDataUsageBreakdown sets the upload/download breakdown along with the
|
||||
// adjusted total used and the limit. Use this in preference to WithDataUsage
|
||||
// whenever upload/download figures are available so the frontend doesn't need
|
||||
// a separate traffic-stats request.
|
||||
func WithDataUsageBreakdown(used, upload, download, limit uint64) SubscriptionDTOOption {
|
||||
return func(d *SubscriptionDTO) {
|
||||
d.DataUsedBytes = used
|
||||
d.DataLimitBytes = limit
|
||||
d.CurrentCycleUploadBytes = upload
|
||||
d.CurrentCycleDownloadBytes = download
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrafficCycle sets the current traffic cycle window (resolved via
|
||||
// subscription.ResolveTrafficPeriod). This window matches DataUsedBytes and
|
||||
// the upload/download breakdown — it is NOT the subscription billing period.
|
||||
func WithTrafficCycle(start, end time.Time) SubscriptionDTOOption {
|
||||
return func(d *SubscriptionDTO) {
|
||||
d.CurrentTrafficCycleStart = start
|
||||
d.CurrentTrafficCycleEnd = end
|
||||
}
|
||||
}
|
||||
|
||||
func toSubscriptionDTOInternal(sub *subscription.Subscription, plan *subscription.Plan, u *user.User, baseURL string, opts ...SubscriptionDTOOption) *SubscriptionDTO {
|
||||
if sub == nil {
|
||||
return nil
|
||||
|
||||
@@ -63,11 +63,14 @@ func (s *QuotaCacheSyncService) SyncQuotaFromSubscription(ctx context.Context, s
|
||||
}
|
||||
}
|
||||
|
||||
// Build cached quota object
|
||||
// Cache the *traffic cycle* (not the billing period) so consumers like the
|
||||
// node hub real-time enforcer query usage over the correct window. For
|
||||
// calendar_month plans the traffic cycle differs from the billing period.
|
||||
cycle := subscription.ResolveTrafficPeriod(plan, sub)
|
||||
quota := &cache.CachedQuota{
|
||||
Limit: int64(trafficLimit),
|
||||
PeriodStart: sub.CurrentPeriodStart(),
|
||||
PeriodEnd: sub.CurrentPeriodEnd(),
|
||||
PeriodStart: cycle.Start,
|
||||
PeriodEnd: cycle.End,
|
||||
PlanType: plan.PlanType().String(),
|
||||
Suspended: false, // Only set to false when syncing (active subscription)
|
||||
}
|
||||
@@ -130,11 +133,12 @@ func (s *QuotaCacheSyncService) LoadQuotaByID(ctx context.Context, subscriptionI
|
||||
}
|
||||
}
|
||||
|
||||
// Build cached quota object
|
||||
// Cache the *traffic cycle* (see SyncQuotaFromSubscription for rationale).
|
||||
cycle := subscription.ResolveTrafficPeriod(plan, sub)
|
||||
quota := &cache.CachedQuota{
|
||||
Limit: int64(trafficLimit),
|
||||
PeriodStart: sub.CurrentPeriodStart(),
|
||||
PeriodEnd: sub.CurrentPeriodEnd(),
|
||||
PeriodStart: cycle.Start,
|
||||
PeriodEnd: cycle.End,
|
||||
PlanType: plan.PlanType().String(),
|
||||
Suspended: false,
|
||||
}
|
||||
|
||||
@@ -140,13 +140,19 @@ func (uc *GetSubscriptionUseCase) buildDTOOptions(ctx context.Context, subID uin
|
||||
}
|
||||
}
|
||||
|
||||
// Query data usage from QuotaService
|
||||
// Query data usage from QuotaService — provides usage figures AND the
|
||||
// traffic cycle window they were aggregated over (calendar_month or
|
||||
// billing_cycle, depending on plan). Both must be exposed together so the
|
||||
// frontend can label the figures with the correct window.
|
||||
if uc.quotaService != nil {
|
||||
quota, err := uc.quotaService.GetSubscriptionQuota(ctx, subID)
|
||||
if err != nil {
|
||||
uc.logger.Warnw("failed to get subscription quota", "error", err, "subscription_id", subID)
|
||||
} else if quota != nil {
|
||||
opts = append(opts, dto.WithDataUsage(quota.UsedBytes, quota.LimitBytes))
|
||||
opts = append(opts,
|
||||
dto.WithDataUsageBreakdown(quota.UsedBytes, quota.UploadBytes, quota.DownloadBytes, quota.LimitBytes),
|
||||
dto.WithTrafficCycle(quota.PeriodStart, quota.PeriodEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,13 +179,18 @@ func (uc *ListUserSubscriptionsUseCase) Execute(ctx context.Context, query ListU
|
||||
if count, ok := onlineCounts[sub.ID()]; ok {
|
||||
opts = append(opts, dto.WithOnlineDeviceCount(count))
|
||||
}
|
||||
// Set data usage from QuotaService
|
||||
// Set data usage and traffic cycle window from QuotaService so the
|
||||
// frontend doesn't need a separate traffic-stats request and can
|
||||
// label figures with the cycle window that was actually aggregated.
|
||||
if uc.quotaService != nil && plan != nil {
|
||||
quota, err := uc.quotaService.GetSubscriptionQuotaPreloaded(ctx, sub, plan)
|
||||
if err != nil {
|
||||
uc.logger.Warnw("failed to get subscription quota", "error", err, "subscription_id", sub.ID())
|
||||
} else if quota != nil {
|
||||
opts = append(opts, dto.WithDataUsage(quota.UsedBytes, quota.LimitBytes))
|
||||
opts = append(opts,
|
||||
dto.WithDataUsageBreakdown(quota.UsedBytes, quota.UploadBytes, quota.DownloadBytes, quota.LimitBytes),
|
||||
dto.WithTrafficCycle(quota.PeriodStart, quota.PeriodEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,14 +14,22 @@ import (
|
||||
)
|
||||
|
||||
// QuotaCheckResult represents the quota usage status for a subscription.
|
||||
//
|
||||
// PeriodStart/PeriodEnd describe the *traffic cycle* the usage is aggregated
|
||||
// over (resolved via subscription.ResolveTrafficPeriod). For calendar_month
|
||||
// reset mode this is the business-timezone calendar month, NOT the subscription's
|
||||
// billing period; callers must use these dates when displaying or persisting
|
||||
// the matching usage figures, not sub.CurrentPeriodStart/End.
|
||||
type QuotaCheckResult struct {
|
||||
SubscriptionID uint // Internal subscription ID
|
||||
SubscriptionSID string // Stripe-style subscription ID
|
||||
PlanType string // Plan type (node, forward, hybrid)
|
||||
UsedBytes uint64 // Total traffic used in current period
|
||||
UsedBytes uint64 // Total traffic used in current period (= UploadBytes + DownloadBytes after adjustment)
|
||||
UploadBytes uint64 // Upload traffic in current period (raw, no adjustment)
|
||||
DownloadBytes uint64 // Download traffic in current period (raw, no adjustment)
|
||||
LimitBytes uint64 // Traffic limit (0 = unlimited)
|
||||
PeriodStart time.Time // Current billing period start
|
||||
PeriodEnd time.Time // Current billing period end
|
||||
PeriodStart time.Time // Current traffic cycle start (calendar_month or billing_cycle)
|
||||
PeriodEnd time.Time // Current traffic cycle end
|
||||
IsExceeded bool // Whether quota is exceeded
|
||||
RemainingBytes uint64 // Remaining traffic (0 if exceeded or unlimited)
|
||||
}
|
||||
@@ -284,8 +292,8 @@ func (s *QuotaServiceImpl) buildQuotaResult(
|
||||
// - Hybrid plan: count all resource types (node + forward_rule)
|
||||
resourceType := s.getResourceTypeForPlan(plan.PlanType())
|
||||
|
||||
// Calculate period usage
|
||||
usedBytes, err := s.calculatePeriodUsage(
|
||||
// Calculate period usage (with upload/download breakdown)
|
||||
totalBytes, uploadBytes, downloadBytes, err := s.calculatePeriodUsage(
|
||||
ctx,
|
||||
[]uint{sub.ID()},
|
||||
resourceType,
|
||||
@@ -302,7 +310,9 @@ func (s *QuotaServiceImpl) buildQuotaResult(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply traffic used adjustment
|
||||
// Apply traffic used adjustment to the aggregate total only;
|
||||
// upload/download breakdown remains the raw observed split.
|
||||
usedBytes := totalBytes
|
||||
if adj := sub.TrafficUsedAdjustment(); adj != 0 {
|
||||
adjusted := int64(usedBytes) + adj
|
||||
if adjusted < 0 {
|
||||
@@ -331,6 +341,8 @@ func (s *QuotaServiceImpl) buildQuotaResult(
|
||||
SubscriptionSID: sub.SID(),
|
||||
PlanType: plan.PlanType().String(),
|
||||
UsedBytes: usedBytes,
|
||||
UploadBytes: uploadBytes,
|
||||
DownloadBytes: downloadBytes,
|
||||
LimitBytes: limitBytes,
|
||||
PeriodStart: periodStart,
|
||||
PeriodEnd: periodEnd,
|
||||
@@ -369,7 +381,7 @@ func (s *QuotaServiceImpl) GetCurrentPeriodUsage(
|
||||
periodEnd time.Time,
|
||||
) (int64, error) {
|
||||
// Aggregate all resource types (nil = no filter)
|
||||
usage, err := s.calculatePeriodUsage(ctx, []uint{subscriptionID}, nil, periodStart, periodEnd)
|
||||
usage, _, _, err := s.calculatePeriodUsage(ctx, []uint{subscriptionID}, nil, periodStart, periodEnd)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -380,22 +392,27 @@ func (s *QuotaServiceImpl) GetCurrentPeriodUsage(
|
||||
return int64(usage), nil
|
||||
}
|
||||
|
||||
// calculatePeriodUsage calculates total usage for subscriptions within a billing period.
|
||||
// calculatePeriodUsage calculates total/upload/download usage for subscriptions
|
||||
// within a traffic cycle.
|
||||
// Uses Redis HourlyTrafficCache for recent data (last 24h) and MySQL subscription_usage_stats
|
||||
// for historical data. This approach provides real-time accuracy for recent traffic while
|
||||
// efficiently querying pre-aggregated data for historical periods.
|
||||
//
|
||||
// resourceType: nil = aggregate all resource types (for Hybrid plans),
|
||||
// non-nil = filter by specific resource type (for Node/Forward plans).
|
||||
//
|
||||
// Returns (total, upload, download, err). total == upload + download under
|
||||
// normal conditions; if either data source partially fails the returned values
|
||||
// reflect what was successfully fetched.
|
||||
func (s *QuotaServiceImpl) calculatePeriodUsage(
|
||||
ctx context.Context,
|
||||
subscriptionIDs []uint,
|
||||
resourceType *string,
|
||||
periodStart time.Time,
|
||||
periodEnd time.Time,
|
||||
) (uint64, error) {
|
||||
) (uint64, uint64, uint64, error) {
|
||||
if len(subscriptionIDs) == 0 {
|
||||
return 0, nil
|
||||
return 0, 0, 0, nil
|
||||
}
|
||||
|
||||
now := biztime.NowUTC()
|
||||
@@ -409,7 +426,7 @@ func (s *QuotaServiceImpl) calculatePeriodUsage(
|
||||
// MySQL: complete days before yesterday; Redis: yesterday + today (within 48h TTL)
|
||||
recentBoundary := biztime.StartOfDayUTC(now.AddDate(0, 0, -1))
|
||||
|
||||
var total uint64
|
||||
var total, upload, download uint64
|
||||
var redisErr, mysqlErr error
|
||||
|
||||
// Determine time boundaries for recent data (yesterday + today from Redis)
|
||||
@@ -448,6 +465,8 @@ func (s *QuotaServiceImpl) calculatePeriodUsage(
|
||||
} else {
|
||||
for _, t := range recentTraffic {
|
||||
total += t.Total
|
||||
upload += t.Upload
|
||||
download += t.Download
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,13 +494,15 @@ func (s *QuotaServiceImpl) calculatePeriodUsage(
|
||||
// Continue with Redis data even if MySQL stats fail
|
||||
} else if historicalTraffic != nil {
|
||||
total += historicalTraffic.Total
|
||||
upload += historicalTraffic.Upload
|
||||
download += historicalTraffic.Download
|
||||
}
|
||||
}
|
||||
|
||||
// If both data sources failed, return error to prevent false zero-usage
|
||||
if redisErr != nil && mysqlErr != nil {
|
||||
return 0, fmt.Errorf("both traffic data sources failed: redis=%w, mysql=%v", redisErr, mysqlErr)
|
||||
return 0, 0, 0, fmt.Errorf("both traffic data sources failed: redis=%w, mysql=%v", redisErr, mysqlErr)
|
||||
}
|
||||
|
||||
return total, nil
|
||||
return total, upload, download, nil
|
||||
}
|
||||
|
||||
@@ -19,13 +19,21 @@ type DashboardPlanDTO struct {
|
||||
Limits map[string]interface{} `json:"limits,omitempty"`
|
||||
}
|
||||
|
||||
// DashboardSubscriptionDTO represents subscription info with usage for dashboard
|
||||
// DashboardSubscriptionDTO represents subscription info with usage for dashboard.
|
||||
//
|
||||
// CurrentPeriodStart/End are the subscription billing period.
|
||||
// CurrentTrafficCycleStart/End are the window that Usage is aggregated over
|
||||
// (resolved via subscription.ResolveTrafficPeriod). For calendar_month plans
|
||||
// they differ from the billing period — frontend should display Usage paired
|
||||
// with the traffic cycle window, not the billing window.
|
||||
type DashboardSubscriptionDTO struct {
|
||||
SID string `json:"id"`
|
||||
Plan *DashboardPlanDTO `json:"plan,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CurrentPeriodStart time.Time `json:"current_period_start"`
|
||||
CurrentPeriodEnd time.Time `json:"current_period_end"`
|
||||
CurrentTrafficCycleStart time.Time `json:"current_traffic_cycle_start"`
|
||||
CurrentTrafficCycleEnd time.Time `json:"current_traffic_cycle_end"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Usage *UsageSummary `json:"usage"`
|
||||
}
|
||||
|
||||
@@ -117,12 +117,19 @@ func (uc *GetDashboardUseCase) Execute(
|
||||
response.TotalUsage.Download += subUsage.Download
|
||||
response.TotalUsage.Total += subUsage.Total
|
||||
|
||||
// Resolve traffic cycle so the DTO carries both the billing period and
|
||||
// the actual window Usage was aggregated over (they differ for
|
||||
// calendar_month plans).
|
||||
cycle := subscription.ResolveTrafficPeriod(planMap[sub.PlanID()], sub)
|
||||
|
||||
// Build subscription DTO
|
||||
subDTO := &dto.DashboardSubscriptionDTO{
|
||||
SID: sub.SID(),
|
||||
Status: sub.EffectiveStatus().String(),
|
||||
CurrentPeriodStart: sub.CurrentPeriodStart(),
|
||||
CurrentPeriodEnd: sub.CurrentPeriodEnd(),
|
||||
CurrentTrafficCycleStart: cycle.Start,
|
||||
CurrentTrafficCycleEnd: cycle.End,
|
||||
IsActive: sub.IsActive(),
|
||||
Usage: subUsage,
|
||||
}
|
||||
|
||||
161
internal/domain/subscription/trafficperiod_test.go
Normal file
161
internal/domain/subscription/trafficperiod_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
vo "github.com/orris-inc/orris/internal/domain/subscription/valueobjects"
|
||||
"github.com/orris-inc/orris/internal/shared/biztime"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// trafficCyclePlan builds a Plan whose traffic_reset_mode is the given value.
|
||||
// Pass empty string to leave the mode unset (caller verifies fallback behavior).
|
||||
func trafficCyclePlan(t *testing.T, mode string) *Plan {
|
||||
t.Helper()
|
||||
|
||||
plan, err := NewPlan("Test Plan", "test", "desc", vo.PlanTypeNode)
|
||||
require.NoError(t, err)
|
||||
|
||||
features := vo.NewPlanFeatures(nil)
|
||||
if mode != "" {
|
||||
require.NoError(t, features.SetTrafficResetMode(mode))
|
||||
}
|
||||
require.NoError(t, plan.UpdateFeatures(features))
|
||||
return plan
|
||||
}
|
||||
|
||||
// trafficCycleSubscription builds an active monthly subscription with the
|
||||
// given billing-period bounds. The cycle dates are also used as start/end so
|
||||
// the subscription validates without renewal handling.
|
||||
func trafficCycleSubscription(t *testing.T, periodStart, periodEnd time.Time) *Subscription {
|
||||
t.Helper()
|
||||
|
||||
bc, err := vo.NewBillingCycle("monthly")
|
||||
require.NoError(t, err)
|
||||
|
||||
sub, err := ReconstructSubscriptionWithParams(SubscriptionReconstructParams{
|
||||
ID: 1,
|
||||
UserID: 10,
|
||||
PlanID: 100,
|
||||
SubjectType: "user",
|
||||
SubjectID: 10,
|
||||
SID: "sub_test",
|
||||
UUID: "00000000-0000-0000-0000-000000000001",
|
||||
LinkToken: "dGVzdHRva2VuMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkw",
|
||||
Status: vo.StatusActive,
|
||||
StartDate: periodStart,
|
||||
EndDate: periodEnd,
|
||||
AutoRenew: true,
|
||||
CurrentPeriodStart: periodStart,
|
||||
CurrentPeriodEnd: periodEnd,
|
||||
BillingCycle: bc,
|
||||
Version: 1,
|
||||
CreatedAt: periodStart,
|
||||
UpdatedAt: periodStart,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return sub
|
||||
}
|
||||
|
||||
func TestResolveTrafficPeriod_CalendarMonth_DiffersFromBillingPeriod(t *testing.T) {
|
||||
// Subscription billing window mid-Jan to mid-Feb; calendar_month plan
|
||||
// must resolve to the business-tz calendar month containing "now",
|
||||
// NOT the subscription billing period.
|
||||
periodStart := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
periodEnd := time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
plan := trafficCyclePlan(t, "calendar_month")
|
||||
sub := trafficCycleSubscription(t, periodStart, periodEnd)
|
||||
|
||||
got := ResolveTrafficPeriod(plan, sub)
|
||||
|
||||
bizNow := biztime.ToBizTimezone(biztime.NowUTC())
|
||||
expectedStart := biztime.StartOfMonthUTC(bizNow.Year(), bizNow.Month())
|
||||
expectedEnd := biztime.EndOfMonthUTC(bizNow.Year(), bizNow.Month())
|
||||
|
||||
// Today's calendar month start is far from Jan 15: confirm we're not
|
||||
// silently returning the billing period.
|
||||
assert.Equal(t, expectedStart, got.Start, "calendar_month plan must use calendar month start")
|
||||
assert.Equal(t, expectedEnd, got.End, "calendar_month plan must use calendar month end")
|
||||
assert.NotEqual(t, sub.CurrentPeriodStart(), got.Start, "calendar_month must NOT use billing period start")
|
||||
}
|
||||
|
||||
func TestResolveTrafficPeriod_CalendarMonth_FloorsToManualReset(t *testing.T) {
|
||||
// If a manual reset moves CurrentPeriodStart past the calendar month
|
||||
// start, that reset wins as a floor (excludes pre-reset traffic).
|
||||
bizNow := biztime.ToBizTimezone(biztime.NowUTC())
|
||||
monthStart := biztime.StartOfMonthUTC(bizNow.Year(), bizNow.Month())
|
||||
resetAt := monthStart.AddDate(0, 0, 5) // 5 days into the month
|
||||
|
||||
plan := trafficCyclePlan(t, "calendar_month")
|
||||
sub := trafficCycleSubscription(t, resetAt, resetAt.AddDate(0, 1, 0))
|
||||
|
||||
got := ResolveTrafficPeriod(plan, sub)
|
||||
|
||||
assert.Equal(t, resetAt, got.Start, "manual reset (post month-start) must floor the cycle start")
|
||||
assert.Equal(t, biztime.EndOfMonthUTC(bizNow.Year(), bizNow.Month()), got.End)
|
||||
}
|
||||
|
||||
func TestResolveTrafficPeriod_BillingCycle_UsesSubscriptionPeriod(t *testing.T) {
|
||||
periodStart := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
periodEnd := time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
plan := trafficCyclePlan(t, "billing_cycle")
|
||||
sub := trafficCycleSubscription(t, periodStart, periodEnd)
|
||||
|
||||
got := ResolveTrafficPeriod(plan, sub)
|
||||
|
||||
assert.Equal(t, periodStart, got.Start)
|
||||
assert.Equal(t, periodEnd, got.End)
|
||||
}
|
||||
|
||||
func TestResolveTrafficPeriod_FallsBackToCalendarMonthOnNilPlan(t *testing.T) {
|
||||
periodStart := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
periodEnd := time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC)
|
||||
sub := trafficCycleSubscription(t, periodStart, periodEnd)
|
||||
|
||||
got := ResolveTrafficPeriod(nil, sub)
|
||||
|
||||
bizNow := biztime.ToBizTimezone(biztime.NowUTC())
|
||||
assert.Equal(t, biztime.StartOfMonthUTC(bizNow.Year(), bizNow.Month()), got.Start)
|
||||
assert.Equal(t, biztime.EndOfMonthUTC(bizNow.Year(), bizNow.Month()), got.End)
|
||||
}
|
||||
|
||||
func TestResolveTrafficPeriod_LifetimeAlwaysUsesSubscriptionPeriod(t *testing.T) {
|
||||
// Lifetime subscriptions must NEVER be reset by calendar_month even if
|
||||
// the plan declares calendar_month — they accumulate from start to end.
|
||||
periodStart := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
periodEnd := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
bc, err := vo.NewBillingCycle("lifetime")
|
||||
require.NoError(t, err)
|
||||
sub, err := ReconstructSubscriptionWithParams(SubscriptionReconstructParams{
|
||||
ID: 2,
|
||||
UserID: 10,
|
||||
PlanID: 100,
|
||||
SubjectType: "user",
|
||||
SubjectID: 10,
|
||||
SID: "sub_lifetime",
|
||||
UUID: "00000000-0000-0000-0000-000000000002",
|
||||
LinkToken: "dGVzdHRva2VuMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkx",
|
||||
Status: vo.StatusActive,
|
||||
StartDate: periodStart,
|
||||
EndDate: periodEnd,
|
||||
AutoRenew: false,
|
||||
CurrentPeriodStart: periodStart,
|
||||
CurrentPeriodEnd: periodEnd,
|
||||
BillingCycle: bc,
|
||||
Version: 1,
|
||||
CreatedAt: periodStart,
|
||||
UpdatedAt: periodStart,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
plan := trafficCyclePlan(t, "calendar_month")
|
||||
got := ResolveTrafficPeriod(plan, sub)
|
||||
|
||||
assert.Equal(t, periodStart, got.Start)
|
||||
assert.Equal(t, periodEnd, got.End)
|
||||
}
|
||||
@@ -12,11 +12,18 @@ import (
|
||||
"github.com/orris-inc/orris/internal/shared/logger"
|
||||
)
|
||||
|
||||
// CachedQuota represents cached subscription quota information
|
||||
// CachedQuota represents cached subscription quota information.
|
||||
//
|
||||
// PeriodStart/PeriodEnd describe the *traffic cycle* (resolved via
|
||||
// subscription.ResolveTrafficPeriod), NOT the subscription billing period.
|
||||
// For calendar_month reset mode they describe the business-timezone calendar
|
||||
// month; for billing_cycle mode they match the subscription's current period.
|
||||
// Producers must populate them via ResolveTrafficPeriod and consumers must
|
||||
// treat them as the authoritative window for usage aggregation.
|
||||
type CachedQuota struct {
|
||||
Limit int64 // Traffic limit in bytes
|
||||
PeriodStart time.Time // Billing period start
|
||||
PeriodEnd time.Time // Billing period end
|
||||
PeriodStart time.Time // Traffic cycle start
|
||||
PeriodEnd time.Time // Traffic cycle end
|
||||
PlanType string // Plan type: node/forward/hybrid
|
||||
Suspended bool // Whether the subscription is suspended
|
||||
NotFound bool // Null marker: subscription confirmed not found/inactive in DB
|
||||
|
||||
@@ -117,11 +117,14 @@ func (a *NodeSubscriptionQuotaLoaderAdapter) LoadQuotaByID(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
// Build cached quota object
|
||||
// Cache the *traffic cycle* (resolved via ResolveTrafficPeriod) rather than
|
||||
// the billing period so the real-time node enforcer queries usage over the
|
||||
// correct window for calendar_month plans.
|
||||
cycle := subscription.ResolveTrafficPeriod(plan, sub)
|
||||
cachedQuota := &cache.CachedQuota{
|
||||
Limit: int64(trafficLimit),
|
||||
PeriodStart: sub.CurrentPeriodStart(),
|
||||
PeriodEnd: sub.CurrentPeriodEnd(),
|
||||
PeriodStart: cycle.Start,
|
||||
PeriodEnd: cycle.End,
|
||||
PlanType: plan.PlanType().String(),
|
||||
Suspended: false,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
package logger
|
||||
|
||||
import "log/slog"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// isContextCancellation reports whether err is a context cancellation or
|
||||
// deadline-exceeded error. Such errors usually indicate the caller (HTTP
|
||||
// client, background job that was cancelled, etc.) went away, not a real
|
||||
// backend failure, so logging them at ERROR creates alert noise.
|
||||
func isContextCancellation(err error) bool {
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// hasContextCancellationValue scans args for a non-nil error value that is a
|
||||
// context cancellation. Both `Errorw("msg", "key", err)` style and `Error("msg",
|
||||
// slog.Any("err", err))` style are supported by walking every arg and testing
|
||||
// any error-typed value.
|
||||
func hasContextCancellationValue(args []any) bool {
|
||||
for _, a := range args {
|
||||
switch v := a.(type) {
|
||||
case error:
|
||||
if isContextCancellation(v) {
|
||||
return true
|
||||
}
|
||||
case slog.Attr:
|
||||
if e, ok := v.Value.Any().(error); ok && isContextCancellation(e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Interface interface {
|
||||
Debug(msg string, args ...any)
|
||||
@@ -47,6 +79,13 @@ func (l *slogLogger) Warn(msg string, args ...any) {
|
||||
}
|
||||
|
||||
func (l *slogLogger) Error(msg string, args ...any) {
|
||||
// Demote context-cancellation errors to WARN: they indicate caller
|
||||
// lifecycle (request canceled, deadline reached) rather than backend
|
||||
// failure, so they should not page operators.
|
||||
if hasContextCancellationValue(args) {
|
||||
l.logger.Warn(msg, args...)
|
||||
return
|
||||
}
|
||||
l.logger.Error(msg, args...)
|
||||
}
|
||||
|
||||
@@ -80,6 +119,11 @@ func (l *slogLogger) Warnw(msg string, keysAndValues ...interface{}) {
|
||||
}
|
||||
|
||||
func (l *slogLogger) Errorw(msg string, keysAndValues ...interface{}) {
|
||||
// See Error: context-cancellation errors are not real failures.
|
||||
if hasContextCancellationValue(keysAndValues) {
|
||||
l.logger.Warn(msg, keysAndValues...)
|
||||
return
|
||||
}
|
||||
l.logger.Error(msg, keysAndValues...)
|
||||
}
|
||||
|
||||
|
||||
108
internal/shared/logger/interface_test.go
Normal file
108
internal/shared/logger/interface_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newCapturingLogger builds a slogLogger that writes JSON to buf at LevelDebug
|
||||
// so we can assert what level a given call landed on.
|
||||
func newCapturingLogger(buf *bytes.Buffer) *slogLogger {
|
||||
h := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
return &slogLogger{logger: slog.New(h)}
|
||||
}
|
||||
|
||||
func levelOf(t *testing.T, line string) string {
|
||||
t.Helper()
|
||||
// slog JSON output includes "level":"INFO|WARN|ERROR|DEBUG"
|
||||
for _, want := range []string{`"level":"DEBUG"`, `"level":"INFO"`, `"level":"WARN"`, `"level":"ERROR"`} {
|
||||
if strings.Contains(line, want) {
|
||||
return strings.Trim(strings.SplitN(want, ":", 2)[1], `"`)
|
||||
}
|
||||
}
|
||||
t.Fatalf("no level found in log line: %s", line)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestErrorw_DemotesContextCanceled(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newCapturingLogger(&buf)
|
||||
|
||||
log.Errorw("query failed", "id", 42, "error", context.Canceled)
|
||||
|
||||
if got := levelOf(t, buf.String()); got != "WARN" {
|
||||
t.Fatalf("expected WARN for context.Canceled, got %s; line=%s", got, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorw_DemotesDeadlineExceeded(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newCapturingLogger(&buf)
|
||||
|
||||
log.Errorw("query failed", "error", context.DeadlineExceeded)
|
||||
|
||||
if got := levelOf(t, buf.String()); got != "WARN" {
|
||||
t.Fatalf("expected WARN for context.DeadlineExceeded, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorw_DemotesWrappedCancellation(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newCapturingLogger(&buf)
|
||||
|
||||
wrapped := fmt.Errorf("db query failed: %w", context.Canceled)
|
||||
log.Errorw("repo failed", "error", wrapped)
|
||||
|
||||
if got := levelOf(t, buf.String()); got != "WARN" {
|
||||
t.Fatalf("expected WARN for wrapped cancellation, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorw_PreservesRealError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newCapturingLogger(&buf)
|
||||
|
||||
log.Errorw("real failure", "error", errors.New("connection refused"))
|
||||
|
||||
if got := levelOf(t, buf.String()); got != "ERROR" {
|
||||
t.Fatalf("expected ERROR for real error, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestError_DemotesContextCancellationViaSlogAttr(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newCapturingLogger(&buf)
|
||||
|
||||
log.Error("query failed", slog.Any("err", context.Canceled))
|
||||
|
||||
if got := levelOf(t, buf.String()); got != "WARN" {
|
||||
t.Fatalf("expected WARN for slog.Any(context.Canceled), got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestError_PreservesRealErrorWithSlogAttr(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newCapturingLogger(&buf)
|
||||
|
||||
log.Error("query failed", slog.Any("err", errors.New("boom")))
|
||||
|
||||
if got := levelOf(t, buf.String()); got != "ERROR" {
|
||||
t.Fatalf("expected ERROR for real slog.Any error, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorw_NoCancellationKeepsError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newCapturingLogger(&buf)
|
||||
|
||||
log.Errorw("just a message", "id", 7)
|
||||
|
||||
if got := levelOf(t, buf.String()); got != "ERROR" {
|
||||
t.Fatalf("expected ERROR when no error value present, got %s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user