diff --git a/apps/manager-server/internal/repository/modelprice/repository.go b/apps/manager-server/internal/repository/modelprice/repository.go
index bafbe7de4..cf6feb0fe 100644
--- a/apps/manager-server/internal/repository/modelprice/repository.go
+++ b/apps/manager-server/internal/repository/modelprice/repository.go
@@ -10,7 +10,7 @@ import (
"time"
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model"
- sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite"
+ "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usagepricing"
)
var ErrStructureChangeAfterRawDeletion = errors.New("model price structure cannot change after archived raw usage has been deleted")
@@ -251,13 +251,12 @@ func (r *repository) ReplaceAll(ctx context.Context, prices map[string]model.Mod
beforeRevision := model.ModelPriceStructureRevision(beforePrices)
afterRevision := model.ModelPriceStructureRevision(normalizedPrices)
if beforeRevision != afterRevision {
- hasDeletedRaw, err := sqliterepo.HistoricalRawDeletionExists(tx)
- if err != nil {
+ if err := usagepricing.VerifyRetainedPricingRebuildSourceTx(ctx, tx); err != nil {
+ if errors.Is(err, usagepricing.ErrRetainedPricingHistoryIncomplete) {
+ return fmt.Errorf("%w: %v", ErrStructureChangeAfterRawDeletion, err)
+ }
return err
}
- if hasDeletedRaw {
- return ErrStructureChangeAfterRawDeletion
- }
}
if _, err := tx.ExecContext(ctx, `delete from model_price_service_tiers`); err != nil {
@@ -463,13 +462,12 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M
beforeRevision := model.ModelPriceStructureRevision(beforePrices)
afterRevision := model.ModelPriceStructureRevision(afterPrices)
if beforeRevision != afterRevision {
- hasDeletedRaw, err := sqliterepo.HistoricalRawDeletionExists(tx)
- if err != nil {
+ if err := usagepricing.VerifyRetainedPricingRebuildSourceTx(ctx, tx); err != nil {
+ if errors.Is(err, usagepricing.ErrRetainedPricingHistoryIncomplete) {
+ return model.ModelPriceSyncResult{}, fmt.Errorf("%w: %v", ErrStructureChangeAfterRawDeletion, err)
+ }
return model.ModelPriceSyncResult{}, err
}
- if hasDeletedRaw {
- return model.ModelPriceSyncResult{}, ErrStructureChangeAfterRawDeletion
- }
}
sort.Strings(result.Preserved)
if err := tx.Commit(); err != nil {
diff --git a/apps/manager-server/internal/repository/modelprice/repository_test.go b/apps/manager-server/internal/repository/modelprice/repository_test.go
index 9b331aee8..c3de6ff90 100644
--- a/apps/manager-server/internal/repository/modelprice/repository_test.go
+++ b/apps/manager-server/internal/repository/modelprice/repository_test.go
@@ -10,6 +10,7 @@ import (
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model"
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/modelprice"
sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite"
+ "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/usagemonitoring"
)
func openTestDB(t *testing.T) (*sql.DB, modelprice.Repository) {
@@ -43,6 +44,36 @@ func markRawDeleted(t *testing.T, db *sql.DB) {
insertArchiveRef(t, db, 2000)
}
+func markRawDeletedWithRetainedProjection(t *testing.T, db *sql.DB) {
+ t.Helper()
+ ctx := context.Background()
+ const eventHash = "hash-retained"
+ if _, err := db.Exec(`insert into usage_events(
+ id, event_hash, timestamp_ms, timestamp, model, created_at_ms
+ ) values(1, ?, 1000, '1970-01-01T00:00:01Z', 'model-a', 1000)`, eventHash); err != nil {
+ t.Fatalf("insert retained raw event: %v", err)
+ }
+ projection := usagemonitoring.New(db)
+ result, err := projection.CatchUpProjection(ctx, 10, 1500)
+ if err != nil {
+ t.Fatalf("build retained projection: %v", err)
+ }
+ if result.CoverageEventID != 1 || result.Pending {
+ t.Fatalf("unexpected retained projection result: %#v", result)
+ }
+ if _, err := db.Exec(`pragma foreign_keys = off`); err != nil {
+ t.Fatalf("disable foreign keys: %v", err)
+ }
+ if _, err := db.Exec(`insert into usage_archive_event_refs(
+ event_hash, run_id, segment_sequence, raw_event_id, timestamp_ms, archived_at_ms, raw_deleted_at_ms
+ ) values(?, 'run-retained', 1, 1, 1000, 1600, 1700)`, eventHash); err != nil {
+ t.Fatalf("insert retained archive ref: %v", err)
+ }
+ if _, err := db.Exec(`delete from usage_events where id = 1`); err != nil {
+ t.Fatalf("delete retained raw event: %v", err)
+ }
+}
+
// Test P1-1:没有 raw deletion,structure change 允许
func TestModelPriceStructureChangeAllowedWithoutRawDeletion(t *testing.T) {
ctx := context.Background()
@@ -514,3 +545,134 @@ func TestModelPriceUpsertSyncedManualPriceAndNewModelRejectedWithRawDeletion(t *
t.Fatalf("model-b should not exist after rollback")
}
}
+
+
+func TestModelPriceStructureChangeAllowedAfterRawDeletionWithRetainedProjection(t *testing.T) {
+ ctx := context.Background()
+ db, repo := openTestDB(t)
+
+ if err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
+ "model-a": {Prompt: 1.0, Completion: 2.0},
+ }); err != nil {
+ t.Fatalf("initial ReplaceAll: %v", err)
+ }
+ markRawDeletedWithRetainedProjection(t, db)
+
+ if err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
+ "model-a": {Prompt: 1.0, Completion: 2.0},
+ "model-b": {Prompt: 3.0, Completion: 4.0},
+ }); err != nil {
+ t.Fatalf("structure change with retained projection: %v", err)
+ }
+
+ persisted, err := repo.LoadAll(ctx)
+ if err != nil {
+ t.Fatalf("LoadAll: %v", err)
+ }
+ if _, ok := persisted["model-b"]; !ok {
+ t.Fatalf("expected model-b to be persisted")
+ }
+}
+
+func TestModelPriceUpsertSyncedStructureChangeAllowedAfterRawDeletionWithRetainedProjection(t *testing.T) {
+ ctx := context.Background()
+ db, repo := openTestDB(t)
+
+ if err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
+ "model-a": {Prompt: 1.0, Completion: 2.0},
+ }); err != nil {
+ t.Fatalf("initial ReplaceAll: %v", err)
+ }
+ markRawDeletedWithRetainedProjection(t, db)
+
+ result, err := repo.UpsertSynced(ctx, map[string]model.ModelPrice{
+ "model-b": {Prompt: 3.0, Completion: 4.0, Source: "sync"},
+ })
+ if err != nil {
+ t.Fatalf("UpsertSynced with retained projection: %v", err)
+ }
+ if result.Imported != 1 {
+ t.Fatalf("unexpected sync result: %+v", result)
+ }
+ persisted, err := repo.LoadAll(ctx)
+ if err != nil {
+ t.Fatalf("LoadAll: %v", err)
+ }
+ if _, ok := persisted["model-b"]; !ok {
+ t.Fatalf("expected model-b to be persisted")
+ }
+}
+
+
+func TestModelPriceStructureChangePreservesRetainedVerifierSystemError(t *testing.T) {
+ ctx := context.Background()
+ db, repo := openTestDB(t)
+
+ if err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
+ "model-a": {Prompt: 1.0, Completion: 2.0},
+ }); err != nil {
+ t.Fatalf("initial ReplaceAll: %v", err)
+ }
+ markRawDeleted(t, db)
+ if _, err := db.Exec(`drop table usage_monitoring_rollup_state`); err != nil {
+ t.Fatalf("drop monitoring rollup state: %v", err)
+ }
+
+ err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
+ "model-a": {Prompt: 1.0, Completion: 2.0},
+ "model-b": {Prompt: 3.0, Completion: 4.0},
+ })
+ if err == nil {
+ t.Fatal("expected retained verifier system error")
+ }
+ if errors.Is(err, modelprice.ErrStructureChangeAfterRawDeletion) {
+ t.Fatalf("system error was misclassified as structure conflict: %v", err)
+ }
+
+ persisted, loadErr := repo.LoadAll(ctx)
+ if loadErr != nil {
+ t.Fatalf("LoadAll: %v", loadErr)
+ }
+ if len(persisted) != 1 {
+ t.Fatalf("expected mutation rollback, got %d models", len(persisted))
+ }
+ if _, ok := persisted["model-b"]; ok {
+ t.Fatal("model-b should not exist after verifier system error")
+ }
+}
+
+func TestModelPriceUpsertSyncedPreservesRetainedVerifierSystemError(t *testing.T) {
+ ctx := context.Background()
+ db, repo := openTestDB(t)
+
+ if err := repo.ReplaceAll(ctx, map[string]model.ModelPrice{
+ "model-a": {Prompt: 1.0, Completion: 2.0},
+ }); err != nil {
+ t.Fatalf("initial ReplaceAll: %v", err)
+ }
+ markRawDeleted(t, db)
+ if _, err := db.Exec(`drop table usage_monitoring_rollup_state`); err != nil {
+ t.Fatalf("drop monitoring rollup state: %v", err)
+ }
+
+ result, err := repo.UpsertSynced(ctx, map[string]model.ModelPrice{
+ "model-b": {Prompt: 3.0, Completion: 4.0, Source: "sync"},
+ })
+ if err == nil {
+ t.Fatalf("expected retained verifier system error, got result=%+v", result)
+ }
+ if errors.Is(err, modelprice.ErrStructureChangeAfterRawDeletion) {
+ t.Fatalf("system error was misclassified as structure conflict: %v", err)
+ }
+
+ persisted, loadErr := repo.LoadAll(ctx)
+ if loadErr != nil {
+ t.Fatalf("LoadAll: %v", loadErr)
+ }
+ if len(persisted) != 1 {
+ t.Fatalf("expected sync rollback, got %d models", len(persisted))
+ }
+ if _, ok := persisted["model-b"]; ok {
+ t.Fatal("model-b should not exist after verifier system error")
+ }
+}
diff --git a/apps/manager-server/internal/repository/usagepricing/projection_read.go b/apps/manager-server/internal/repository/usagepricing/projection_read.go
index fd25e47ec..b1c62bbee 100644
--- a/apps/manager-server/internal/repository/usagepricing/projection_read.go
+++ b/apps/manager-server/internal/repository/usagepricing/projection_read.go
@@ -11,6 +11,16 @@ import (
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usageidentity"
)
+var retainedPricingColumns = []string{
+ "timestamp_ms", "model", "requested_model", "resolved_model", "service_tier", "failed",
+ "input_tokens", "output_tokens", "reasoning_tokens", "cached_tokens", "cache_tokens",
+ "cache_read_tokens", "cache_creation_tokens", "normalized_total_input_tokens", "total_tokens", "latency_ms",
+ "provider", "auth_index", "source", "source_hash", "account_snapshot", "auth_label_snapshot",
+ "auth_file_snapshot", "auth_provider_snapshot", "auth_project_id_snapshot", "auth_account_id_snapshot",
+}
+
+var ErrRetainedPricingHistoryIncomplete = errors.New("retained pricing history is incomplete")
+
// LoadHourlyRowsFromEventsTx bypasses a deficient pricing cache without writing
// to it. A compatible retained projection supplies archived events; raw events
// after its watermark supply the tail, with no overlapping IDs.
@@ -35,51 +45,170 @@ func (r *repository) LoadHourlyRowsFromEventsTx(ctx context.Context, tx *sql.Tx,
}
// LoadAccountRowsFromEventsTx uses the same retained event source and exact
-// pricing bands as hourly recovery. The caller must verify core coverage.
+// pricing bands as hourly recovery. The retained projection arm is scoped by
+// account_key before entering the pricing CTE so degraded account-history reads
+// can use the projection's account index instead of scanning retained history.
+// The caller must verify core coverage.
func (r *repository) LoadAccountRowsFromEventsTx(ctx context.Context, tx *sql.Tx, accountKeys []string) ([]AccountRow, error) {
keys := normalizeValues(accountKeys)
if len(keys) == 0 {
return []AccountRow{}, nil
}
- source, err := retainedEventSourceTx(ctx, tx)
+ source, sourceArgs, err := retainedAccountEventSourceTx(ctx, tx, keys)
if err != nil {
return nil, err
}
grouped := map[accountKey]*AccountRow{}
- if err := mergeAccountRowsFromSource(ctx, tx, 0, keys, grouped, source); err != nil {
+ if err := mergeAccountRowsFromSourceArgs(ctx, tx, 0, keys, grouped, source, sourceArgs); err != nil {
return nil, err
}
return sortedAccountRows(grouped), nil
}
-func retainedEventSourceTx(ctx context.Context, tx *sql.Tx) (string, error) {
- source := "usage_events"
+// VerifyRetainedPricingRebuildSourceTx proves that every raw event removed by
+// the supported archive flow is still available from the compatible monitoring
+// projection. Structural model-price mutations call this before commit so an
+// unrecoverable pricing revision can never become active.
+func VerifyRetainedPricingRebuildSourceTx(ctx context.Context, tx *sql.Tx) error {
+ _, _, err := retainedPricingRebuildSourceTx(ctx, tx)
+ return err
+}
+
+// retainedPricingRebuildSourceTx returns the exact event source and target ID
+// used only for full pricing rebuilds. Live raw rows are authoritative when
+// present; the projection contributes only events whose raw rows were deleted.
+// This keeps the rebuild source duplicate-free while preserving original event
+// IDs for the existing checkpoint state machine.
+func retainedPricingRebuildSourceTx(ctx context.Context, tx *sql.Tx) (string, int64, error) {
+ var hasDeletedRaw bool
+ if err := tx.QueryRowContext(ctx, `select exists (
+ select 1 from usage_archive_event_refs where raw_deleted_at_ms is not null
+ )`).Scan(&hasDeletedRaw); err != nil {
+ return "", 0, err
+ }
+ if !hasDeletedRaw {
+ latestID, err := latestEventID(ctx, tx)
+ return "usage_events", latestID, err
+ }
+
var schemaVersion int
var revision, status string
var coverageID int64
- err := tx.QueryRowContext(ctx, `select schema_version, structure_revision, status, coverage_event_id
+ if err := tx.QueryRowContext(ctx, `select schema_version, structure_revision, status, coverage_event_id
from usage_monitoring_rollup_state where rollup_name = 'projection_v1'`).Scan(
&schemaVersion, &revision, &status, &coverageID,
+ ); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", 0, fmt.Errorf("%w: retained usage projection state is unavailable", ErrRetainedPricingHistoryIncomplete)
+ }
+ return "", 0, err
+ }
+ if schemaVersion != 1 ||
+ revision != usageidentity.MonitoringProjectionStructureRevision() ||
+ status == "clearing" {
+ return "", 0, fmt.Errorf("%w: retained usage projection is incompatible or rebuilding", ErrRetainedPricingHistoryIncomplete)
+ }
+
+ var incomplete bool
+ if err := tx.QueryRowContext(ctx, `select exists (
+ select 1
+ from usage_archive_event_refs archived
+ left join usage_monitoring_event_projection_v1 p
+ on p.event_id = archived.raw_event_id
+ where archived.raw_deleted_at_ms is not null
+ and not exists (
+ select 1 from usage_events live where live.event_hash = archived.event_hash
+ )
+ and (archived.raw_event_id > ? or p.event_id is null)
+ )`, coverageID).Scan(&incomplete); err != nil {
+ return "", 0, err
+ }
+ if incomplete {
+ return "", 0, fmt.Errorf("%w: retained usage projection is missing a deleted pricing event", ErrRetainedPricingHistoryIncomplete)
+ }
+
+ var latestID int64
+ if err := tx.QueryRowContext(ctx, `select coalesce(max(event_id), 0) from (
+ select id as event_id from usage_events
+ union all
+ select archived.raw_event_id as event_id
+ from usage_archive_event_refs archived
+ where archived.raw_deleted_at_ms is not null
+ and not exists (
+ select 1 from usage_events live where live.event_hash = archived.event_hash
+ )
+ )`).Scan(&latestID); err != nil {
+ return "", 0, err
+ }
+
+ source := fmt.Sprintf(`(select e.id, e.%s from usage_events e
+ union all
+ select p.event_id as id, p.%s
+ from usage_archive_event_refs archived
+ join %s p on p.event_id = archived.raw_event_id
+ where archived.raw_deleted_at_ms is not null
+ and not exists (
+ select 1 from usage_events live where live.event_hash = archived.event_hash
+ ))`,
+ strings.Join(retainedPricingColumns, ", e."),
+ strings.Join(retainedPricingColumns, ", p."),
+ usageprojection.EventTable,
)
- if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return source, latestID, nil
+}
+
+func retainedEventSourceTx(ctx context.Context, tx *sql.Tx) (string, error) {
+ coverageID, compatible, err := retainedProjectionCoverageTx(ctx, tx)
+ if err != nil {
return "", err
}
- if err == nil && schemaVersion == 1 && coverageID > 0 &&
- revision == usageidentity.MonitoringProjectionStructureRevision() && status != "clearing" {
- // These are the inputs used by the existing pricing-band query. Do not
- // synthesize request details or trust an obsolete projection identity.
- columns := []string{
- "timestamp_ms", "model", "requested_model", "resolved_model", "service_tier", "failed",
- "input_tokens", "output_tokens", "reasoning_tokens", "cached_tokens", "cache_tokens",
- "cache_read_tokens", "cache_creation_tokens", "normalized_total_input_tokens", "total_tokens", "latency_ms",
- "provider", "auth_index", "source", "source_hash", "account_snapshot", "auth_label_snapshot",
- "auth_file_snapshot", "auth_provider_snapshot", "auth_project_id_snapshot", "auth_account_id_snapshot",
+ if !compatible || coverageID <= 0 {
+ return "usage_events", nil
+ }
+ return fmt.Sprintf(`(select p.event_id as id, p.%s from %s p where p.event_id <= %d
+ union all select e.id, e.%s from usage_events e where e.id > %d)`,
+ strings.Join(retainedPricingColumns, ", p."), usageprojection.EventTable, coverageID,
+ strings.Join(retainedPricingColumns, ", e."), coverageID,
+ ), nil
+}
+
+func retainedAccountEventSourceTx(ctx context.Context, tx *sql.Tx, accountKeys []string) (string, []any, error) {
+ coverageID, compatible, err := retainedProjectionCoverageTx(ctx, tx)
+ if err != nil {
+ return "", nil, err
+ }
+ if !compatible || coverageID <= 0 {
+ return "usage_events", nil, nil
+ }
+ placeholders := strings.TrimRight(strings.Repeat("?,", len(accountKeys)), ",")
+ args := make([]any, 0, len(accountKeys))
+ for _, key := range accountKeys {
+ args = append(args, key)
+ }
+ return fmt.Sprintf(`(select p.event_id as id, p.%s from %s p
+ where p.account_key in (%s) and p.event_id <= %d
+ union all select e.id, e.%s from usage_events e where e.id > %d)`,
+ strings.Join(retainedPricingColumns, ", p."), usageprojection.EventTable, placeholders, coverageID,
+ strings.Join(retainedPricingColumns, ", e."), coverageID,
+ ), args, nil
+}
+
+func retainedProjectionCoverageTx(ctx context.Context, tx *sql.Tx) (int64, bool, error) {
+ var schemaVersion int
+ var revision, status string
+ var coverageID int64
+ err := tx.QueryRowContext(ctx, `select schema_version, structure_revision, status, coverage_event_id
+ from usage_monitoring_rollup_state where rollup_name = 'projection_v1'`).Scan(
+ &schemaVersion, &revision, &status, &coverageID,
+ )
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, false, nil
}
- source = fmt.Sprintf(`(select p.event_id as id, p.%s from %s p where p.event_id <= %d
- union all select e.id, e.%s from usage_events e where e.id > %d)`,
- strings.Join(columns, ", p."), usageprojection.EventTable, coverageID,
- strings.Join(columns, ", e."), coverageID,
- )
+ return 0, false, err
}
- return source, nil
+ compatible := schemaVersion == 1 &&
+ revision == usageidentity.MonitoringProjectionStructureRevision() &&
+ status != "clearing"
+ return coverageID, compatible, nil
}
diff --git a/apps/manager-server/internal/repository/usagepricing/projection_read_test.go b/apps/manager-server/internal/repository/usagepricing/projection_read_test.go
new file mode 100644
index 000000000..e4d7078fa
--- /dev/null
+++ b/apps/manager-server/internal/repository/usagepricing/projection_read_test.go
@@ -0,0 +1,91 @@
+package usagepricing
+
+import (
+ "context"
+ "database/sql"
+ "strings"
+ "testing"
+
+ "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usageidentity"
+ _ "modernc.org/sqlite"
+)
+
+func TestRetainedAccountEventSourceUsesProjectionAccountIndex(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ columns := make([]string, 0, len(retainedPricingColumns)+2)
+ columns = append(columns, "event_id integer primary key", "account_key text not null")
+ for _, column := range retainedPricingColumns {
+ columns = append(columns, column+" text")
+ }
+ if _, err := db.Exec("create table usage_monitoring_event_projection_v1 (" + strings.Join(columns, ",") + ")"); err != nil {
+ t.Fatal(err)
+ }
+ rawColumns := make([]string, 0, len(retainedPricingColumns)+1)
+ rawColumns = append(rawColumns, "id integer primary key")
+ for _, column := range retainedPricingColumns {
+ rawColumns = append(rawColumns, column+" text")
+ }
+ if _, err := db.Exec("create table usage_events (" + strings.Join(rawColumns, ",") + ")"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`create index idx_usage_monitoring_event_projection_account_window
+ on usage_monitoring_event_projection_v1(account_key, timestamp_ms, event_id)`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`create table usage_monitoring_rollup_state (
+ rollup_name text primary key,
+ schema_version integer not null,
+ structure_revision text not null,
+ status text not null,
+ coverage_event_id integer not null
+ )`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`insert into usage_monitoring_rollup_state(
+ rollup_name, schema_version, structure_revision, status, coverage_event_id
+ ) values('projection_v1', 1, ?, 'ready', 100)`,
+ usageidentity.MonitoringProjectionStructureRevision()); err != nil {
+ t.Fatal(err)
+ }
+
+ ctx := context.Background()
+ tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tx.Rollback()
+ source, args, err := retainedAccountEventSourceTx(ctx, tx, []string{"account-a", "account-b"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(source, "p.account_key in (?,?)") {
+ t.Fatalf("retained account source is not account-scoped: %s", source)
+ }
+
+ rows, err := tx.QueryContext(ctx, "explain query plan select id from "+source+" retained", args...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ var details []string
+ for rows.Next() {
+ var id, parent, unused int
+ var detail string
+ if err := rows.Scan(&id, &parent, &unused, &detail); err != nil {
+ t.Fatal(err)
+ }
+ details = append(details, detail)
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+ plan := strings.Join(details, "\n")
+ if !strings.Contains(plan, "idx_usage_monitoring_event_projection_account_window") {
+ t.Fatalf("retained account projection plan does not use account index: %s", plan)
+ }
+}
diff --git a/apps/manager-server/internal/repository/usagepricing/repository.go b/apps/manager-server/internal/repository/usagepricing/repository.go
index df81a917e..8de5232d7 100644
--- a/apps/manager-server/internal/repository/usagepricing/repository.go
+++ b/apps/manager-server/internal/repository/usagepricing/repository.go
@@ -184,23 +184,21 @@ func (r *repository) CatchUp(ctx context.Context, limit int, nowMS int64) (Catch
if err != nil {
return CatchUpResult{}, err
}
+ rebuildMode := state.StructureRevision != revision ||
+ state.Status == "clearing" || state.Status == "rebuilding" || state.Status == "pending"
latestID, err := latestEventID(ctx, tx)
if err != nil {
return CatchUpResult{}, err
}
- rebuilt := (state.Status == "pending" || state.Status == "rebuilding" || state.Status == "clearing") &&
- state.CoverageEventID < state.TargetEventID
- if state.StructureRevision != revision || state.Status == "clearing" || state.Status == "rebuilding" || state.Status == "pending" {
- var hasDeletedRaw bool
- if err := tx.QueryRowContext(ctx, `select exists (
- select 1 from usage_archive_event_refs where raw_deleted_at_ms is not null
- )`).Scan(&hasDeletedRaw); err != nil {
- return CatchUpResult{}, err
- }
- if hasDeletedRaw {
- return CatchUpResult{}, errors.New("cannot rebuild pricing rollups from incomplete raw usage history")
+ eventSource := "usage_events"
+ if rebuildMode {
+ eventSource, latestID, err = retainedPricingRebuildSourceTx(ctx, tx)
+ if err != nil {
+ return CatchUpResult{}, fmt.Errorf("cannot rebuild pricing rollups from retained usage history: %w", err)
}
}
+ rebuilt := (state.Status == "pending" || state.Status == "rebuilding" || state.Status == "clearing") &&
+ state.CoverageEventID < state.TargetEventID
if state.StructureRevision != revision {
if err := resetForRevision(ctx, tx, revision, latestID, nowMS); err != nil {
return CatchUpResult{}, err
@@ -252,7 +250,7 @@ func (r *repository) CatchUp(ctx context.Context, limit int, nowMS int64) (Catch
return CatchUpResult{}, err
}
}
- ids, err := eventIDsThrough(ctx, tx, state.BackfillLastEventID, targetEventID, limit)
+ ids, err := eventIDsThrough(ctx, tx, eventSource, state.BackfillLastEventID, targetEventID, limit)
if err != nil {
return CatchUpResult{}, err
}
@@ -296,13 +294,13 @@ func (r *repository) CatchUp(ctx context.Context, limit int, nowMS int64) (Catch
}
lastEventID := ids[len(ids)-1]
- if err := upsertHourlyBatch(ctx, tx, revision, state.BackfillLastEventID, lastEventID, nowMS); err != nil {
+ if err := upsertHourlyBatch(ctx, tx, eventSource, revision, state.BackfillLastEventID, lastEventID, nowMS); err != nil {
return CatchUpResult{}, err
}
- if err := upsertAccountBatch(ctx, tx, revision, state.BackfillLastEventID, lastEventID, nowMS); err != nil {
+ if err := upsertAccountBatch(ctx, tx, eventSource, revision, state.BackfillLastEventID, lastEventID, nowMS); err != nil {
return CatchUpResult{}, err
}
- minBucket, maxBucket, err := batchBucketRange(ctx, tx, state.BackfillLastEventID, lastEventID)
+ minBucket, maxBucket, err := batchBucketRange(ctx, tx, eventSource, state.BackfillLastEventID, lastEventID)
if err != nil {
return CatchUpResult{}, err
}
@@ -509,11 +507,14 @@ func latestEventID(ctx context.Context, tx *sql.Tx) (int64, error) {
return id, nil
}
-func eventIDsThrough(ctx context.Context, tx *sql.Tx, lastEventID, targetEventID int64, limit int) ([]int64, error) {
+func eventIDsThrough(ctx context.Context, tx *sql.Tx, source string, lastEventID, targetEventID int64, limit int) ([]int64, error) {
if targetEventID <= lastEventID {
return []int64{}, nil
}
- rows, err := tx.QueryContext(ctx, `select id from usage_events where id > ? and id <= ? order by id limit ?`, lastEventID, targetEventID, limit)
+ rows, err := tx.QueryContext(ctx, fmt.Sprintf(
+ `select id from %s where id > ? and id <= ? order by id limit ?`,
+ source,
+ ), lastEventID, targetEventID, limit)
if err != nil {
return nil, err
}
@@ -529,12 +530,12 @@ func eventIDsThrough(ctx context.Context, tx *sql.Tx, lastEventID, targetEventID
return ids, rows.Err()
}
-func batchBucketRange(ctx context.Context, tx *sql.Tx, afterID, throughID int64) (sql.NullInt64, sql.NullInt64, error) {
+func batchBucketRange(ctx context.Context, tx *sql.Tx, source string, afterID, throughID int64) (sql.NullInt64, sql.NullInt64, error) {
var minBucket, maxBucket sql.NullInt64
err := tx.QueryRowContext(ctx, fmt.Sprintf(`select
min(timestamp_ms - (timestamp_ms %% %d)),
max(timestamp_ms - (timestamp_ms %% %d))
- from usage_events where id > ? and id <= ?`, hourMS, hourMS), afterID, throughID).Scan(&minBucket, &maxBucket)
+ from %s where id > ? and id <= ?`, hourMS, hourMS, source), afterID, throughID).Scan(&minBucket, &maxBucket)
return minBucket, maxBucket, err
}
@@ -588,8 +589,8 @@ func bandedEventsFromSourceCTE(whereClause, source string) string {
)`, requestedModelExpression, analyticsModelExpression, analyticsModelExpression, accountKeyExpression, source, whereClause, model.ModelPriceBaseContextThreshold)
}
-func upsertHourlyBatch(ctx context.Context, tx *sql.Tx, revision string, afterID, throughID, nowMS int64) error {
- query := bandedEventsCTE("e.id > ? and e.id <= ?") + fmt.Sprintf(`
+func upsertHourlyBatch(ctx context.Context, tx *sql.Tx, source, revision string, afterID, throughID, nowMS int64) error {
+ query := bandedEventsFromSourceCTE("e.id > ? and e.id <= ?", source) + fmt.Sprintf(`
insert into usage_pricing_hourly_rollups_v1 (
structure_revision, bucket_ms, model, billing_model, pricing_model,
service_tier, context_threshold_tokens, failed, calls,
@@ -659,8 +660,8 @@ func upsertHourlyBatch(ctx context.Context, tx *sql.Tx, revision string, afterID
return err
}
-func upsertAccountBatch(ctx context.Context, tx *sql.Tx, revision string, afterID, throughID, nowMS int64) error {
- query := bandedEventsCTE("e.id > ? and e.id <= ?") + fmt.Sprintf(`
+func upsertAccountBatch(ctx context.Context, tx *sql.Tx, source, revision string, afterID, throughID, nowMS int64) error {
+ query := bandedEventsFromSourceCTE("e.id > ? and e.id <= ?", source) + fmt.Sprintf(`
insert into usage_pricing_account_rollups_v1 (
structure_revision, account_key, account_snapshot, auth_label_snapshot,
auth_provider_snapshot, auth_index, source, source_hash, model,
@@ -1154,6 +1155,18 @@ func mergeAccountRowsFromSource(
accountKeys []string,
grouped map[accountKey]*AccountRow,
source string,
+) error {
+ return mergeAccountRowsFromSourceArgs(ctx, tx, afterID, accountKeys, grouped, source, nil)
+}
+
+func mergeAccountRowsFromSourceArgs(
+ ctx context.Context,
+ tx *sql.Tx,
+ afterID int64,
+ accountKeys []string,
+ grouped map[accountKey]*AccountRow,
+ source string,
+ sourceArgs []any,
) error {
placeholders := strings.TrimRight(strings.Repeat("?,", len(accountKeys)), ",")
query := bandedEventsFromSourceCTE("e.id > ?", source) + fmt.Sprintf(`
@@ -1194,7 +1207,8 @@ func mergeAccountRowsFromSource(
usage.LongContextInputTokenThreshold,
placeholders,
)
- args := make([]any, 0, len(accountKeys)+1)
+ args := make([]any, 0, len(sourceArgs)+len(accountKeys)+1)
+ args = append(args, sourceArgs...)
args = append(args, afterID)
for _, key := range accountKeys {
args = append(args, key)
diff --git a/apps/manager-server/internal/repository/usagepricing/repository_test.go b/apps/manager-server/internal/repository/usagepricing/repository_test.go
index cce4b620d..5d746ab75 100644
--- a/apps/manager-server/internal/repository/usagepricing/repository_test.go
+++ b/apps/manager-server/internal/repository/usagepricing/repository_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model"
+ sqliterepo "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite"
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store"
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/testutil"
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage"
@@ -78,6 +79,110 @@ func TestPricingRollupBandsStrictThresholdsAndMergesRawDelta(t *testing.T) {
}
}
+func TestPricingRollupRebuildsFromRetainedProjectionAfterRawDeletion(t *testing.T) {
+ ctx := context.Background()
+ cfg := testutil.NewConfig(t)
+ st := testutil.NewStore(t, cfg)
+ price := store.ModelPrice{
+ Prompt: 1,
+ ContextTiers: []store.ModelPriceContextTier{{
+ ThresholdTokens: 100,
+ Prompt: 2,
+ PromptConfigured: true,
+ }},
+ }
+ if err := st.SaveModelPrices(ctx, map[string]store.ModelPrice{"resolved-model": price}); err != nil {
+ t.Fatalf("save initial prices: %v", err)
+ }
+ events := []usage.Event{
+ pricingEvent("retained-live", 3_600_001, 150),
+ pricingEvent("retained-deleted-highest", 3_600_002, 250),
+ }
+ if _, err := st.UsageEvents.InsertBatch(ctx, events); err != nil {
+ t.Fatalf("insert retained rebuild events: %v", err)
+ }
+ projection, err := st.CatchUpUsageMonitoringProjection(ctx, 10, 9_000)
+ if err != nil {
+ t.Fatalf("build monitoring projection: %v", err)
+ }
+ if projection.CoverageEventID != 2 || projection.Pending {
+ t.Fatalf("projection result = %#v", projection)
+ }
+ if _, err := st.CatchUpUsagePricing(ctx, 10, 10_000); err != nil {
+ t.Fatalf("build initial pricing rollup: %v", err)
+ }
+
+ db, err := sqliterepo.Open(cfg.DBPath)
+ if err != nil {
+ t.Fatalf("open raw sqlite handle: %v", err)
+ }
+ defer db.Close()
+ var deletedID, deletedTimestamp int64
+ if err := db.QueryRowContext(ctx, `select id, timestamp_ms from usage_events where event_hash = ?`,
+ events[1].EventHash).Scan(&deletedID, &deletedTimestamp); err != nil {
+ t.Fatalf("resolve deleted event: %v", err)
+ }
+ if _, err := db.ExecContext(ctx, `pragma foreign_keys = off`); err != nil {
+ t.Fatalf("disable foreign keys: %v", err)
+ }
+ if _, err := db.ExecContext(ctx, `insert into usage_archive_event_refs(
+ event_hash, run_id, segment_sequence, raw_event_id, timestamp_ms, archived_at_ms, raw_deleted_at_ms
+ ) values(?, 'retained-pricing-run', 1, ?, ?, 11000, 12000)`,
+ events[1].EventHash, deletedID, deletedTimestamp); err != nil {
+ t.Fatalf("insert retained archive ref: %v", err)
+ }
+ if _, err := db.ExecContext(ctx, `delete from usage_events where id = ?`, deletedID); err != nil {
+ t.Fatalf("delete highest raw event: %v", err)
+ }
+
+ price.ContextTiers[0].ThresholdTokens = 200
+ if err := st.SaveModelPrices(ctx, map[string]store.ModelPrice{"resolved-model": price}); err != nil {
+ t.Fatalf("save structural price change with retained history: %v", err)
+ }
+ result, err := st.CatchUpUsagePricing(ctx, 10, 13_000)
+ if err != nil {
+ t.Fatalf("rebuild pricing from retained history: %v", err)
+ }
+ if !result.Rebuilt || result.Pending || result.TargetEventID != deletedID || result.CoverageEventID != deletedID {
+ t.Fatalf("retained rebuild result = %#v, deletedID=%d", result, deletedID)
+ }
+
+ rows, _, available, err := st.UsagePricingHourlyRows(ctx, store.UsagePricingHourlyFilter{
+ FromMS: 3_600_000,
+ ToMS: 7_200_000,
+ IncludeFailed: true,
+ })
+ if err != nil || !available {
+ t.Fatalf("load retained pricing rows: available=%v err=%v", available, err)
+ }
+ var calls int64
+ byThreshold := map[int64]int64{}
+ for _, row := range rows {
+ calls += row.Calls
+ byThreshold[row.ContextThresholdTokens] += row.Calls
+ }
+ if calls != 2 {
+ t.Fatalf("retained rebuild calls=%d rows=%#v", calls, rows)
+ }
+ if byThreshold[model.ModelPriceBaseContextThreshold] != 1 || byThreshold[200] != 1 {
+ t.Fatalf("retained rebuild threshold rows=%#v", byThreshold)
+ }
+
+ accountRows, _, accountAvailable, err := st.UsagePricingAccountRows(ctx, []string{
+ pricingAccountKey("team-a.json", "auth-team-a"),
+ })
+ if err != nil || !accountAvailable {
+ t.Fatalf("load retained account rows: available=%v err=%v", accountAvailable, err)
+ }
+ var accountCalls int64
+ for _, row := range accountRows {
+ accountCalls += row.Calls
+ }
+ if accountCalls != 2 {
+ t.Fatalf("retained account calls=%d rows=%#v", accountCalls, accountRows)
+ }
+}
+
func TestPricingRollupDoesNotClassifyIncrementalBacklogAsRebuild(t *testing.T) {
ctx := context.Background()
cfg := testutil.NewConfig(t)
diff --git a/apps/manager-server/internal/service/monitoring/pricing_coverage_test.go b/apps/manager-server/internal/service/monitoring/pricing_coverage_test.go
index 6874d1dd8..1b20f4fcd 100644
--- a/apps/manager-server/internal/service/monitoring/pricing_coverage_test.go
+++ b/apps/manager-server/internal/service/monitoring/pricing_coverage_test.go
@@ -133,35 +133,40 @@ func TestAnalyticsPricingRecoveryPreservesFiltersAndCollapsedBuckets(t *testing.
}
}
-func TestPricingCatchUpPreservesArchivedHistoryDuringRebuild(t *testing.T) {
+func TestPricingCatchUpRebuildsArchivedHistoryFromRetainedProjection(t *testing.T) {
for _, test := range []struct {
name string
sql string
}{
{"revision change", `update usage_pricing_rollup_state set structure_revision = 'obsolete'`},
{"resumed clearing", `update usage_pricing_rollup_state set status = 'clearing', coverage_event_id = 0, backfill_last_event_id = 0`},
- {"resumed rebuilding", `update usage_pricing_rollup_state set status = 'rebuilding', coverage_event_id = 0, backfill_last_event_id = 0`},
+ {"resumed rebuilding", `delete from usage_pricing_hourly_rollups_v1;
+ delete from usage_pricing_account_rollups_v1;
+ update usage_pricing_rollup_state set status = 'rebuilding', coverage_event_id = 0, backfill_last_event_id = 0`},
} {
t.Run(test.name, func(t *testing.T) {
db, sqlDB, _, _ := pricingCoverageFixture(t)
ctx := context.Background()
+ before := pricingCoverageCounts(t, sqlDB)
if _, err := sqlDB.ExecContext(ctx, test.sql); err != nil {
t.Fatal(err)
}
- before := pricingCoverageCounts(t, sqlDB)
- stateBefore, err := db.UsagePricingState(ctx)
+ result, err := db.CatchUpUsagePricing(ctx, 100, time.Now().UnixMilli())
if err != nil {
- t.Fatal(err)
+ t.Fatalf("rebuild from retained projection: %v", err)
}
- if _, err := db.CatchUpUsagePricing(ctx, 100, time.Now().UnixMilli()); err == nil {
- t.Fatal("rebuild from deleted raw history unexpectedly succeeded")
+ if !result.Rebuilt || result.Pending || result.CoverageEventID < result.TargetEventID {
+ t.Fatalf("unexpected retained rebuild result: %#v", result)
}
stateAfter, err := db.UsagePricingState(ctx)
if err != nil {
t.Fatal(err)
}
- if after := pricingCoverageCounts(t, sqlDB); after != before || !reflect.DeepEqual(stateAfter, stateBefore) {
- t.Fatalf("rebuild changed retained history: before=%v after=%v states=%#v / %#v", before, after, stateBefore, stateAfter)
+ if stateAfter.Status != "ready" || stateAfter.CoverageEventID < stateAfter.TargetEventID {
+ t.Fatalf("retained rebuild state = %#v", stateAfter)
+ }
+ if after := pricingCoverageCounts(t, sqlDB); after != before {
+ t.Fatalf("retained rebuild changed historical coverage: before=%v after=%v", before, after)
}
})
}
@@ -190,7 +195,10 @@ func TestAccountHistoryRecoversIncompleteArchivedPricing(t *testing.T) {
t.Fatalf("baseline history calls = %d, want 8", calls)
}
wantCost := analytics.Summary.TotalCost + analytics.SummaryComparison.TotalCost
- if delta := want.Items[0].TotalCost + want.Items[1].TotalCost - wantCost; delta < -0.000001 || delta > 0.000001 {
+ if want.Items[0].TotalCost == nil || want.Items[1].TotalCost == nil {
+ t.Fatalf("baseline account costs unavailable: %#v", want.Items)
+ }
+ if delta := *want.Items[0].TotalCost + *want.Items[1].TotalCost - wantCost; delta < -0.000001 || delta > 0.000001 {
t.Fatalf("baseline account costs differ from hourly costs: %v", delta)
}
if _, err := sqlDB.ExecContext(ctx, test.sql); err != nil {
@@ -208,16 +216,107 @@ func TestAccountHistoryRecoversIncompleteArchivedPricing(t *testing.T) {
}
}
-func TestAccountHistoryIncompleteArchivedPricingFailsClosed(t *testing.T) {
+func TestAccountHistoryDegradesWhenArchivedPricingCannotBeRecovered(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ catchUp bool
+ sql string
+ }{
+ {name: "read", sql: `delete from usage_pricing_account_rollups_v1;
+ delete from usage_monitoring_event_projection_v1 where event_id not in (select id from usage_events)`},
+ {name: "catch up", catchUp: true, sql: `delete from usage_pricing_account_rollups_v1;
+ delete from usage_monitoring_event_projection_v1 where event_id not in (select id from usage_events);
+ update usage_pricing_rollup_state set structure_revision = 'obsolete'`},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ db, sqlDB, _, _ := pricingCoverageFixture(t)
+ ctx := context.Background()
+ request := pricingCoverageAccountRequest()
+ want, err := New(db).AccountHistory(ctx, request)
+ if err != nil || len(want.Items) != 2 {
+ t.Fatalf("baseline account history = %#v, %v", want, err)
+ }
+ if _, err := sqlDB.ExecContext(ctx, test.sql); err != nil {
+ t.Fatal(err)
+ }
+ request.CatchUp = test.catchUp
+ got, err := New(db).AccountHistory(ctx, request)
+ if err != nil || len(got.Items) != len(want.Items) {
+ t.Fatalf("degraded account history = %#v, %v", got, err)
+ }
+ for index := range got.Items {
+ item, baseline := got.Items[index], want.Items[index]
+ if !item.Matched || item.TotalCost != nil {
+ t.Fatalf("degraded item availability = %#v", item)
+ }
+ if item.TotalRequests != baseline.TotalRequests || item.SuccessCalls != baseline.SuccessCalls ||
+ item.FailureCalls != baseline.FailureCalls || item.TotalTokens != baseline.TotalTokens ||
+ !reflect.DeepEqual(item.SuccessRate, baseline.SuccessRate) ||
+ !reflect.DeepEqual(item.FirstSeenMS, baseline.FirstSeenMS) ||
+ !reflect.DeepEqual(item.LastSeenMS, baseline.LastSeenMS) {
+ t.Fatalf("degraded core history differs: got=%#v want=%#v", item, baseline)
+ }
+ if !reflect.DeepEqual(item.LatestRequest, baseline.LatestRequest) ||
+ !reflect.DeepEqual(item.RecentRequests, baseline.RecentRequests) {
+ t.Fatalf("degraded recent requests differ: got=%#v want=%#v", item, baseline)
+ }
+ }
+ })
+ }
+}
+
+func TestAccountHistoryIsolatesUnrecoverablePricingByAccount(t *testing.T) {
+ db, sqlDB, _, _ := pricingCoverageFixture(t)
+ ctx := context.Background()
+ request := pricingCoverageAccountRequest()
+ want, err := New(db).AccountHistory(ctx, request)
+ if err != nil || len(want.Items) != 2 || want.Items[0].TotalCost == nil || want.Items[1].TotalCost == nil {
+ t.Fatalf("baseline account history = %#v, %v", want.Items, err)
+ }
+
+ if _, err := sqlDB.ExecContext(ctx, `delete from usage_pricing_account_rollups_v1 where auth_index = 'a';
+ delete from usage_monitoring_event_projection_v1
+ where auth_index = 'a' and event_id not in (select id from usage_events)`); err != nil {
+ t.Fatal(err)
+ }
+ got, err := New(db).AccountHistory(ctx, request)
+ if err != nil || len(got.Items) != len(want.Items) {
+ t.Fatalf("isolated account history = %#v, %v", got.Items, err)
+ }
+
+ wantA := want.Items[0]
+ wantA.TotalCost = nil
+ if !reflect.DeepEqual(got.Items[0], wantA) {
+ t.Fatalf("incomplete account was not isolated: got=%#v want=%#v", got.Items[0], wantA)
+ }
+ if !reflect.DeepEqual(got.Items[1], want.Items[1]) {
+ t.Fatalf("complete account pricing was degraded: got=%#v want=%#v", got.Items[1], want.Items[1])
+ }
+}
+
+func TestAccountHistoryDoesNotHidePricingRecoveryQueryErrors(t *testing.T) {
+ db, sqlDB, _, _ := pricingCoverageFixture(t)
+ ctx := context.Background()
+ if _, err := sqlDB.ExecContext(ctx, `delete from usage_pricing_account_rollups_v1 where auth_index = 'a';
+ alter table usage_monitoring_event_projection_v1
+ rename column normalized_total_input_tokens to unavailable_tokens`); err != nil {
+ t.Fatal(err)
+ }
+ _, err := New(db).AccountHistory(ctx, pricingCoverageAccountRequest())
+ if err == nil || errors.Is(err, store.ErrUsagePricingCoverageIncomplete) {
+ t.Fatalf("pricing recovery query error was hidden: %v", err)
+ }
+}
+
+func TestAccountHistoryDoesNotHideCoreHistoryReadErrors(t *testing.T) {
db, sqlDB, _, _ := pricingCoverageFixture(t)
ctx := context.Background()
- if _, err := sqlDB.ExecContext(ctx, `delete from usage_pricing_account_rollups_v1;
- delete from usage_monitoring_event_projection_v1 where event_id not in (select id from usage_events)`); err != nil {
+ if _, err := sqlDB.ExecContext(ctx, `alter table usage_account_model_rollups rename column total_tokens to unavailable_tokens`); err != nil {
t.Fatal(err)
}
- got, err := New(db).AccountHistory(ctx, pricingCoverageAccountRequest())
- if !errors.Is(err, store.ErrUsagePricingCoverageIncomplete) || len(got.Items) != 0 {
- t.Fatalf("incomplete account history returned success: %#v error=%v", got.Items, err)
+ _, err := New(db).AccountHistory(ctx, pricingCoverageAccountRequest())
+ if err == nil || errors.Is(err, store.ErrUsagePricingCoverageIncomplete) {
+ t.Fatalf("core history read error was hidden: %v", err)
}
}
diff --git a/apps/manager-server/internal/service/monitoring/service.go b/apps/manager-server/internal/service/monitoring/service.go
index 91b62ef1c..2433040ae 100644
--- a/apps/manager-server/internal/service/monitoring/service.go
+++ b/apps/manager-server/internal/service/monitoring/service.go
@@ -289,7 +289,7 @@ type AccountHistoryItem struct {
SuccessCalls int64 `json:"success_calls"`
FailureCalls int64 `json:"failure_calls"`
TotalTokens int64 `json:"total_tokens"`
- TotalCost float64 `json:"total_cost"`
+ TotalCost *float64 `json:"total_cost"`
SuccessRate *float64 `json:"success_rate"`
FirstSeenMS *int64 `json:"first_seen_ms"`
LastSeenMS *int64 `json:"last_seen_ms"`
@@ -1773,7 +1773,8 @@ func (s *Service) accountHistory(ctx context.Context, req AccountHistoryRequest)
return AccountHistoryResponse{}, err
}
processed = result.Processed
- if _, err := s.store.CatchUpUsagePricing(ctx, accountHistoryCatchUpLimit, generatedAtMS); err != nil {
+ if _, err := s.store.CatchUpUsagePricing(ctx, accountHistoryCatchUpLimit, generatedAtMS); err != nil &&
+ !errors.Is(err, store.ErrUsagePricingCoverageIncomplete) {
return AccountHistoryResponse{}, err
}
}
@@ -1838,15 +1839,20 @@ func (s *Service) accountHistory(ctx context.Context, req AccountHistoryRequest)
if err != nil {
return nil, err
}
- prices := pricingSnapshot.Prices
- if pricingSnapshot.Available {
- return buildPricingAccountHistoryTotals(pricingSnapshot.Rows, prices), nil
+ totals := buildPricingAccountHistoryTotals(pricingSnapshot.Rows, pricingSnapshot.Prices)
+ if len(pricingSnapshot.PricingIncompleteAccountKeys) == 0 {
+ return totals, nil
}
- rows, err := s.store.AccountHistoryRollupRows(ctx, readKeys)
- if err != nil {
- return nil, err
+ unpricedRows := make([]store.AccountHistoryRollupRow, 0, len(pricingSnapshot.CoreRows))
+ for _, row := range pricingSnapshot.CoreRows {
+ if _, incomplete := pricingSnapshot.PricingIncompleteAccountKeys[row.AccountKey]; incomplete {
+ unpricedRows = append(unpricedRows, row)
+ }
+ }
+ for key, total := range buildUnpricedAccountHistoryTotals(unpricedRows) {
+ totals[key] = total
}
- return buildAccountHistoryTotals(rows, prices), nil
+ return totals, nil
}
totals, err := loadTotals(keys)
if err != nil {
@@ -1935,7 +1941,7 @@ func (s *Service) accountHistory(ctx context.Context, req AccountHistoryRequest)
SuccessCalls: total.successCalls,
FailureCalls: total.failureCalls,
TotalTokens: total.totalTokens,
- TotalCost: total.cost,
+ TotalCost: accountHistoryCostPointer(total),
SuccessRate: successRate,
FirstSeenMS: nullableMSPointer(total.firstSeenMS),
LastSeenMS: nullableMSPointer(total.lastSeenMS),
@@ -4072,13 +4078,14 @@ func buildHeaderSnapshots(items []store.HeaderSnapshot) []HeaderSnapshot {
}
type accountHistoryTotal struct {
- requests int64
- successCalls int64
- failureCalls int64
- totalTokens int64
- cost float64
- firstSeenMS int64
- lastSeenMS int64
+ requests int64
+ successCalls int64
+ failureCalls int64
+ totalTokens int64
+ cost float64
+ costAvailable bool
+ firstSeenMS int64
+ lastSeenMS int64
}
func accountHistoryTargetKey(target AccountHistoryTarget) (string, bool) {
@@ -4181,7 +4188,7 @@ func accountLatestRequestFromStore(request store.LatestAccountRequest) *AccountL
}
}
-func buildAccountHistoryTotals(rows []store.AccountHistoryRollupRow, prices map[string]store.ModelPrice) map[string]*accountHistoryTotal {
+func buildUnpricedAccountHistoryTotals(rows []store.AccountHistoryRollupRow) map[string]*accountHistoryTotal {
totals := map[string]*accountHistoryTotal{}
for _, row := range rows {
total := totals[row.AccountKey]
@@ -4193,23 +4200,6 @@ func buildAccountHistoryTotals(rows []store.AccountHistoryRollupRow, prices map[
total.successCalls += row.SuccessCalls
total.failureCalls += row.FailureCalls
total.totalTokens += row.TotalTokens
- total.cost += pricing.CostForModelCandidatesWithServiceTier(
- []string{row.BillingModel, row.Model},
- row.ServiceTier,
- pricing.ModelTokens{
- InputTokens: row.InputTokens,
- OutputTokens: row.OutputTokens,
- CachedTokens: row.CachedTokens,
- CacheReadTokens: row.CacheReadTokens,
- CacheCreationTokens: row.CacheCreationTokens,
- LongInputTokens: row.LongInputTokens,
- LongOutputTokens: row.LongOutputTokens,
- LongCachedTokens: row.LongCachedTokens,
- LongCacheReadTokens: row.LongCacheReadTokens,
- LongCacheCreationTokens: row.LongCacheCreationTokens,
- },
- prices,
- )
if total.firstSeenMS == 0 || (row.FirstSeenMS > 0 && row.FirstSeenMS < total.firstSeenMS) {
total.firstSeenMS = row.FirstSeenMS
}
@@ -4225,7 +4215,7 @@ func buildPricingAccountHistoryTotals(rows []store.UsagePricingAccountRow, price
for _, row := range rows {
total := totals[row.AccountKey]
if total == nil {
- total = &accountHistoryTotal{}
+ total = &accountHistoryTotal{costAvailable: true}
totals[row.AccountKey] = total
}
total.requests += row.Calls
@@ -4278,6 +4268,7 @@ func mergeAliasedAccountHistoryTotals(totals map[string]*accountHistoryTotal, al
primary.failureCalls += legacy.failureCalls
primary.totalTokens += legacy.totalTokens
primary.cost += legacy.cost
+ primary.costAvailable = primary.costAvailable && legacy.costAvailable
if primary.firstSeenMS == 0 || (legacy.firstSeenMS > 0 && legacy.firstSeenMS < primary.firstSeenMS) {
primary.firstSeenMS = legacy.firstSeenMS
}
@@ -4288,6 +4279,14 @@ func mergeAliasedAccountHistoryTotals(totals map[string]*accountHistoryTotal, al
}
}
+func accountHistoryCostPointer(total *accountHistoryTotal) *float64 {
+ if total == nil || !total.costAvailable {
+ return nil
+ }
+ value := total.cost
+ return &value
+}
+
func accountWindowUsageTargetKey(target AccountWindowUsageTarget) (string, bool) {
if !AccountWindowUsageTargetHasCredentialIdentity(target) {
return "", false
diff --git a/apps/manager-server/internal/service/monitoring/service_test.go b/apps/manager-server/internal/service/monitoring/service_test.go
index 7ecea05cd..9c842cf9c 100644
--- a/apps/manager-server/internal/service/monitoring/service_test.go
+++ b/apps/manager-server/internal/service/monitoring/service_test.go
@@ -2249,7 +2249,7 @@ func TestAccountHistoryReturnsRollupTotalsAndCost(t *testing.T) {
if history.SuccessRate == nil || math.Abs(*history.SuccessRate-0.5) > 0.000001 {
t.Fatalf("success rate = %#v", history.SuccessRate)
}
- if math.Abs(history.TotalCost-2.055) > 0.000001 {
+ if history.TotalCost == nil || math.Abs(*history.TotalCost-2.055) > 0.000001 {
t.Fatalf("total cost = %v", history.TotalCost)
}
if history.FirstSeenMS == nil || *history.FirstSeenMS != baseMS+1_000 || history.LastSeenMS == nil || *history.LastSeenMS != baseMS+2_000 {
@@ -2477,7 +2477,7 @@ func TestAccountHistoryPricesContextTierBands(t *testing.T) {
t.Fatalf("history item = %#v", resp.Items)
}
const wantCost = 4.60002
- if math.Abs(resp.Items[0].TotalCost-wantCost) > 0.000001 {
+ if resp.Items[0].TotalCost == nil || math.Abs(*resp.Items[0].TotalCost-wantCost) > 0.000001 {
t.Fatalf("history cost = %v, want %v", resp.Items[0].TotalCost, wantCost)
}
}
diff --git a/apps/manager-server/internal/store/store.go b/apps/manager-server/internal/store/store.go
index a5e1a32a9..3c9e4ff17 100644
--- a/apps/manager-server/internal/store/store.go
+++ b/apps/manager-server/internal/store/store.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"database/sql"
+ "errors"
"fmt"
"io"
"sync"
@@ -132,10 +133,12 @@ type UsageHourlyPricingSnapshot struct {
}
type UsagePricingAccountSnapshot struct {
- Rows []UsagePricingAccountRow
- State UsagePricingState
- Available bool
- Prices map[string]ModelPrice
+ CoreRows []AccountHistoryRollupRow
+ Rows []UsagePricingAccountRow
+ State UsagePricingState
+ Available bool
+ PricingIncompleteAccountKeys map[string]struct{}
+ Prices map[string]ModelPrice
}
type Store struct {
@@ -547,7 +550,11 @@ func (s *Store) CatchUpUsagePricing(ctx context.Context, limit int, nowMS int64)
if !ready {
return UsagePricingCatchUpResult{Pending: true}, nil
}
- return s.UsagePricing.CatchUp(ctx, limit, nowMS)
+ result, err := s.UsagePricing.CatchUp(ctx, limit, nowMS)
+ if err != nil && errors.Is(err, usagepricing.ErrRetainedPricingHistoryIncomplete) {
+ return result, fmt.Errorf("%w: %w", ErrUsagePricingCoverageIncomplete, err)
+ }
+ return result, err
}
func (s *Store) RecordUsagePricingFailure(ctx context.Context, rollupErr error, nowMS int64) error {
@@ -715,17 +722,49 @@ func (s *Store) LoadUsagePricingAccountSnapshot(ctx context.Context, accountKeys
if err != nil {
return UsagePricingAccountSnapshot{}, err
}
- rows, state, available, pricingErr := s.UsagePricing.LoadAccountRowsTx(ctx, tx, accountKeys)
- if pricingErr != nil || !available || !accountPricingCoverageMatches(coreRows, rows) {
- rows, err = s.UsagePricing.LoadAccountRowsFromEventsTx(ctx, tx, accountKeys)
- if err != nil {
- return UsagePricingAccountSnapshot{}, fmt.Errorf("%w: retained account event query: %w", ErrUsagePricingCoverageIncomplete, err)
+ rows, state, available, err := s.UsagePricing.LoadAccountRowsTx(ctx, tx, accountKeys)
+ if err != nil {
+ return UsagePricingAccountSnapshot{}, err
+ }
+ if !available {
+ rows = nil
+ }
+
+ incomplete := accountPricingCoverageIncompleteKeys(coreRows, rows)
+ if len(incomplete) > 0 {
+ recoveryKeys := make([]string, 0, len(incomplete))
+ for key := range incomplete {
+ recoveryKeys = append(recoveryKeys, key)
+ }
+ recoveredRows, recoveryErr := s.UsagePricing.LoadAccountRowsFromEventsTx(ctx, tx, recoveryKeys)
+ if recoveryErr != nil {
+ return UsagePricingAccountSnapshot{}, recoveryErr
}
- if !accountPricingCoverageMatches(coreRows, rows) {
- return UsagePricingAccountSnapshot{}, ErrUsagePricingCoverageIncomplete
+
+ recoveryCoreRows := make([]AccountHistoryRollupRow, 0, len(coreRows))
+ for _, row := range coreRows {
+ if _, needsRecovery := incomplete[row.AccountKey]; needsRecovery {
+ recoveryCoreRows = append(recoveryCoreRows, row)
+ }
+ }
+ stillIncomplete := accountPricingCoverageIncompleteKeys(recoveryCoreRows, recoveredRows)
+
+ completeRows := make([]UsagePricingAccountRow, 0, len(rows)+len(recoveredRows))
+ for _, row := range rows {
+ if _, needsRecovery := incomplete[row.AccountKey]; !needsRecovery {
+ completeRows = append(completeRows, row)
+ }
+ }
+ for _, row := range recoveredRows {
+ if _, unresolved := stillIncomplete[row.AccountKey]; !unresolved {
+ completeRows = append(completeRows, row)
+ }
}
- available = true
+ rows = completeRows
+ incomplete = stillIncomplete
}
+ available = len(incomplete) == 0
+
prices, err := s.ModelPrices.LoadAllTx(ctx, tx)
if err != nil {
return UsagePricingAccountSnapshot{}, err
@@ -734,10 +773,12 @@ func (s *Store) LoadUsagePricingAccountSnapshot(ctx context.Context, accountKeys
return UsagePricingAccountSnapshot{}, err
}
return UsagePricingAccountSnapshot{
- Rows: rows,
- State: state,
- Available: available,
- Prices: prices,
+ CoreRows: coreRows,
+ Rows: rows,
+ State: state,
+ Available: available,
+ PricingIncompleteAccountKeys: incomplete,
+ Prices: prices,
}, nil
}
diff --git a/apps/manager-server/internal/store/usage_pricing_coverage.go b/apps/manager-server/internal/store/usage_pricing_coverage.go
index 94b3cf021..209cb744d 100644
--- a/apps/manager-server/internal/store/usage_pricing_coverage.go
+++ b/apps/manager-server/internal/store/usage_pricing_coverage.go
@@ -85,7 +85,7 @@ func addAccountPricingCoverage(grouped map[accountPricingCoverageKey]accountPric
grouped[key] = entry
}
-func accountPricingCoverageMatches(core []AccountHistoryRollupRow, prices []UsagePricingAccountRow) bool {
+func accountPricingCoverageIncompleteKeys(core []AccountHistoryRollupRow, prices []UsagePricingAccountRow) map[string]struct{} {
coreTotals := make(map[accountPricingCoverageKey]accountPricingCoverage, len(core))
priceTotals := make(map[accountPricingCoverageKey]accountPricingCoverage, len(prices))
for _, row := range core {
@@ -110,13 +110,20 @@ func accountPricingCoverageMatches(core []AccountHistoryRollupRow, prices []Usag
row.LongCacheReadTokens, row.LongCacheCreationTokens, row.TotalTokens,
}, row.FirstSeenMS, row.LastSeenMS)
}
- if len(coreTotals) != len(priceTotals) {
- return false
- }
+ incomplete := make(map[string]struct{})
for key, expected := range coreTotals {
if actual, exists := priceTotals[key]; !exists || actual != expected {
- return false
+ incomplete[key.accountKey] = struct{}{}
}
}
- return true
+ for key := range priceTotals {
+ if _, exists := coreTotals[key]; !exists {
+ incomplete[key.accountKey] = struct{}{}
+ }
+ }
+ return incomplete
+}
+
+func accountPricingCoverageMatches(core []AccountHistoryRollupRow, prices []UsagePricingAccountRow) bool {
+ return len(accountPricingCoverageIncompleteKeys(core, prices)) == 0
}
diff --git a/apps/web/src/features/accounts/AccountsPage.tsx b/apps/web/src/features/accounts/AccountsPage.tsx
index 7c3d6678f..962c153ba 100644
--- a/apps/web/src/features/accounts/AccountsPage.tsx
+++ b/apps/web/src/features/accounts/AccountsPage.tsx
@@ -8820,8 +8820,12 @@ export function AccountsPage() {
key: 'cost',
icon: ,
className: styles.accountHistoryMetricCost,
- value: matched ? formatCompactUsd(accountHistory.total_cost) : '-',
- exact: matched ? formatUsd(accountHistory.total_cost) : '-',
+ value:
+ matched && accountHistory.total_cost !== null
+ ? formatCompactUsd(accountHistory.total_cost)
+ : '-',
+ exact:
+ matched && accountHistory.total_cost !== null ? formatUsd(accountHistory.total_cost) : '-',
},
{
key: 'success',
diff --git a/apps/web/src/features/accounts/components/accountDetail/AccountQuotaTab.tsx b/apps/web/src/features/accounts/components/accountDetail/AccountQuotaTab.tsx
index 6ff2f3bab..0698eaaa3 100644
--- a/apps/web/src/features/accounts/components/accountDetail/AccountQuotaTab.tsx
+++ b/apps/web/src/features/accounts/components/accountDetail/AccountQuotaTab.tsx
@@ -229,7 +229,7 @@ export function AccountQuotaTab({
icon={}
tone="amber"
label={t('accounts.detail_total_cost')}
- value={history ? formatUsd(history.totalCost) : '-'}
+ value={history?.totalCost != null ? formatUsd(history.totalCost) : '-'}
/>
}
diff --git a/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts b/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts
index fcc757d55..15922fa45 100644
--- a/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts
+++ b/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts
@@ -244,6 +244,19 @@ const makeMonitoringValue = (
});
describe('accountDetailViewModel', () => {
+ it('preserves unavailable historical cost without dropping core history', () => {
+ const viewModel = buildAccountDetailViewModel(makeRow(), {
+ history: makeHistory({ total_cost: null }),
+ });
+
+ expect(viewModel.history).toMatchObject({
+ matched: true,
+ totalRequests: 12,
+ totalTokens: 2400,
+ totalCost: null,
+ });
+ });
+
it('uses the full unified plan label for credential details', () => {
const viewModel = buildAccountDetailViewModel(
makeRow({
diff --git a/apps/web/src/features/accounts/model/accountDetailViewModel.ts b/apps/web/src/features/accounts/model/accountDetailViewModel.ts
index 7e4035ce4..09459512c 100644
--- a/apps/web/src/features/accounts/model/accountDetailViewModel.ts
+++ b/apps/web/src/features/accounts/model/accountDetailViewModel.ts
@@ -208,7 +208,7 @@ export interface AccountDetailHistorySummary {
successCalls: number;
failureCalls: number;
totalTokens: number;
- totalCost: number;
+ totalCost: number | null;
successRate: number | null;
firstSeenMs: number | null;
lastSeenMs: number | null;
diff --git a/apps/web/src/features/accounts/model/accountsPagePresentation.test.ts b/apps/web/src/features/accounts/model/accountsPagePresentation.test.ts
index 34194b677..ead014aca 100644
--- a/apps/web/src/features/accounts/model/accountsPagePresentation.test.ts
+++ b/apps/web/src/features/accounts/model/accountsPagePresentation.test.ts
@@ -57,6 +57,7 @@ describe('accountsPagePresentation', () => {
expect(parsePriorityValue('1.2')).toBeNull();
expect(formatHistorySuccessRate(0.975)).toBe('97.5%');
expect(formatMoney(12.34)).toBe('$12.34');
+ expect(formatMoney(null)).toBe('-');
expect(quotaStatusLabelKey('exhausted')).toBe('accounts.quota_status_exhausted');
});
diff --git a/apps/web/src/features/accounts/model/accountsPagePresentation.ts b/apps/web/src/features/accounts/model/accountsPagePresentation.ts
index 37f605521..0505a9753 100644
--- a/apps/web/src/features/accounts/model/accountsPagePresentation.ts
+++ b/apps/web/src/features/accounts/model/accountsPagePresentation.ts
@@ -111,7 +111,8 @@ export const getProviderLabel = (provider: string, t: TFunction) => {
export const formatPercent = (value: number | null | undefined, digits = 0) =>
typeof value === 'number' && Number.isFinite(value) ? `${value.toFixed(digits)}%` : '-';
-export const formatMoney = (value: number) => formatUsd(value);
+export const formatMoney = (value: number | null | undefined) =>
+ typeof value === 'number' && Number.isFinite(value) ? formatUsd(value) : '-';
export const formatHistoryNumber = (value: number, locale: string) => {
const numberValue = Number(value);
diff --git a/apps/web/src/features/config/ConfigPage.persistence.test.tsx b/apps/web/src/features/config/ConfigPage.persistence.test.tsx
index 54963c756..92e6f2e19 100644
--- a/apps/web/src/features/config/ConfigPage.persistence.test.tsx
+++ b/apps/web/src/features/config/ConfigPage.persistence.test.tsx
@@ -3,6 +3,8 @@ import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ApiKeyMutation } from '@/components/config/ApiKeysCardEditor';
import type { ManagerConfigResponse } from '@/services/api/usageService';
+import type { VisualConfigValues } from '@/types/visualConfig';
+import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
vi.mock('react-dom', () => ({
createPortal: (children: ReactNode) => children,
@@ -32,6 +34,7 @@ const mocks = vi.hoisted(() => ({
capturedApiKeyOperationStart: null as (() => void) | null,
capturedApiKeyOperationEnd: null as (() => void) | null,
translate: (key: string) => key,
+ useRealVisualConfig: false,
visualState: {
apiKeysText: 'sk-old',
dirty: false,
@@ -54,10 +57,14 @@ vi.mock('@/hooks/useMediaQuery', () => ({
vi.mock('@/components/config/VisualConfigEditor', () => ({
VisualConfigEditor: ({
+ values,
+ onChange,
onPersistApiKeyMutation,
onApiKeyOperationStart,
onApiKeyOperationEnd,
}: {
+ values: VisualConfigValues;
+ onChange: (values: Partial) => void;
onPersistApiKeyMutation: (mutation: ApiKeyMutation) => Promise;
onApiKeyOperationStart: () => void;
onApiKeyOperationEnd: () => void;
@@ -78,6 +85,8 @@ vi.mock('@/components/config/VisualConfigEditor', () => ({
return (
+
+