task-84: 优化店铺抓取队列状态合并,消除 historyItems 的线性重复查找

新增纯 TS 合并模块 merge-history-items:以 taskId→行列表 二级索引替代
原 mergeProgress 的 [...map.values()].find() 全表扫描(O(n×m)→O(n+m))。
命中语义与原实现一致(taskId 相同且 resultId 相同或 incoming 无
resultId),命中后删除旧 key 写入新 key,shopName 变化不再留下幽灵
重复条目,同批内后到条目可命中先到新增。BrandShopDataCrawlTab 的
mergeProgress 改用该模块。9 个测试覆盖默认、批量、幂等、空、单元素、
maxItems 限流、非法输入、keyOf 故障零变更与幽灵条目场景。
This commit is contained in:
2026-08-30 22:47:41 +08:00
parent 5f062df3cc
commit 5caca86ad1
3 changed files with 323 additions and 7 deletions
@@ -0,0 +1,119 @@
/**
* 历史条目合并(Task 84)。
*
* 消除店铺抓取队列状态合并中 historyItems 的线性重复查找:原实现
* `[...map.values()].find(...)` 对每个 incoming 条目全表扫描(O(n×m)),
* 且命中后沿用旧 key 写回,shopName 变化时会留下幽灵重复条目。本模块以
* taskId → 行列表 二级索引替代全表扫描(O(n+m)),命中后删除旧 key、
* 写入新 key,同批内后到条目可命中先到条目,结果幂等。
*
* 命中语义与原 mergeProgress 一致:incoming 的 taskId 为正数,且
* existing.resultId 与 incoming.resultId 相同(或 incoming 无 resultId
* 时命中同 taskId 的第一行)。key 相同(taskId/resultId/shopName 均未变)
* 的更新保持原有位置;key 变化的更新视为内容更新,写入新 key。
*
* 纯 TS 无副作用模块:入参列表永不修改(先全量建索引再产出新数组);
* keyOf 抛错时整个合并失败且零状态变更,可恢复后继续工作。maxItems 为
* 可选的输出上限(默认不限制),超过时保留最晚到达的条目。
*/
export interface MergeHistoryItemsOptions<T> {
/** 从条目提取唯一 key 的函数 */
keyOf: (item: T) => string
/** 输出条目的最大条数(默认不限制),超过时驱逐最旧条目 */
maxItems?: number
}
export interface MergeHistoryItemsResult<T> {
/** 合并后的条目数组(新数组,原数组不修改) */
items: T[]
/** 命中现有条目并替换的条数 */
updatedCount: number
/** 作为新条目追加的条数 */
addedCount: number
}
/** 命中语义:同 taskId,且 resultId 相同或 incoming 无 resultId(与原 mergeProgress 一致) */
function matches(
existing: Record<string, unknown>,
incoming: Record<string, unknown>,
): boolean {
const incomingTaskId = incoming.taskId
if (typeof incomingTaskId !== 'number' || incomingTaskId <= 0) return false
if (existing.taskId !== incomingTaskId) return false
const incomingResultId = incoming.resultId
if (typeof incomingResultId === 'number' && incomingResultId > 0) {
return existing.resultId === incomingResultId
}
return true
}
interface IndexedRow<T> {
key: string
row: T
}
export function mergeHistoryItems<T>(
existing: T[],
incoming: T[],
options: MergeHistoryItemsOptions<T>,
): MergeHistoryItemsResult<T> {
if (!Array.isArray(existing)) throw new Error('existing 必须是数组')
if (!Array.isArray(incoming)) throw new Error('incoming 必须是数组')
const { keyOf, maxItems } = options ?? {}
if (typeof keyOf !== 'function') throw new Error('keyOf 必须是函数')
if (maxItems != null && !(maxItems > 0)) {
throw new Error('maxItems 必须为正数: ' + maxItems)
}
const byKey = new Map<string, T>()
const byTaskId = new Map<number, IndexedRow<T>[]>()
function indexRow(row: T) {
const key = keyOf(row)
byKey.set(key, row)
const taskId = (row as Record<string, unknown>).taskId
if (typeof taskId === 'number' && taskId > 0) {
const list = byTaskId.get(taskId)
if (list) list.push({ key, row })
else byTaskId.set(taskId, [{ key, row }])
}
}
// 第一遍建索引:keyOf 抛错发生在任何状态变更之前
for (const row of existing) indexRow(row)
let updatedCount = 0
let addedCount = 0
for (const row of incoming) {
const taskId = (row as Record<string, unknown>).taskId
let hit: IndexedRow<T> | undefined
if (typeof taskId === 'number' && taskId > 0) {
const list = byTaskId.get(taskId)
if (list) {
hit = list.find((entry) =>
matches(entry.row as Record<string, unknown>, row as Record<string, unknown>),
)
}
}
if (hit) {
const oldKey = hit.key
const newKey = keyOf(row)
if (newKey !== oldKey) {
byKey.delete(oldKey)
hit.key = newKey
}
byKey.set(newKey, row)
hit.row = row
updatedCount += 1
} else {
indexRow(row)
addedCount += 1
}
}
const items = [...byKey.values()]
if (maxItems != null && items.length > maxItems) {
return { items: items.slice(items.length - maxItems), updatedCount, addedCount }
}
return { items, updatedCount, addedCount }
}