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:
orris-inc
2026-04-29 14:06:20 +08:00
parent abe0bb74ea
commit 9beb449393
14 changed files with 502 additions and 90 deletions

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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),
)
}
}

View File

@@ -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),
)
}
}

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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,
}

View 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)
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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...)
}

View 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)
}
}