perf(pipeline): 订单项管道改用水位游标并只扫缺失
- 新增 watermark-store:统一读写游标/日水位/失败原因 - 渠道判定、商品归一、成本快照从上次游标继续,到表尾后回绕重试 - 成本快照只处理没有有效快照的订单项,避免每 5 分钟全量重扫
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { resolveExceptionsForItems, upsertAggregatedException } from './cost-exception-service.js'
|
||||
import { readWatermarkItemId } from '../watermark-store.js'
|
||||
|
||||
/**
|
||||
* 管道 C1:成本快照(唯一入口)。
|
||||
@@ -157,6 +158,7 @@ export type SyncCostSnapshotsResult = {
|
||||
created: number
|
||||
missing: number
|
||||
lastId: number
|
||||
nextCursor: number
|
||||
}
|
||||
|
||||
export async function syncChannelCostSnapshots(
|
||||
@@ -164,14 +166,24 @@ export async function syncChannelCostSnapshots(
|
||||
): Promise<SyncCostSnapshotsResult> {
|
||||
const batchSize = Math.min(5000, Math.max(1, Math.trunc(options.batchSize || 500)))
|
||||
const maxBatches = Math.min(200, Math.max(1, Math.trunc(options.maxBatches || 20)))
|
||||
let cursor = Math.max(0, Math.trunc(options.afterId || 0))
|
||||
let cursor =
|
||||
options.afterId != null
|
||||
? Math.max(0, Math.trunc(options.afterId))
|
||||
: await readWatermarkItemId('cost_snapshot')
|
||||
let reachedEnd = false
|
||||
|
||||
const costItems = await loadCostItems()
|
||||
const versions = await loadActiveVersions()
|
||||
const pricingModeByItemId = new Map<number, string>()
|
||||
for (const item of costItems.values()) pricingModeByItemId.set(item.id, item.pricing_mode)
|
||||
|
||||
const result: SyncCostSnapshotsResult = { scanned: 0, created: 0, missing: 0, lastId: cursor }
|
||||
const result: SyncCostSnapshotsResult = {
|
||||
scanned: 0,
|
||||
created: 0,
|
||||
missing: 0,
|
||||
lastId: cursor,
|
||||
nextCursor: cursor,
|
||||
}
|
||||
|
||||
for (let batch = 0; batch < maxBatches; batch += 1) {
|
||||
const rows = await query<CostCandidateItem>(
|
||||
@@ -198,6 +210,11 @@ export async function syncChannelCostSnapshots(
|
||||
oi.canonical_product_id IS NOT NULL
|
||||
OR (oi.fulfillment_channel = 'work_order' AND wo.product_rule_id IS NOT NULL)
|
||||
)
|
||||
-- 只处理还没有有效快照的订单项,避免每轮重复解析整张表。
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
WHERE cs.order_item_id = oi.id AND cs.superseded_at IS NULL
|
||||
)
|
||||
AND oi.id > $1
|
||||
ORDER BY oi.id
|
||||
LIMIT $2
|
||||
@@ -205,7 +222,11 @@ export async function syncChannelCostSnapshots(
|
||||
[cursor, batchSize],
|
||||
)
|
||||
|
||||
if (rows.rows.length === 0) break
|
||||
if (rows.rows.length === 0) {
|
||||
reachedEnd = true
|
||||
break
|
||||
}
|
||||
if (rows.rows.length < batchSize) reachedEnd = true
|
||||
result.scanned += rows.rows.length
|
||||
cursor = Number(rows.rows[rows.rows.length - 1]!.id)
|
||||
result.lastId = cursor
|
||||
@@ -236,6 +257,7 @@ export async function syncChannelCostSnapshots(
|
||||
await resolveExceptionsForItems(inserts.map((row) => row.item.id))
|
||||
}
|
||||
|
||||
result.nextCursor = reachedEnd ? 0 : cursor
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { resolveExceptionsForItems, upsertAggregatedException } from './cost-exception-service.js'
|
||||
import { readWatermarkItemId } from '../watermark-store.js'
|
||||
|
||||
/**
|
||||
* 管道 B:商品归一。
|
||||
@@ -171,6 +172,7 @@ export type NormalizeProductsResult = {
|
||||
matched: number
|
||||
unmatched: number
|
||||
lastId: number
|
||||
nextCursor: number
|
||||
}
|
||||
|
||||
export async function normalizeOrderItemProducts(
|
||||
@@ -178,10 +180,20 @@ export async function normalizeOrderItemProducts(
|
||||
): Promise<NormalizeProductsResult> {
|
||||
const batchSize = Math.min(5000, Math.max(1, Math.trunc(options.batchSize || 500)))
|
||||
const maxBatches = Math.min(200, Math.max(1, Math.trunc(options.maxBatches || 20)))
|
||||
let cursor = Math.max(0, Math.trunc(options.afterId || 0))
|
||||
let cursor =
|
||||
options.afterId != null
|
||||
? Math.max(0, Math.trunc(options.afterId))
|
||||
: await readWatermarkItemId('product_normalize')
|
||||
let reachedEnd = false
|
||||
|
||||
const index = await loadMappingIndex()
|
||||
const result: NormalizeProductsResult = { scanned: 0, matched: 0, unmatched: 0, lastId: cursor }
|
||||
const result: NormalizeProductsResult = {
|
||||
scanned: 0,
|
||||
matched: 0,
|
||||
unmatched: 0,
|
||||
lastId: cursor,
|
||||
nextCursor: cursor,
|
||||
}
|
||||
|
||||
for (let batch = 0; batch < maxBatches; batch += 1) {
|
||||
const rows = await query<ItemRow>(
|
||||
@@ -211,7 +223,11 @@ export async function normalizeOrderItemProducts(
|
||||
[cursor, batchSize],
|
||||
)
|
||||
|
||||
if (rows.rows.length === 0) break
|
||||
if (rows.rows.length === 0) {
|
||||
reachedEnd = true
|
||||
break
|
||||
}
|
||||
if (rows.rows.length < batchSize) reachedEnd = true
|
||||
result.scanned += rows.rows.length
|
||||
cursor = Number(rows.rows[rows.rows.length - 1]!.id)
|
||||
result.lastId = cursor
|
||||
@@ -249,6 +265,7 @@ export async function normalizeOrderItemProducts(
|
||||
}
|
||||
}
|
||||
|
||||
result.nextCursor = reachedEnd ? 0 : cursor
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { query } from '../../db/client.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { upsertAggregatedException } from '../cost-center/cost-exception-service.js'
|
||||
import { readWatermarkItemId } from '../watermark-store.js'
|
||||
import {
|
||||
FULFILLMENT_CHANNELS,
|
||||
MATCH_KEY_TYPES,
|
||||
@@ -138,6 +139,8 @@ export type AssignChannelsResult = {
|
||||
conflicted: number
|
||||
stillPending: number
|
||||
lastId: number
|
||||
/** 下一轮起点;到达表尾时回绕到 0,保证漏判的订单还能被重新评估。 */
|
||||
nextCursor: number
|
||||
byChannel: Record<string, number>
|
||||
}
|
||||
|
||||
@@ -147,7 +150,11 @@ export async function assignPendingOrderItemChannels(
|
||||
): Promise<AssignChannelsResult> {
|
||||
const batchSize = Math.min(5000, Math.max(1, Math.trunc(options.batchSize || 500)))
|
||||
const maxBatches = Math.min(200, Math.max(1, Math.trunc(options.maxBatches || 20)))
|
||||
let cursor = Math.max(0, Math.trunc(options.afterId || 0))
|
||||
let cursor =
|
||||
options.afterId != null
|
||||
? Math.max(0, Math.trunc(options.afterId))
|
||||
: await readWatermarkItemId('channel_assign')
|
||||
let reachedEnd = false
|
||||
|
||||
const rules = await loadEnabledRules()
|
||||
const result: AssignChannelsResult = {
|
||||
@@ -156,6 +163,7 @@ export async function assignPendingOrderItemChannels(
|
||||
conflicted: 0,
|
||||
stillPending: 0,
|
||||
lastId: cursor,
|
||||
nextCursor: cursor,
|
||||
byChannel: {},
|
||||
}
|
||||
|
||||
@@ -188,7 +196,11 @@ export async function assignPendingOrderItemChannels(
|
||||
[cursor, batchSize],
|
||||
)
|
||||
|
||||
if (rows.rows.length === 0) break
|
||||
if (rows.rows.length === 0) {
|
||||
reachedEnd = true
|
||||
break
|
||||
}
|
||||
if (rows.rows.length < batchSize) reachedEnd = true
|
||||
result.scanned += rows.rows.length
|
||||
cursor = Number(rows.rows[rows.rows.length - 1]!.id)
|
||||
result.lastId = cursor
|
||||
@@ -245,6 +257,7 @@ export async function assignPendingOrderItemChannels(
|
||||
}
|
||||
}
|
||||
|
||||
result.nextCursor = reachedEnd ? 0 : cursor
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,12 @@ export async function runChannelAssignJob(job: JsonObject = {}) {
|
||||
const result = await assignPendingOrderItemChannels({
|
||||
batchSize: clampInt(config.batchSize, 500, 1, 5000),
|
||||
maxBatches: clampInt(config.maxBatches, 20, 1, 200),
|
||||
afterId: clampInt(config.afterId, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
...(config.afterId != null
|
||||
? { afterId: clampInt(config.afterId, 0, 0, Number.MAX_SAFE_INTEGER) }
|
||||
: {}),
|
||||
})
|
||||
|
||||
await touchWatermark('channel_assign', { lastItemId: result.lastId })
|
||||
await touchWatermark('channel_assign', { lastItemId: result.nextCursor })
|
||||
return {
|
||||
status: 'ok',
|
||||
message:
|
||||
@@ -188,7 +190,7 @@ export async function runProductNormalizeJob(job: JsonObject = {}) {
|
||||
maxBatches: clampInt(config.maxBatches, 20, 1, 200),
|
||||
})
|
||||
|
||||
await touchWatermark('product_normalize', { lastItemId: result.lastId })
|
||||
await touchWatermark('product_normalize', { lastItemId: result.nextCursor })
|
||||
return {
|
||||
status: 'ok',
|
||||
message: `商品归一:扫描 ${result.scanned},命中 ${result.matched},未命中 ${result.unmatched}`,
|
||||
@@ -203,7 +205,7 @@ export async function runCostSnapshotJob(job: JsonObject = {}) {
|
||||
maxBatches: clampInt(config.maxBatches, 20, 1, 200),
|
||||
})
|
||||
|
||||
await touchWatermark('cost_snapshot', { lastItemId: result.lastId })
|
||||
await touchWatermark('cost_snapshot', { lastItemId: result.nextCursor })
|
||||
return {
|
||||
status: 'ok',
|
||||
message: `成本快照:扫描 ${result.scanned},生成 ${result.created},缺失 ${result.missing}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { getScheduledJobRuntimeStates } from './scheduler-service.js'
|
||||
import { recordPipelineError, touchWatermark } from '../watermark-store.js'
|
||||
import type { WatermarkPipeline } from '../watermark-store.js'
|
||||
|
||||
/**
|
||||
* 财务管道可观测性:水位 + 定时任务运行状态。
|
||||
@@ -22,49 +23,12 @@ export const FINANCE_PIPELINES = [
|
||||
|
||||
export type FinancePipelineName = (typeof FINANCE_PIPELINES)[number]['name']
|
||||
|
||||
export { recordPipelineError, touchWatermark }
|
||||
|
||||
export function isFinancePipelineName(value: unknown): value is FinancePipelineName {
|
||||
return FINANCE_PIPELINES.some((item) => item.name === value)
|
||||
}
|
||||
|
||||
/** 记录一次成功运行的水位。 */
|
||||
export async function touchWatermark(
|
||||
pipeline: FinancePipelineName,
|
||||
patch: { lastItemId?: number; lastStatDate?: string | null } = {},
|
||||
): Promise<void> {
|
||||
const now = nowIso()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO finance_watermarks (pipeline, last_item_id, last_stat_date, last_success_at, updated_at)
|
||||
VALUES ($1, $2, $3::date, $4, $4)
|
||||
ON CONFLICT (pipeline) DO UPDATE SET
|
||||
last_item_id = GREATEST(finance_watermarks.last_item_id, EXCLUDED.last_item_id),
|
||||
last_stat_date = COALESCE(EXCLUDED.last_stat_date, finance_watermarks.last_stat_date),
|
||||
last_success_at = EXCLUDED.last_success_at,
|
||||
last_error = '',
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`,
|
||||
[pipeline, Math.max(0, Math.trunc(patch.lastItemId || 0)), patch.lastStatDate ?? null, now],
|
||||
)
|
||||
}
|
||||
|
||||
/** 记录一次失败原因,不改水位。 */
|
||||
export async function recordPipelineError(
|
||||
pipeline: FinancePipelineName,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await query(
|
||||
`
|
||||
INSERT INTO finance_watermarks (pipeline, last_error, updated_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (pipeline) DO UPDATE SET
|
||||
last_error = EXCLUDED.last_error,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`,
|
||||
[pipeline, message.slice(0, 500), nowIso()],
|
||||
)
|
||||
}
|
||||
|
||||
export type FinancePipelineStatusRow = {
|
||||
pipeline: string
|
||||
label: string
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { query } from '../db/client.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
/**
|
||||
* 管道水位:订单项级游标 + 按日水位 + 最近失败原因。
|
||||
*
|
||||
* 只在服务端使用,不依赖 scheduler,避免循环依赖。
|
||||
*/
|
||||
|
||||
export type WatermarkPipeline =
|
||||
| 'channel_assign'
|
||||
| 'carrier_dispatch'
|
||||
| 'product_normalize'
|
||||
| 'cost_snapshot'
|
||||
| 'sales_facts'
|
||||
| 'ledger_post'
|
||||
|
||||
/** 读取订单项级游标;没有记录时返回 0。 */
|
||||
export async function readWatermarkItemId(pipeline: WatermarkPipeline): Promise<number> {
|
||||
const result = await query<{ last_item_id: string }>(
|
||||
`SELECT last_item_id::text FROM finance_watermarks WHERE pipeline = $1`,
|
||||
[pipeline],
|
||||
)
|
||||
return Number(result.rows[0]?.last_item_id || 0)
|
||||
}
|
||||
|
||||
/** 记录一次成功运行的水位。 */
|
||||
export async function touchWatermark(
|
||||
pipeline: WatermarkPipeline,
|
||||
patch: { lastItemId?: number; lastStatDate?: string | null } = {},
|
||||
): Promise<void> {
|
||||
const now = nowIso()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO finance_watermarks (pipeline, last_item_id, last_stat_date, last_success_at, updated_at)
|
||||
VALUES ($1, $2, $3::date, $4, $4)
|
||||
ON CONFLICT (pipeline) DO UPDATE SET
|
||||
last_item_id = EXCLUDED.last_item_id,
|
||||
last_stat_date = COALESCE(EXCLUDED.last_stat_date, finance_watermarks.last_stat_date),
|
||||
last_success_at = EXCLUDED.last_success_at,
|
||||
last_error = '',
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`,
|
||||
[pipeline, Math.max(0, Math.trunc(patch.lastItemId || 0)), patch.lastStatDate ?? null, now],
|
||||
)
|
||||
}
|
||||
|
||||
/** 记录一次失败原因,不改水位。 */
|
||||
export async function recordPipelineError(
|
||||
pipeline: WatermarkPipeline,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await query(
|
||||
`
|
||||
INSERT INTO finance_watermarks (pipeline, last_error, updated_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (pipeline) DO UPDATE SET
|
||||
last_error = EXCLUDED.last_error,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`,
|
||||
[pipeline, message.slice(0, 500), nowIso()],
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user