Skip to content

Commit f308e18

Browse files
committed
fix: detach canceled pricing loads
A pricing load with no remaining waiters canceled its DB context but stayed installed on the Store until the load goroutine returned. A later request in that window could join the already-canceled load and fail with context.Canceled even though its own context was valid. Detach the load from Store before canceling it when the final waiter leaves, and cover that race with a probe-driver regression test. Also remove the now-dead bounded full-row usage helpers left behind by the usage query reshaping so CI lint no longer fails on unused code.
1 parent f1f7dfa commit f308e18

4 files changed

Lines changed: 67 additions & 300 deletions

File tree

internal/db/usage.go

Lines changed: 1 addition & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -39,69 +39,6 @@ type UsageFilter struct {
3939
Breakdowns bool // populate Project/AgentBreakdowns per day
4040
}
4141

42-
func (f UsageFilter) appendUsageRowFilterClauses(
43-
query string, args []any,
44-
) (string, []any) {
45-
appendCSV := func(
46-
q string, a []any, col, csv string, include bool,
47-
) (string, []any) {
48-
if csv == "" {
49-
return q, a
50-
}
51-
vals := strings.Split(csv, ",")
52-
op := "IN"
53-
if !include {
54-
op = "NOT IN"
55-
}
56-
if len(vals) == 1 {
57-
if include {
58-
q += " AND " + col + " = ?"
59-
} else {
60-
q += " AND " + col + " != ?"
61-
}
62-
a = append(a, vals[0])
63-
} else {
64-
ph := make([]string, len(vals))
65-
for i, v := range vals {
66-
ph[i] = "?"
67-
a = append(a, v)
68-
}
69-
q += " AND " + col + " " + op +
70-
" (" + strings.Join(ph, ",") + ")"
71-
}
72-
return q, a
73-
}
74-
75-
query, args = appendCSV(query, args, "u.agent", f.Agent, true)
76-
query, args = appendCSV(query, args, "u.project", f.Project, true)
77-
query, args = appendCSV(query, args, "u.machine", f.Machine, true)
78-
query, args = appendCSV(query, args, "u.model", f.Model, true)
79-
80-
query, args = appendCSV(
81-
query, args, "u.project", f.ExcludeProject, false)
82-
query, args = appendCSV(
83-
query, args, "u.agent", f.ExcludeAgent, false)
84-
query, args = appendCSV(
85-
query, args, "u.model", f.ExcludeModel, false)
86-
87-
if f.MinUserMessages > 0 {
88-
query += " AND u.user_message_count >= ?"
89-
args = append(args, f.MinUserMessages)
90-
}
91-
if f.ExcludeOneShot {
92-
query += " AND u.user_message_count > 1"
93-
}
94-
if f.ExcludeAutomated {
95-
query += " AND COALESCE(u.is_automated, 0) = 0"
96-
}
97-
if f.ActiveSince != "" {
98-
query += " AND u.session_activity_at >= ?"
99-
args = append(args, f.ActiveSince)
100-
}
101-
102-
return query, args
103-
}
104-
10542
func (f UsageFilter) appendUsageBranchFilterClauses(
10643
where string, args []any, modelCol string,
10744
) (string, []any) {
@@ -649,60 +586,6 @@ func appendUsageColumnBounds(
649586
return where, args
650587
}
651588

652-
func usageRowsSQLForBounds(b usageBounds) (string, []any) {
653-
if !b.bounded() {
654-
return usageRowsSQLWithWhere(
655-
usageMessageEligibility,
656-
usageEventEligibility,
657-
), nil
658-
}
659-
660-
messageTimestampWhere := usageMessageEligibility +
661-
"\n\tAND m.timestamp IS NOT NULL"
662-
var messageTimestampArgs []any
663-
messageTimestampWhere, messageTimestampArgs = appendUsageColumnBounds(
664-
messageTimestampWhere, "m.timestamp", b, messageTimestampArgs)
665-
666-
eventTimestampWhere := usageEventEligibility +
667-
"\n\tAND ue.occurred_at IS NOT NULL"
668-
var eventTimestampArgs []any
669-
eventTimestampWhere, eventTimestampArgs = appendUsageColumnBounds(
670-
eventTimestampWhere, "ue.occurred_at", b, eventTimestampArgs)
671-
672-
messageFallbackWhere := usageMessageEligibility +
673-
"\n\tAND m.timestamp IS NULL"
674-
var messageFallbackArgs []any
675-
messageFallbackWhere, messageFallbackArgs = appendUsageColumnBounds(
676-
messageFallbackWhere, "s.started_at", b, messageFallbackArgs)
677-
678-
eventFallbackWhere := usageEventEligibility +
679-
"\n\tAND ue.occurred_at IS NULL"
680-
var eventFallbackArgs []any
681-
eventFallbackWhere, eventFallbackArgs = appendUsageColumnBounds(
682-
eventFallbackWhere, "s.started_at", b, eventFallbackArgs)
683-
684-
rowsSQL := strings.Join([]string{
685-
usageRowsSQLWithWhere(
686-
messageTimestampWhere,
687-
eventTimestampWhere,
688-
),
689-
usageRowsSQLWithWhere(
690-
messageFallbackWhere,
691-
eventFallbackWhere,
692-
),
693-
}, "\n\nUNION ALL\n\n")
694-
args := make(
695-
[]any, 0,
696-
len(messageTimestampArgs)+len(eventTimestampArgs)+
697-
len(messageFallbackArgs)+len(eventFallbackArgs),
698-
)
699-
args = append(args, messageTimestampArgs...)
700-
args = append(args, eventTimestampArgs...)
701-
args = append(args, messageFallbackArgs...)
702-
args = append(args, eventFallbackArgs...)
703-
return rowsSQL, args
704-
}
705-
706589
func dailyUsageRowsSQLForBounds(
707590
f UsageFilter, b usageBounds,
708591
) (string, []any) {
@@ -797,13 +680,6 @@ func topSessionsUsageRowQuery(f UsageFilter) (string, []any) {
797680
return usageRowQuery(f)
798681
}
799682

800-
func usageFullRowQuery(f UsageFilter) (string, []any) {
801-
rowsSQL, args := usageRowsSQLForBounds(usageBoundsForFilter(f))
802-
query := usageRowSelectFromRows(rowsSQL)
803-
query, args = f.appendUsageRowFilterClauses(query, args)
804-
return query, args
805-
}
806-
807683
func scanUsageRow(rows *sql.Rows) (usageScanRow, error) {
808684
var r usageScanRow
809685
err := rows.Scan(
@@ -859,42 +735,6 @@ func scanDailyUsageRow(rows *sql.Rows) (dailyUsageScanRow, error) {
859735
return r, err
860736
}
861737

862-
func usageAmounts(
863-
r usageScanRow, pricing map[string]modelRates,
864-
) (inputTok, outputTok, cacheCrTok, cacheRdTok int, cost, savings float64) {
865-
if r.usageSource == "message" {
866-
usage := gjson.Parse(r.tokenJSON)
867-
inputTok = int(usage.Get("input_tokens").Int())
868-
outputTok = int(usage.Get("output_tokens").Int())
869-
cacheCrTok = int(
870-
usage.Get("cache_creation_input_tokens").Int())
871-
cacheRdTok = int(
872-
usage.Get("cache_read_input_tokens").Int())
873-
} else {
874-
inputTok = r.inputTokens
875-
outputTok = r.outputTokens
876-
cacheCrTok = r.cacheCreationInputTokens
877-
cacheRdTok = r.cacheReadInputTokens
878-
}
879-
880-
rates, _ := lookupModelRates(pricing, r.model)
881-
if r.costUSD.Valid {
882-
cost = r.costUSD.Float64
883-
} else {
884-
cost = (float64(inputTok)*rates.input +
885-
float64(outputTok)*rates.output +
886-
float64(cacheCrTok)*rates.cacheCreation +
887-
float64(cacheRdTok)*rates.cacheRead) / 1_000_000
888-
}
889-
890-
readDelta := float64(cacheRdTok) *
891-
(rates.input - rates.cacheRead) / 1_000_000
892-
crDelta := float64(cacheCrTok) *
893-
(rates.input - rates.cacheCreation) / 1_000_000
894-
savings = readDelta + crDelta
895-
return
896-
}
897-
898738
func dailyUsageAmounts(
899739
r dailyUsageScanRow, pricing map[string]modelRates,
900740
) (inputTok, outputTok, cacheCrTok, cacheRdTok int, cost, savings float64) {
@@ -1714,8 +1554,7 @@ type SessionUsage struct {
17141554

17151555
// sessionRowCost computes one usage row's cost and reports whether
17161556
// it was priced and whether it contributes to the estimate. A row
1717-
// contributes when it carries an explicit cost or any tokens.
1718-
// Unlike usageAmounts (which zero-fills missing pricing), this does
1557+
// contributes when it carries an explicit cost or any tokens. It does
17191558
// an explicit map lookup so callers can distinguish "unpriced" from
17201559
// "$0".
17211560
func sessionRowCost(

internal/postgres/pricing.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,16 @@ func (s *Store) runPricingLoad(ctx context.Context, load *pricingLoad) {
137137
}
138138

139139
func (s *Store) leavePricingLoad(load *pricingLoad) {
140+
var cancel context.CancelFunc
140141
s.pricingLoadMu.Lock()
141-
defer s.pricingLoadMu.Unlock()
142142
load.waiters--
143143
if load.waiters == 0 && s.pricingLoad == load {
144-
load.cancel()
144+
s.pricingLoad = nil
145+
cancel = load.cancel
146+
}
147+
s.pricingLoadMu.Unlock()
148+
if cancel != nil {
149+
cancel()
145150
}
146151
}
147152

internal/postgres/pricing_unit_test.go

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ type pricingProbeRows struct {
3030
}
3131

3232
type pricingProbeState struct {
33-
mu sync.Mutex
34-
doneOnce sync.Once
35-
queries int
36-
err error
37-
rows [][]driver.Value
38-
block <-chan struct{}
39-
done chan struct{}
33+
mu sync.Mutex
34+
doneOnce sync.Once
35+
queries int
36+
err error
37+
rows [][]driver.Value
38+
block <-chan struct{}
39+
afterCancelBlock <-chan struct{}
40+
done chan struct{}
4041
}
4142

4243
var (
@@ -98,11 +99,15 @@ func (c *pricingProbeConn) QueryContext(
9899
err := c.state.err
99100
values := append([][]driver.Value(nil), c.state.rows...)
100101
block := c.state.block
102+
afterCancelBlock := c.state.afterCancelBlock
101103
c.state.mu.Unlock()
102104
if block != nil {
103105
select {
104106
case <-block:
105107
case <-ctx.Done():
108+
if afterCancelBlock != nil {
109+
<-afterCancelBlock
110+
}
106111
return nil, ctx.Err()
107112
}
108113
}
@@ -147,6 +152,13 @@ func (s *pricingProbeState) setRows(rows [][]driver.Value) {
147152
s.rows = rows
148153
}
149154

155+
func (s *pricingProbeState) unblockNextQuery() {
156+
s.mu.Lock()
157+
defer s.mu.Unlock()
158+
s.block = nil
159+
s.afterCancelBlock = nil
160+
}
161+
150162
func TestCustomPricingOverridesPricingMap(t *testing.T) {
151163
tests := []struct {
152164
name string
@@ -323,6 +335,46 @@ func TestLoadPricingMapCancelsDBRowsWithCaller(t *testing.T) {
323335
require.ErrorIs(t, <-result, context.Canceled)
324336
}
325337

338+
func TestLoadPricingMapStartsFreshLoadAfterAllWaitersCancel(t *testing.T) {
339+
block := make(chan struct{})
340+
releaseCanceledQuery := make(chan struct{})
341+
defer close(releaseCanceledQuery)
342+
state := &pricingProbeState{
343+
rows: [][]driver.Value{{
344+
"db-model", 1.0, 2.0, 3.0, 4.0, "2026-06-08",
345+
}},
346+
block: block,
347+
afterCancelBlock: releaseCanceledQuery,
348+
}
349+
pg := newPricingProbeDB(t, state)
350+
store := &Store{pg: pg}
351+
352+
ctx, cancel := context.WithCancel(context.Background())
353+
firstResult := make(chan error, 1)
354+
go func() {
355+
_, err := store.loadPricingMap(ctx)
356+
firstResult <- err
357+
}()
358+
require.Eventually(t, func() bool {
359+
return state.queryCount() == 1
360+
}, time.Second, 10*time.Millisecond)
361+
362+
cancel()
363+
require.ErrorIs(t, <-firstResult, context.Canceled)
364+
state.unblockNextQuery()
365+
366+
secondResult := make(chan error, 1)
367+
go func() {
368+
_, err := store.loadPricingMap(context.Background())
369+
secondResult <- err
370+
}()
371+
372+
require.Eventually(t, func() bool {
373+
return state.queryCount() == 2
374+
}, time.Second, 10*time.Millisecond)
375+
require.NoError(t, <-secondResult, "second loadPricingMap")
376+
}
377+
326378
func TestSetCustomPricingForgetsInFlightPricingLoad(t *testing.T) {
327379
block := make(chan struct{})
328380
defer close(block)

0 commit comments

Comments
 (0)