Skip to content

Commit 8b81d09

Browse files
icecrasher321claude
andcommitted
feat(db): backfill residual cost_total projections before the cost drop
The 0220 procedure projected every then-existing legacy cost json into cost_total, but a transition-window writer added 23 rows (all 2026-05-30, verified on the prod replica) carrying a numeric json total with no projection. Script migration 0009 re-runs 0220's exact candidate filter and projection in bounded batches at deploy time, so the pending cost DROP abandons nothing cost_total should hold. The contract PR that drops the column must deregister the script in the same change — it reads the column; the contract-pending marker says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2bb6a6d commit 8b81d09

5 files changed

Lines changed: 156 additions & 4 deletions

File tree

packages/db/schema.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -466,10 +466,10 @@ export const workflowExecutionLogs = pgTable(
466466
executionData: jsonb('execution_data').notNull().default('{}'),
467467
// contract-pending(after #7134 is fully deployed to production): DROP COLUMN
468468
// cost. Same procedure and argless-read lint as the user_stats marker
469-
// (scripts/check-pending-drop-tables.ts). Backfill precondition verified on
470-
// the prod replica 2026-08-26: 94 of 4.77M rows carry a cost json with no
471-
// cost_total (71 zero/absent totals Jul–Aug 2025, 22 small totals May 2026)
472-
// — unread history the drop abandons.
469+
// (scripts/check-pending-drop-tables.ts). Script migration
470+
// 0009_backfill_wel_residual_cost_total projects the ~23 straggler rows
471+
// whose json still held a numeric total into cost_total before the drop;
472+
// the contract PR must ALSO deregister that script (it reads this column).
473473
/** @deprecated Not written/read; cost lives in usage_log + the `cost_total` projection. */
474474
cost: jsonb('cost'),
475475
// Faithful, write-once projection of the run's usage_log ledger sum (dollars).

packages/db/script-migrations-paused-billing-attribution.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ describe('script migration registry', () => {
445445
'0006_repair_unknown_table_row_provenance_second_pass',
446446
'0007_repair_unknown_workspace_file_provenance',
447447
'0008_backfill_workspace_file_size_bytes',
448+
'0009_backfill_wel_residual_cost_total',
448449
])
449450
})
450451
})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import {
3+
backfillWelResidualCostTotal,
4+
WEL_RESIDUAL_COST_TOTAL_BATCH_SIZE,
5+
type WelResidualCostTotalBackfillStore,
6+
} from './0009_backfill_wel_residual_cost_total'
7+
8+
describe('backfillWelResidualCostTotal', () => {
9+
it('projects batches until the candidate set is empty and counts rows changed', async () => {
10+
const projectBatch = vi
11+
.fn<WelResidualCostTotalBackfillStore['projectBatch']>()
12+
.mockResolvedValueOnce(500)
13+
.mockResolvedValueOnce(23)
14+
.mockResolvedValueOnce(0)
15+
16+
await expect(backfillWelResidualCostTotal({ projectBatch })).resolves.toBe(523)
17+
expect(projectBatch.mock.calls).toEqual([
18+
[WEL_RESIDUAL_COST_TOTAL_BATCH_SIZE],
19+
[WEL_RESIDUAL_COST_TOTAL_BATCH_SIZE],
20+
[WEL_RESIDUAL_COST_TOTAL_BATCH_SIZE],
21+
])
22+
})
23+
24+
it('honors a custom batch size', async () => {
25+
const projectBatch = vi
26+
.fn<WelResidualCostTotalBackfillStore['projectBatch']>()
27+
.mockResolvedValueOnce(2)
28+
.mockResolvedValueOnce(0)
29+
30+
await expect(backfillWelResidualCostTotal({ projectBatch }, { batchSize: 2 })).resolves.toBe(2)
31+
expect(projectBatch).toHaveBeenCalledWith(2)
32+
})
33+
34+
it('rejects an invalid batch size', async () => {
35+
const projectBatch = vi.fn<WelResidualCostTotalBackfillStore['projectBatch']>()
36+
37+
await expect(backfillWelResidualCostTotal({ projectBatch }, { batchSize: 0 })).rejects.toThrow(
38+
'positive integer'
39+
)
40+
expect(projectBatch).not.toHaveBeenCalled()
41+
})
42+
43+
it('fails loudly when the candidate set stops shrinking', async () => {
44+
const projectBatch = vi
45+
.fn<WelResidualCostTotalBackfillStore['projectBatch']>()
46+
.mockResolvedValue(1)
47+
48+
await expect(backfillWelResidualCostTotal({ projectBatch })).rejects.toThrow('not shrinking')
49+
})
50+
})
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { Sql } from 'postgres'
3+
import type { ScriptMigration } from './types'
4+
5+
const logger = createLogger('WelResidualCostTotalBackfill')
6+
7+
export const WEL_RESIDUAL_COST_TOTAL_BATCH_SIZE = 500
8+
9+
/**
10+
* Safety valve for a store that keeps reporting progress: each batch must
11+
* shrink the candidate set (projected rows no longer match `cost_total IS
12+
* NULL`), so hitting this bound means the store is broken, not the data big.
13+
*/
14+
const MAX_BATCHES = 10_000
15+
16+
export interface WelResidualCostTotalBackfillStore {
17+
/** Projects one bounded batch of candidates and reports rows changed. */
18+
projectBatch(limit: number): Promise<number>
19+
}
20+
21+
interface WelResidualCostTotalBackfillOptions {
22+
batchSize?: number
23+
}
24+
25+
/**
26+
* Projects the residual `workflow_execution_logs.cost` json totals into
27+
* `cost_total`/`models_used`, batch by batch, until no candidates remain.
28+
*/
29+
export async function backfillWelResidualCostTotal(
30+
store: WelResidualCostTotalBackfillStore,
31+
options: WelResidualCostTotalBackfillOptions = {}
32+
): Promise<number> {
33+
const batchSize = options.batchSize ?? WEL_RESIDUAL_COST_TOTAL_BATCH_SIZE
34+
if (!Number.isInteger(batchSize) || batchSize <= 0) {
35+
throw new Error('Residual cost_total backfill batch size must be a positive integer')
36+
}
37+
38+
let projected = 0
39+
for (let batch = 0; batch < MAX_BATCHES; batch++) {
40+
const changed = await store.projectBatch(batchSize)
41+
if (changed === 0) return projected
42+
projected += changed
43+
}
44+
throw new Error('Residual cost_total backfill did not converge; candidate set is not shrinking')
45+
}
46+
47+
/**
48+
* Same candidate filter and projection as the 0220 procedure that introduced
49+
* `cost_total`: a numeric `cost->>'total'` fills `cost_total`, and the
50+
* `cost->'models'` keys fill `models_used`. Rows whose json lacks a numeric
51+
* total have nothing to project and stay untouched.
52+
*/
53+
export function createPostgresWelResidualCostTotalBackfillStore(
54+
sql: Sql
55+
): WelResidualCostTotalBackfillStore {
56+
return {
57+
async projectBatch(limit) {
58+
const result = await sql`
59+
WITH candidates AS (
60+
SELECT id FROM workflow_execution_logs
61+
WHERE cost_total IS NULL
62+
AND cost ? 'total'
63+
AND (cost->>'total') ~ '^-?[0-9]+(\\.[0-9]+)?$'
64+
LIMIT ${limit}
65+
)
66+
UPDATE workflow_execution_logs wel
67+
SET cost_total = NULLIF(wel.cost->>'total', '')::numeric,
68+
models_used = CASE
69+
WHEN jsonb_typeof(wel.cost->'models') = 'object'
70+
THEN ARRAY(SELECT jsonb_object_keys(wel.cost->'models'))
71+
ELSE wel.models_used
72+
END
73+
FROM candidates
74+
WHERE wel.id = candidates.id
75+
`
76+
return result.count
77+
},
78+
}
79+
}
80+
81+
/**
82+
* The 0220 backfill projected every then-existing legacy `cost` json into
83+
* `cost_total`; a transition-window writer path added a handful of rows after
84+
* it ran with the json but no projection (verified on the prod replica
85+
* 2026-08-26: ~23 of 4.77M rows carry a numeric total with `cost_total` NULL).
86+
* This projects those stragglers so the pending `cost` DROP (see the
87+
* contract-pending marker on the column) abandons nothing that `cost_total`
88+
* should hold. The contract PR that drops `cost` must delete this entry from
89+
* the registry in the same change — it reads the column.
90+
*/
91+
export const backfillWelResidualCostTotalMigration: ScriptMigration = {
92+
name: '0009_backfill_wel_residual_cost_total',
93+
async up(sql) {
94+
const projected = await backfillWelResidualCostTotal(
95+
createPostgresWelResidualCostTotalBackfillStore(sql)
96+
)
97+
logger.info(`Residual cost_total backfill complete: ${projected} row(s) projected.`)
98+
},
99+
}

packages/db/script-migrations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { repairUnknownTableRowProvenance } from './0005_repair_unknown_table_row
77
import { repairUnknownTableRowProvenanceSecondPass } from './0006_repair_unknown_table_row_provenance_second_pass'
88
import { repairUnknownWorkspaceFileProvenance } from './0007_repair_unknown_workspace_file_provenance'
99
import { backfillWorkspaceFileSizeBytesMigration } from './0008_backfill_workspace_file_size_bytes'
10+
import { backfillWelResidualCostTotalMigration } from './0009_backfill_wel_residual_cost_total'
1011
import type { ScriptMigration } from './types'
1112

1213
export type { ScriptMigration } from './types'
@@ -25,6 +26,7 @@ export const scriptMigrations: readonly ScriptMigration[] = [
2526
repairUnknownTableRowProvenanceSecondPass,
2627
repairUnknownWorkspaceFileProvenance,
2728
backfillWorkspaceFileSizeBytesMigration,
29+
backfillWelResidualCostTotalMigration,
2830
]
2931

3032
/**

0 commit comments

Comments
 (0)