cost_exceptions 只增不减:本地快照 260,253 行(resolved 151,939 / open 108,314), 已处理的行只是审计痕迹,留在热表会拖慢「状态 + 时间」分页,并让唯一索引持续膨胀。 - 迁移 108:新增 cost_exceptions_archive(保留原 id 与时间字段,加 archived_at), 热表补部分索引 idx_cost_exceptions_archivable (COALESCE(resolved_at, created_at)) WHERE status <> 'open',让每日任务不扫全表; - cost-exception-archive-service:只归档 status <> 'open' 且超期的行,每批 1000 行, DELETE ... RETURNING + INSERT 在同一语句内完成,单批原子,不存在「删了没归档」; - data-retention 每日任务接入;保留期走 COST_EXCEPTION_RETENTION_DAYS(默认 60); - 新增 npm run cost-exceptions:archive(--dry-run / --days / --batch-size)。 为什么 open 行不归档:open 是待办队列,超期不等于已解决,归档等于把问题藏起来。 验证: - 3 条 resolved 回拨到 90 天前 → 归档 3 条,热表 resolved 151,939 → 151,936; - 1 条 open 回拨到 90 天前 → 仍在热表,未被归档; - 归档表保留原 id 与时间,archived_at 记录归档时刻; - 恢复测试数据后计数回到 151,939 / 108,314。
93 lines
2.7 KiB
TypeScript
93 lines
2.7 KiB
TypeScript
/**
|
|
* 手动归档已处理的成本异常(日常由 data-retention 定时任务自动执行)。
|
|
*
|
|
* 默认窗口与线上配置一致(60 天),可通过 --days 覆盖;open 行永远不归档。
|
|
*
|
|
* 用法:
|
|
* npm run cost-exceptions:archive -- --dry-run
|
|
* npm run cost-exceptions:archive -- --days=90
|
|
* npm run cost-exceptions:archive -- --days=60 --batch-size=2000
|
|
*/
|
|
import process from 'node:process'
|
|
|
|
import { closeDb } from '../src/db/client.js'
|
|
import { runtimeConfig } from '../src/config/runtime.js'
|
|
import {
|
|
archiveResolvedCostExceptions,
|
|
countArchivableCostExceptions,
|
|
} from '../src/services/cost-center/cost-exception-archive-service.js'
|
|
|
|
const options = parseOptions(process.argv.slice(2))
|
|
const defaultDays = Number(runtimeConfig.retention?.costExceptionDays) || 60
|
|
const retentionDays = normalizeInteger(options.days, defaultDays, 1, 3650)
|
|
const batchSize = normalizeInteger(options.batchSize, 1000, 1, 10_000)
|
|
const dryRun = options.dryRun === 'true'
|
|
|
|
async function main() {
|
|
const startedAt = Date.now()
|
|
const archivable = await countArchivableCostExceptions(retentionDays)
|
|
|
|
if (dryRun) {
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
dryRun: true,
|
|
retentionDays,
|
|
archivableRows: archivable,
|
|
durationMs: Date.now() - startedAt,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
const result = await archiveResolvedCostExceptions({ retentionDays, batchSize })
|
|
console.log(
|
|
JSON.stringify(
|
|
{ dryRun: false, ...result, batchSize, durationMs: Date.now() - startedAt },
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
if (result.remainingArchivableRows > 0) {
|
|
console.error(`仍有 ${result.remainingArchivableRows} 行待归档;请重跑(幂等)。`)
|
|
process.exitCode = 2
|
|
}
|
|
}
|
|
|
|
main()
|
|
.catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error)
|
|
process.exitCode = 1
|
|
})
|
|
.finally(async () => {
|
|
await closeDb()
|
|
})
|
|
|
|
function parseOptions(args: string[]) {
|
|
const result: Record<string, string> = {}
|
|
for (const arg of args) {
|
|
if (!arg.startsWith('--')) continue
|
|
const body = arg.slice(2)
|
|
const index = body.indexOf('=')
|
|
const key = index === -1 ? body : body.slice(0, index)
|
|
const value = index === -1 ? 'true' : body.slice(index + 1)
|
|
result[key] = value
|
|
result[toCamelCase(key)] = value
|
|
}
|
|
return result
|
|
}
|
|
|
|
function toCamelCase(value: string) {
|
|
return value.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())
|
|
}
|
|
|
|
function normalizeInteger(value: unknown, fallback: number, min: number, max: number) {
|
|
const parsed = Number(value)
|
|
if (!Number.isInteger(parsed)) return fallback
|
|
return Math.min(max, Math.max(min, parsed))
|
|
}
|