diff --git a/backend/internal/repository/account_usage_window_repo.go b/backend/internal/repository/account_usage_window_repo.go index 4688998069dd..28f7395b6223 100644 --- a/backend/internal/repository/account_usage_window_repo.go +++ b/backend/internal/repository/account_usage_window_repo.go @@ -13,6 +13,8 @@ import ( "github.com/lib/pq" ) +var errAccountUsageWindowConflict = errors.New("account usage window state conflict") + type accountUsageWindowRepository struct { sql sqlExecutor db *sql.DB @@ -42,7 +44,7 @@ func (r *accountUsageWindowRepository) UpsertOpen(ctx context.Context, row *usag } func (r *accountUsageWindowRepository) UpdateSample(ctx context.Context, id int64, peakPercent, lastPercent float64, sampledAt time.Time) error { - _, err := r.sql.ExecContext(ctx, ` + result, err := r.sql.ExecContext(ctx, ` UPDATE account_usage_windows SET peak_used_percent = GREATEST(peak_used_percent, $2), last_used_percent = $3, @@ -50,7 +52,17 @@ func (r *accountUsageWindowRepository) UpdateSample(ctx context.Context, id int6 updated_at = NOW() WHERE id = $1 AND status = 'open' `, id, peakPercent, lastPercent, sampledAt) - return err + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected != 1 { + return fmt.Errorf("%w: update window %d affected %d rows", errAccountUsageWindowConflict, id, affected) + } + return nil } func (r *accountUsageWindowRepository) CloseAndOpen(ctx context.Context, closed *usagestats.AccountUsageWindow, next *usagestats.AccountUsageWindow) error { @@ -115,7 +127,7 @@ func (r *accountUsageWindowRepository) closeOne(ctx context.Context, exec sqlExe if row.ClosedReason != "" { reason = row.ClosedReason } - _, err = exec.ExecContext(ctx, ` + result, err := exec.ExecContext(ctx, ` UPDATE account_usage_windows SET status = 'closed', closed_reason = $2, @@ -135,7 +147,17 @@ func (r *accountUsageWindowRepository) closeOne(ctx context.Context, exec sqlExe `, row.ID, reason, row.PeakUsedPercent, row.LastUsedPercent, row.LocalCost, row.StandardCost, row.UserCost, row.Requests, row.Tokens, row.InferredLimitUSD, row.InferredConfidence, breakdown, row.SampledAt) - return err + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected != 1 { + return fmt.Errorf("%w: close window %d affected %d rows", errAccountUsageWindowConflict, row.ID, affected) + } + return nil } func upsertOpenWith(ctx context.Context, exec sqlExecutor, row *usagestats.AccountUsageWindow) error { @@ -166,7 +188,7 @@ func upsertOpenWith(ctx context.Context, exec sqlExecutor, row *usagestats.Accou `, []any{row.AccountID, row.WindowType, row.WindowStart, row.WindowEnd, row.PeakUsedPercent, row.LastUsedPercent, breakdown, row.SampledAt}, &id) if errors.Is(err, sql.ErrNoRows) { - return nil + return fmt.Errorf("%w: open window already exists as a closed row", errAccountUsageWindowConflict) } if err != nil { return err diff --git a/backend/internal/repository/account_usage_window_repo_test.go b/backend/internal/repository/account_usage_window_repo_test.go new file mode 100644 index 000000000000..ea70f3d66bf7 --- /dev/null +++ b/backend/internal/repository/account_usage_window_repo_test.go @@ -0,0 +1,120 @@ +package repository + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/stretchr/testify/require" +) + +func newAccountUsageWindowRepoMock(t *testing.T) (*accountUsageWindowRepository, sqlmock.Sqlmock) { + t.Helper() + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return &accountUsageWindowRepository{sql: db, db: db}, mock +} + +func accountUsageWindowTestRows() (*usagestats.AccountUsageWindow, *usagestats.AccountUsageWindow) { + now := time.Date(2026, 8, 27, 11, 33, 43, 0, time.UTC) + closed := &usagestats.AccountUsageWindow{ + ID: 75, + AccountID: 20, + WindowType: usagestats.AccountWindowType7d, + WindowStart: now.Add(-7 * 24 * time.Hour), + WindowEnd: now, + Status: usagestats.AccountWindowStatusOpen, + PeakUsedPercent: 6, + LastUsedPercent: 6, + InferredConfidence: usagestats.AccountWindowConfidenceLow, + ModelBreakdown: []usagestats.AccountWindowModelStat{}, + SampledAt: now, + } + next := &usagestats.AccountUsageWindow{ + AccountID: 20, + WindowType: usagestats.AccountWindowType7d, + WindowStart: now, + WindowEnd: now.Add(7 * 24 * time.Hour), + Status: usagestats.AccountWindowStatusOpen, + InferredConfidence: usagestats.AccountWindowConfidenceLow, + ModelBreakdown: []usagestats.AccountWindowModelStat{}, + SampledAt: now, + } + return closed, next +} + +func TestAccountUsageWindowUpdateSampleRejectsStaleWindow(t *testing.T) { + repo, mock := newAccountUsageWindowRepoMock(t) + now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) + + mock.ExpectExec(`(?s)UPDATE account_usage_windows.*WHERE id = \$1 AND status = 'open'`). + WillReturnResult(sqlmock.NewResult(0, 0)) + + err := repo.UpdateSample(context.Background(), 75, 6, 6, now) + require.ErrorIs(t, err, errAccountUsageWindowConflict) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestAccountUsageWindowCloseAndOpenCommitsCompleteRoll(t *testing.T) { + repo, mock := newAccountUsageWindowRepoMock(t) + closed, next := accountUsageWindowTestRows() + + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE account_usage_windows.*WHERE id = \$1 AND status = 'open'`). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(`(?s)INSERT INTO account_usage_windows.*RETURNING id`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(76))) + mock.ExpectCommit() + + require.NoError(t, repo.CloseAndOpen(context.Background(), closed, next)) + require.Equal(t, int64(76), next.ID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestAccountUsageWindowCloseAndOpenRollsBackStaleClose(t *testing.T) { + repo, mock := newAccountUsageWindowRepoMock(t) + closed, next := accountUsageWindowTestRows() + + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE account_usage_windows.*WHERE id = \$1 AND status = 'open'`). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectRollback() + + err := repo.CloseAndOpen(context.Background(), closed, next) + require.ErrorIs(t, err, errAccountUsageWindowConflict) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestAccountUsageWindowCloseAndOpenRollsBackClosedTargetConflict(t *testing.T) { + repo, mock := newAccountUsageWindowRepoMock(t) + closed, next := accountUsageWindowTestRows() + + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE account_usage_windows.*WHERE id = \$1 AND status = 'open'`). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(`(?s)INSERT INTO account_usage_windows.*RETURNING id`). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectRollback() + + err := repo.CloseAndOpen(context.Background(), closed, next) + require.True(t, errors.Is(err, errAccountUsageWindowConflict)) + require.Zero(t, next.ID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestAccountUsageWindowUpsertOpenRejectsClosedTargetConflict(t *testing.T) { + repo, mock := newAccountUsageWindowRepoMock(t) + _, next := accountUsageWindowTestRows() + + mock.ExpectQuery(`(?s)INSERT INTO account_usage_windows.*RETURNING id`). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + err := repo.UpsertOpen(context.Background(), next) + require.ErrorIs(t, err, errAccountUsageWindowConflict) + require.Zero(t, next.ID) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/service/account_usage_window.go b/backend/internal/service/account_usage_window.go index 15972cb81a05..10eb53088f22 100644 --- a/backend/internal/service/account_usage_window.go +++ b/backend/internal/service/account_usage_window.go @@ -2,6 +2,7 @@ package service import ( "context" + "math" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" @@ -12,10 +13,9 @@ const ( accountWindowRollSkew = 2 * time.Minute // accountWindow7dRollSkew 7d 剩余秒数是 now+seconds 现算的,双源/取整常漂几分钟。 accountWindow7dRollSkew = 30 * time.Minute - // accountWindow7dRollMinJump 7d 占比几乎没掉时,reset_at 至少跳这么多才换窗。 - accountWindow7dRollMinJump = 6 * time.Hour - // accountWindowResetPercentDrop 官方占比至少下降这么多,才认为窗口被重置。 - accountWindowResetPercentDrop = 3.0 + // 官方 7d 时长偶尔会有取整差异,允许 6d-8d,拒绝 0 等占位窗口。 + accountWindow7dMinMinutes = 6 * 24 * 60 + accountWindow7dMaxMinutes = 8 * 24 * 60 // accountWindowLowPercent 低于此峰值不反推额度。 accountWindowLowPercent = 8.0 // accountWindowMediumPercent 低于此峰值为中等置信。 @@ -29,14 +29,23 @@ const ( // CodexWindowSample 一次官方 5h/7d 采样。缺字段表示这次没看到该窗。 type CodexWindowSample struct { - Used5hPercent *float64 - Reset5hAt *time.Time - Used7dPercent *float64 - Reset7dAt *time.Time - ClosedReason string - Now time.Time + Used5hPercent *float64 + Reset5hAt *time.Time + Used7dPercent *float64 + Reset7dAt *time.Time + Window7dMinutes *int + ClosedReason string + Now time.Time } +type accountWindowSampleAction uint8 + +const ( + accountWindowSampleUpdate accountWindowSampleAction = iota + accountWindowSampleIgnore + accountWindowSampleRoll +) + // CodexWindowObserver 在 Extra 覆盖之前观察官方窗口,避免节流丢掉换窗。 type CodexWindowObserver interface { Observe(ctx context.Context, accountID int64, sample CodexWindowSample) @@ -107,44 +116,59 @@ func sameAccountWindowWithin(prevEnd, nextEnd time.Time, skew time.Duration) boo return absDuration(nextEnd.Sub(prevEnd)) <= skew } -func shouldStayOnOpenWindow(open *usagestats.AccountUsageWindow, percent float64, resetAt *time.Time) bool { +func classifyAccountWindowSample(open *usagestats.AccountUsageWindow, resetAt *time.Time, now time.Time) accountWindowSampleAction { if open == nil { - return false + return accountWindowSampleRoll } if resetAt == nil { - return true + return accountWindowSampleUpdate } nextEnd := resetAt.UTC() if open.WindowType != usagestats.AccountWindowType7d { - return sameAccountWindow(open.WindowEnd, nextEnd) + if sameAccountWindow(open.WindowEnd, nextEnd) { + return accountWindowSampleUpdate + } + return accountWindowSampleRoll } if sameAccountWindowWithin(open.WindowEnd, nextEnd, accountWindow7dRollSkew) { - return true + return accountWindowSampleUpdate } - // 7d:占比没掉且 reset_at 没跳过数小时,当作剩余秒数抖动,挂在原窗。 - return !usagePercentDropped(windowUsedPercent(open), percent) && - !accountWindowResetJumped(open.WindowEnd, nextEnd, accountWindow7dRollMinJump) -} - -func windowUsedPercent(open *usagestats.AccountUsageWindow) float64 { - if open == nil { - return 0 + // 普通 probe 不能在官方窗口结束前提前换窗。主动 credit reset + // 走 CloseOpenWindows 的显式路径,不依赖这里的启发式判断。 + if now.Before(open.WindowEnd) { + return accountWindowSampleIgnore } - if open.PeakUsedPercent > open.LastUsedPercent { - return open.PeakUsedPercent + // 新 reset 必须明确落在当前窗口之后;旧值、倒退值都视为陈旧样本。 + if !nextEnd.After(open.WindowEnd.Add(accountWindow7dRollSkew)) { + return accountWindowSampleIgnore } - return open.LastUsedPercent + return accountWindowSampleRoll } -func usagePercentDropped(prev, next float64) bool { - return prev-next >= accountWindowResetPercentDrop -} - -func accountWindowResetJumped(prev, next time.Time, minJump time.Duration) bool { - if prev.IsZero() || next.IsZero() || minJump <= 0 { +func validAccountWindowSample(windowType string, used *float64, resetAt *time.Time, windowMinutes *int, now time.Time, opening bool) bool { + if used == nil { + return false + } + if math.IsNaN(*used) || math.IsInf(*used, 0) || *used < 0 || *used > 100 { return false } - return absDuration(next.Sub(prev)) >= minJump + if windowType == usagestats.AccountWindowType7d && windowMinutes != nil { + if *windowMinutes < accountWindow7dMinMinutes || *windowMinutes > accountWindow7dMaxMinutes { + return false + } + } + if resetAt != nil { + end := resetAt.UTC() + maxDuration := windowDuration(windowType) + if windowMinutes != nil { + maxDuration = time.Duration(*windowMinutes) * time.Minute + } + if !end.After(now) || (maxDuration > 0 && end.After(now.Add(maxDuration+accountWindow7dRollSkew))) { + return false + } + } + // 没有 reset_at 时,首次观测无法建立稳定的窗口身份。 + return !opening || resetAt != nil } func absDuration(d time.Duration) time.Duration { diff --git a/backend/internal/service/account_usage_window_test.go b/backend/internal/service/account_usage_window_test.go index 2356206e3ce8..f8c99cb77e38 100644 --- a/backend/internal/service/account_usage_window_test.go +++ b/backend/internal/service/account_usage_window_test.go @@ -14,8 +14,8 @@ func TestSameAccountWindowSkew(t *testing.T) { require.False(t, sameAccountWindow(base, base.Add(accountWindowRollSkew+time.Minute))) } -func TestShouldStayOnOpenWindow7dIgnoresResetJitter(t *testing.T) { - end := time.Date(2026, 8, 20, 3, 33, 43, 0, time.UTC) +func TestClassifyAccountWindowSample7d(t *testing.T) { + end := time.Date(2026, 8, 27, 3, 33, 43, 0, time.UTC) open := &usagestats.AccountUsageWindow{ WindowType: usagestats.AccountWindowType7d, WindowEnd: end, @@ -24,26 +24,26 @@ func TestShouldStayOnOpenWindow7dIgnoresResetJitter(t *testing.T) { } jitter := end.Add(3 * time.Minute) - require.True(t, shouldStayOnOpenWindow(open, 23, &jitter)) + require.Equal(t, accountWindowSampleUpdate, classifyAccountWindowSample(open, &jitter, end.Add(-24*time.Hour))) back := end.Add(-3 * time.Minute) - require.True(t, shouldStayOnOpenWindow(open, 23, &back)) - require.True(t, shouldStayOnOpenWindow(open, 23, nil)) + require.Equal(t, accountWindowSampleUpdate, classifyAccountWindowSample(open, &back, end.Add(-24*time.Hour))) + require.Equal(t, accountWindowSampleUpdate, classifyAccountWindowSample(open, nil, end.Add(-24*time.Hour))) - // 超过 30 分钟但仍不到数小时,占比没掉:继续挂原窗。 - drift := end.Add(2 * time.Hour) - require.True(t, shouldStayOnOpenWindow(open, 23, &drift)) - require.True(t, shouldStayOnOpenWindow(open, 22, &drift)) + // A contradictory reset cannot roll an official window several days early. + earlyNow := time.Date(2026, 8, 22, 3, 33, 43, 0, time.UTC) + earlyReset := earlyNow.Add(7 * 24 * time.Hour) + require.Equal(t, accountWindowSampleIgnore, classifyAccountWindowSample(open, &earlyReset, earlyNow)) - // 占比明显下降,即使 reset_at 只挪了不到 6 小时,也换窗。 - dropped := end.Add(40 * time.Minute) - require.False(t, shouldStayOnOpenWindow(open, 1, &dropped)) + // Once the current window has ended, a reset for the following week rolls it. + rollNow := end.Add(time.Second) + nextEnd := end.Add(7 * 24 * time.Hour) + require.Equal(t, accountWindowSampleRoll, classifyAccountWindowSample(open, &nextEnd, rollNow)) - // 漏掉了占比回落,但 reset_at 跳了大半天:换窗。 - jumped := end.Add(7 * 24 * time.Hour) - require.False(t, shouldStayOnOpenWindow(open, 22, &jumped)) + stale := end.Add(-time.Hour) + require.Equal(t, accountWindowSampleIgnore, classifyAccountWindowSample(open, &stale, rollNow)) } -func TestShouldStayOnOpenWindow5hKeepsTightSkew(t *testing.T) { +func TestClassifyAccountWindowSample5hKeepsTightSkew(t *testing.T) { end := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) open := &usagestats.AccountUsageWindow{ WindowType: usagestats.AccountWindowType5h, @@ -52,9 +52,31 @@ func TestShouldStayOnOpenWindow5hKeepsTightSkew(t *testing.T) { LastUsedPercent: 40, } near := end.Add(accountWindowRollSkew) - require.True(t, shouldStayOnOpenWindow(open, 40, &near)) + require.Equal(t, accountWindowSampleUpdate, classifyAccountWindowSample(open, &near, end.Add(-time.Hour))) far := end.Add(accountWindowRollSkew + time.Minute) - require.False(t, shouldStayOnOpenWindow(open, 40, &far)) + require.Equal(t, accountWindowSampleRoll, classifyAccountWindowSample(open, &far, end.Add(-time.Hour))) +} + +func TestValidAccountWindowSample(t *testing.T) { + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + used := 6.0 + reset := now.Add(7 * 24 * time.Hour) + minutes := 7 * 24 * 60 + require.True(t, validAccountWindowSample(usagestats.AccountWindowType7d, &used, &reset, &minutes, now, true)) + + zeroMinutes := 0 + require.False(t, validAccountWindowSample(usagestats.AccountWindowType7d, &used, &reset, &zeroMinutes, now, true)) + past := now + require.False(t, validAccountWindowSample(usagestats.AccountWindowType7d, &used, &past, &minutes, now, true)) + invalidPercent := 101.0 + require.False(t, validAccountWindowSample(usagestats.AccountWindowType7d, &invalidPercent, &reset, &minutes, now, true)) + require.False(t, validAccountWindowSample(usagestats.AccountWindowType7d, nil, &reset, &minutes, now, true)) + require.False(t, validAccountWindowSample(usagestats.AccountWindowType7d, &used, nil, &minutes, now, true)) + require.True(t, validAccountWindowSample(usagestats.AccountWindowType7d, &used, nil, &minutes, now, false)) + + eightDayMinutes := 8 * 24 * 60 + eightDayReset := now.Add(8 * 24 * time.Hour) + require.True(t, validAccountWindowSample(usagestats.AccountWindowType7d, &used, &eightDayReset, &eightDayMinutes, now, true)) } func TestBuildOpenWindowUsesPreviousReset(t *testing.T) { diff --git a/backend/internal/service/account_usage_windows_query.go b/backend/internal/service/account_usage_windows_query.go index da1483c99fbe..833f8539bebb 100644 --- a/backend/internal/service/account_usage_windows_query.go +++ b/backend/internal/service/account_usage_windows_query.go @@ -313,6 +313,9 @@ func sampleFromCodexExtra(updates map[string]any, now time.Time, reason string) if resetAt, ok := extraTimePtr(updates["codex_7d_reset_at"]); ok { sample.Reset7dAt = resetAt } + if minutes, ok := extraIntPtr(updates["codex_7d_window_minutes"]); ok { + sample.Window7dMinutes = minutes + } return sample } @@ -335,6 +338,14 @@ func extraTimePtr(raw any) (*time.Time, bool) { return &parsed, true } +func extraIntPtr(raw any) (*int, bool) { + if raw == nil { + return nil, false + } + value := parseExtraInt(raw) + return &value, true +} + func observeCodexWindow(observer CodexWindowObserver, accountID int64, sample CodexWindowSample) { if observer == nil || accountID <= 0 { return diff --git a/backend/internal/service/account_usage_windows_query_test.go b/backend/internal/service/account_usage_windows_query_test.go index 3b4a1452916c..3364d40a6de2 100644 --- a/backend/internal/service/account_usage_windows_query_test.go +++ b/backend/internal/service/account_usage_windows_query_test.go @@ -141,6 +141,23 @@ func TestFinalizeWindowSamples_KeepsTickSlopeAfterDownsample(t *testing.T) { require.Greater(t, *slope, 20.0) } +func TestSampleFromCodexExtraIncludes7dWindowMinutes(t *testing.T) { + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + reset := now.Add(7 * 24 * time.Hour) + sample := sampleFromCodexExtra(map[string]any{ + "codex_7d_used_percent": 6.0, + "codex_7d_reset_at": reset.Format(time.RFC3339), + "codex_7d_window_minutes": "10080", + }, now, usagestats.AccountWindowClosedProbe) + + require.NotNil(t, sample.Used7dPercent) + require.InDelta(t, 6, *sample.Used7dPercent, 0.001) + require.NotNil(t, sample.Reset7dAt) + require.Equal(t, reset, *sample.Reset7dAt) + require.NotNil(t, sample.Window7dMinutes) + require.Equal(t, 10080, *sample.Window7dMinutes) +} + func TestAccountUsageService_GetUsageWindows_RejectsBadType(t *testing.T) { svc := &AccountUsageService{windowRepo: newWindowRepoStub()} _, err := svc.GetUsageWindows(context.Background(), 1, time.Now().Add(-time.Hour), time.Now(), "monthly") diff --git a/backend/internal/service/codex_window_recorder.go b/backend/internal/service/codex_window_recorder.go index 2d4bc91a457f..d515d1c24024 100644 --- a/backend/internal/service/codex_window_recorder.go +++ b/backend/internal/service/codex_window_recorder.go @@ -26,7 +26,7 @@ func (r *CodexWindowRecorder) Observe(ctx context.Context, accountID int64, samp if now.IsZero() { now = time.Now() } - r.observeOne(ctx, accountID, usagestats.AccountWindowType7d, sample.Used7dPercent, sample.Reset7dAt, sample.ClosedReason, now) + r.observeOne(ctx, accountID, usagestats.AccountWindowType7d, sample.Used7dPercent, sample.Reset7dAt, sample.Window7dMinutes, sample.ClosedReason, now) } func (r *CodexWindowRecorder) CloseOpenWindows(ctx context.Context, accountID int64, reason string) error { @@ -57,6 +57,7 @@ func (r *CodexWindowRecorder) observeOne( windowType string, used *float64, resetAt *time.Time, + windowMinutes *int, reason string, now time.Time, ) { @@ -72,9 +73,19 @@ func (r *CodexWindowRecorder) observeOne( slog.Warn("codex_window_get_open_failed", "account_id", accountID, "window_type", windowType, "error", err) return } - if open != nil && shouldStayOnOpenWindow(open, percent, resetAt) { + if !validAccountWindowSample(windowType, used, resetAt, windowMinutes, now, open == nil) { + slog.Debug("codex_window_sample_ignored", "account_id", accountID, "window_type", windowType, "reason", "invalid_sample") + return + } + action := classifyAccountWindowSample(open, resetAt, now) + if action == accountWindowSampleIgnore { + slog.Debug("codex_window_sample_ignored", "account_id", accountID, "window_type", windowType, "reason", "conflicts_with_open_window") + return + } + if action == accountWindowSampleUpdate { if err := r.repo.UpdateSample(ctx, open.ID, percent, percent, now); err != nil { slog.Warn("codex_window_sample_failed", "account_id", accountID, "window_type", windowType, "error", err) + return } r.recordTrajectory(ctx, open, percent, now) return diff --git a/backend/internal/service/codex_window_recorder_test.go b/backend/internal/service/codex_window_recorder_test.go index f0bd538fb249..ba84f9f1272d 100644 --- a/backend/internal/service/codex_window_recorder_test.go +++ b/backend/internal/service/codex_window_recorder_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "testing" "time" @@ -177,7 +178,7 @@ func TestCodexWindowRecorder_ResetAtJumpClosesAndOpens(t *testing.T) { rec.Observe(context.Background(), 7, CodexWindowSample{ Used7dPercent: &used, Reset7dAt: &firstEnd, Now: now, }) - nextEnd := firstEnd.Add(5 * time.Hour) + nextEnd := firstEnd.Add(7 * 24 * time.Hour) used = 1.0 rec.Observe(context.Background(), 7, CodexWindowSample{ Used7dPercent: &used, Reset7dAt: &nextEnd, Now: firstEnd.Add(time.Minute), @@ -215,6 +216,102 @@ func TestCodexWindowRecorder_ResetAtJitterKeepsWindow(t *testing.T) { require.InDelta(t, 23.0, open.LastUsedPercent, 0.001) } +func TestCodexWindowRecorder_IgnoresResetAtNowDuringOpenWindow(t *testing.T) { + repo := newWindowRepoStub() + rec := NewCodexWindowRecorder(repo) + now := time.Date(2026, 8, 20, 11, 33, 43, 0, time.UTC) + end := now.Add(7 * 24 * time.Hour) + used := 6.0 + rec.Observe(context.Background(), 7, CodexWindowSample{ + Used7dPercent: &used, Reset7dAt: &end, Now: now, + }) + + badNow := now.Add(4*time.Hour + 25*time.Minute) + zero := 0.0 + rec.Observe(context.Background(), 7, CodexWindowSample{ + Used7dPercent: &zero, Reset7dAt: &badNow, Now: badNow, + }) + + require.Equal(t, 0, repo.rolls) + require.Equal(t, 0, repo.samples) + require.Len(t, repo.trajectory[repo.open[usagestats.AccountWindowType7d].ID], 1) + require.InDelta(t, 6, repo.open[usagestats.AccountWindowType7d].LastUsedPercent, 0.001) +} + +func TestCodexWindowRecorder_IgnoresEarlyFutureReset(t *testing.T) { + repo := newWindowRepoStub() + rec := NewCodexWindowRecorder(repo) + now := time.Date(2026, 8, 20, 11, 33, 43, 0, time.UTC) + end := now.Add(7 * 24 * time.Hour) + used := 6.0 + rec.Observe(context.Background(), 7, CodexWindowSample{ + Used7dPercent: &used, Reset7dAt: &end, Now: now, + }) + + probeNow := now.Add(2 * 24 * time.Hour) + probeEnd := probeNow.Add(7 * 24 * time.Hour) + zero := 0.0 + rec.Observe(context.Background(), 7, CodexWindowSample{ + Used7dPercent: &zero, Reset7dAt: &probeEnd, Now: probeNow, + }) + + require.Equal(t, 0, repo.rolls) + require.Equal(t, 0, repo.samples) + require.Len(t, repo.trajectory[repo.open[usagestats.AccountWindowType7d].ID], 1) + require.Equal(t, end, repo.open[usagestats.AccountWindowType7d].WindowEnd) + require.InDelta(t, 6, repo.open[usagestats.AccountWindowType7d].LastUsedPercent, 0.001) +} + +func TestCodexWindowRecorder_UpdateConflictSkipsTrajectory(t *testing.T) { + repo := newWindowRepoStub() + rec := NewCodexWindowRecorder(repo) + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + end := now.Add(7 * 24 * time.Hour) + used := 6.0 + rec.Observe(context.Background(), 7, CodexWindowSample{ + Used7dPercent: &used, Reset7dAt: &end, Now: now, + }) + windowID := repo.open[usagestats.AccountWindowType7d].ID + require.Len(t, repo.trajectory[windowID], 1) + + repo.updateErr = errors.New("stale window") + used = 7.0 + rec.Observe(context.Background(), 7, CodexWindowSample{ + Used7dPercent: &used, Reset7dAt: &end, Now: now.Add(time.Minute), + }) + + require.Equal(t, 1, repo.samples) + require.Len(t, repo.trajectory[windowID], 1) +} + +func TestCodexWindowRecorder_ResetOnlySampleIsIgnored(t *testing.T) { + repo := newWindowRepoStub() + rec := NewCodexWindowRecorder(repo) + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + end := now.Add(7 * 24 * time.Hour) + rec.Observe(context.Background(), 7, CodexWindowSample{ + Reset7dAt: &end, Now: now, + }) + + require.Equal(t, 0, repo.upserts) + require.Nil(t, repo.open[usagestats.AccountWindowType7d]) +} + +func TestCodexWindowRecorder_InvalidWindowMinutesCannotOpen(t *testing.T) { + repo := newWindowRepoStub() + rec := NewCodexWindowRecorder(repo) + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + end := now.Add(7 * 24 * time.Hour) + used := 0.0 + minutes := 0 + rec.Observe(context.Background(), 7, CodexWindowSample{ + Used7dPercent: &used, Reset7dAt: &end, Window7dMinutes: &minutes, Now: now, + }) + + require.Equal(t, 0, repo.upserts) + require.Nil(t, repo.open[usagestats.AccountWindowType7d]) +} + func TestInferAccountWindowLimit(t *testing.T) { limit, conf := inferAccountWindowLimit(8, 4) require.Nil(t, limit)