Compare commits

...

2 Commits

Author SHA1 Message Date
a58fc7d67b Merge pull request 'fix: MKH-BOM 修复在途旧查询回写缓存,导致「同步最新数据」点完仍是旧数据' (#20) from codex/mkh-cache-stale-write-fix-0922 into main 2026-09-22 23:58:04 +08:00
add148192d fix: MKH-BOM 修复在途旧查询回写缓存,导致「同步最新数据」点完仍是旧数据
问题:点「同步最新数据」后,页面在 2~10 分钟内仍显示旧数据,用户表现为「像在用缓存」。

根因:本页 10 个进程内缓存都是「先挂 pending → await 结果 → 无条件写回」的写法。
若某个查询 in-flight 期间用户点了同步(clearMkhBomCache 清空全部缓存),那个**发起于
清缓存之前**的查询读到的仍是旧快照,返回时会无条件把旧值写回缓存,覆盖掉清缓存后新
请求填入的新值 —— 谁后完成谁生效。并发越高(多人同时使用、或先点刷新再点同步)越容
易撞上,且清缓存反而把旧数据「钉」在缓存里 2~10 分钟(各缓存 TTL),表现为按钮失灵。

修法:引入缓存代际 cacheEpoch。
- clearMkhBomCache() 先自增代际,再清各缓存;
- 每个查询发起时记下当时的代际,写回缓存前比对:代际对不上说明中途被清过缓存,
  本次结果只返回给发起它的那个请求,不写缓存。
覆盖 7 个变量型缓存(officialBomIndex / businessBom / productBom / mkhProduct /
workOrderIndex / orderIndex / replenishIndex)与 3 个按 key 的 Map 缓存
(officialBomDetail / componentLocation / componentStock)。
POST /api/mkh-bom/sync 响应新增 cacheEpoch 字段,便于线上核对清缓存是否生效。

验证:
- 新增 2 个用例:① 复现竞态——对照组(去掉写回保护)报 expected 1 / actual 9,正是
  「点同步后拿到旧值」;加保护后通过。② 同代际内缓存照常命中的回归保护。
- 后端全量 node --test test/*.test.js:340 用例 / 339 通过,唯一失败为环境固有的
  resilientSecretStore Windows 权限断言(与基线一致)。
- 本地端到端:预热 24.3s → 缓存 58ms;sync 返回 cacheEpoch=1;冷查 8.5s 确实重查;
  再查 37ms 缓存重新生效;数据指纹前后一致。
2026-09-22 18:39:09 +08:00
2 changed files with 174 additions and 20 deletions

View File

@ -360,6 +360,21 @@ const officialBomDetailCache = new Map();
const componentLocationCache = new Map();
let productBomCache = null;
/**
* 缓存代际(epoch):`clearMkhBomCache()` 每次自增。
*
* 为什么需要它 ——「同步最新数据」按钮点完反而看到旧数据:
* 所有缓存都是「先挂 pending,await 到结果后无条件写回」的写法。若在某个查询
* in-flight 期间用户点了同步(清空全部缓存),那个**发起于清缓存之前**的查询
* 读到的仍是旧快照,它返回时会无条件把旧值写回缓存,把清缓存后新请求填入的
* 新值覆盖掉 —— 之后 2~10 分钟(各缓存 TTL)内页面持续显示旧数据。
* 并发越高(多人同时用、或先点刷新再点同步)越容易撞上。
*
* 修法:查询发起时记下当时的 epoch,写回前比对;epoch 变了说明中途被清过缓存,
* 这次结果已经过期,**只返回给发起它的那个请求,不写缓存**。
*/
let cacheEpoch = 0;
// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。
// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。
const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
@ -742,13 +757,18 @@ async function loadOfficialBomIndex() {
return { bySku, bySpuPrefix: [], entries: [] };
});
const epoch = cacheEpoch;
officialBomIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
officialBomIndexCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS };
// epoch 变了 ⇒ 期间用户点过「同步最新数据」,本次结果来自清缓存前的旧快照,
// 只还给发起它的请求,不写回缓存(否则会把新值覆盖成旧值)
if (epoch === cacheEpoch) {
officialBomIndexCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS };
}
return value;
} catch (error) {
officialBomIndexCache = null;
if (epoch === cacheEpoch) officialBomIndexCache = null;
throw error;
}
}
@ -765,6 +785,8 @@ async function loadOfficialBomForSkus(skuIds = []) {
.filter(([key, value]) => key && value)
).entries()).slice(0, MAX_PAGE_SIZE);
const now = Date.now();
// 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存
const epoch = cacheEpoch;
const missing = requested.filter(([key]) => {
const cached = officialBomDetailCache.get(key);
return !cached || cached.expiresAt <= now;
@ -795,11 +817,14 @@ async function loadOfficialBomForSkus(skuIds = []) {
);
const grouped = groupOfficialBomRows(rows);
const expiresAt = Date.now() + OFFICIAL_BOM_DETAIL_CACHE_TTL_MS;
const writable = epoch === cacheEpoch;
missing.forEach(([key]) => {
officialBomDetailCache.set(key, {
components: grouped.bySku.get(key) || [],
expiresAt,
});
if (writable) {
officialBomDetailCache.set(key, {
components: grouped.bySku.get(key) || [],
expiresAt,
});
}
});
}
@ -856,13 +881,16 @@ async function loadBusinessBom() {
return bom;
});
const epoch = cacheEpoch;
businessBomCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
businessBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS };
if (epoch === cacheEpoch) {
businessBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS };
}
return value;
} catch (error) {
businessBomCache = null;
if (epoch === cacheEpoch) businessBomCache = null;
throw error;
}
}
@ -939,13 +967,16 @@ async function loadPgBom() {
return { bySku, bySpuPrefix, entries };
})();
const epoch = cacheEpoch;
productBomCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
productBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS };
if (epoch === cacheEpoch) {
productBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS };
}
return value;
} catch (error) {
productBomCache = null;
if (epoch === cacheEpoch) productBomCache = null;
throw error;
}
}
@ -1283,13 +1314,16 @@ async function loadMkhProductIndex() {
material: toText(row.material),
})).filter((row) => row.skuId));
const epoch = cacheEpoch;
mkhProductCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
if (epoch === cacheEpoch) {
mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
}
return value;
} catch (error) {
mkhProductCache = null;
if (epoch === cacheEpoch) mkhProductCache = null;
throw error;
}
}
@ -1350,13 +1384,16 @@ async function loadPendingWorkOrderIndex() {
return { bySku: new Map(), degraded: true, error: error.message, ttl: WORK_ORDER_DEGRADED_TTL_MS };
});
const epoch = cacheEpoch;
workOrderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
if (epoch === cacheEpoch) {
workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
}
return value;
} catch (error) {
workOrderIndexCache = null;
if (epoch === cacheEpoch) workOrderIndexCache = null;
throw error;
}
}
@ -1642,13 +1679,16 @@ async function loadOrderIndex() {
return { pendingBySku, shippedBySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS };
});
const epoch = cacheEpoch;
orderIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
if (epoch === cacheEpoch) {
orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
}
return value;
} catch (error) {
orderIndexCache = null;
if (epoch === cacheEpoch) orderIndexCache = null;
throw error;
}
}
@ -1751,13 +1791,16 @@ async function loadReplenishIndex() {
};
});
const epoch = cacheEpoch;
replenishIndexCache = { pending, value: null, expiresAt: 0 };
try {
const value = await pending;
replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
if (epoch === cacheEpoch) {
replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
}
return value;
} catch (error) {
replenishIndexCache = null;
if (epoch === cacheEpoch) replenishIndexCache = null;
throw error;
}
}
@ -1772,6 +1815,8 @@ async function loadReplenishIndex() {
*/
async function loadComponentStock(codes = []) {
const now = Date.now();
// 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存
const epoch = cacheEpoch;
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
const stockMap = new Map();
const missing = [];
@ -1806,9 +1851,11 @@ async function loadComponentStock(codes = []) {
if (code) found.set(code, toNumber(row.qty) ?? 0);
});
const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS;
const writable = epoch === cacheEpoch;
chunk.forEach((code) => {
const qty = found.get(code) ?? 0;
componentStockCache.set(code, { qty, expiresAt });
// 本批结果仍要返回给调用方(stockMap),只是不再写缓存
if (writable) componentStockCache.set(code, { qty, expiresAt });
stockMap.set(code, qty);
});
} catch (error) {
@ -2019,6 +2066,8 @@ function buildPackLocationMap(rows, componentCodes) {
*/
async function loadComponentLocations(codes = []) {
const now = Date.now();
// 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存
const epoch = cacheEpoch;
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
const locationMap = new Map();
const missing = [];
@ -2065,9 +2114,11 @@ async function loadComponentLocations(codes = []) {
);
const byCode = buildPackLocationMap(rows, chunk);
const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS;
const writable = epoch === cacheEpoch;
chunk.forEach((code) => {
const location = byCode.get(code) || null;
componentLocationCache.set(code, { location, expiresAt });
// 本批结果仍要返回给调用方(locationMap),只是不再写缓存
if (writable) componentLocationCache.set(code, { location, expiresAt });
if (location) locationMap.set(code, location);
});
} catch (error) {
@ -2405,6 +2456,11 @@ async function listMkhBomExportRows(query = {}) {
// 而不是 2~15 分钟前的缓存结果。
// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。
function clearMkhBomCache() {
// 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询,
// 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值,
// 导致按钮点完反而 2~10 分钟一直显示旧数据)。
cacheEpoch += 1;
businessBomCache = null;
officialBomIndexCache = null;
productBomCache = null;
@ -2424,6 +2480,7 @@ function clearMkhBomCache() {
return {
syncedAt: new Date().toISOString(),
cacheEpoch,
cleared: cleared.map(([name, entries]) => ({ name, entries })),
};
}

View File

@ -1167,3 +1167,100 @@ test('export: 配件不足 rows are flagged and carry no replenish target', () =
// 可组装数一并导出,便于业务核对短板
assert.equal(rows[0].assemblableQty, 0);
});
// ─────────────────────────────────────────────────────────────────────────────
// 缓存代际(cacheEpoch):修复「点同步最新数据,反而显示旧数据」
//
// 症状:用户点「同步最新数据」后,页面 2~10 分钟内持续显示旧值,像在用缓存。
// 成因:缓存是「先挂 pending → await 结果 → 无条件写回」。若某个查询在途时用户点了
// 同步(清空缓存),那个发起于清缓存之前的查询读到的是旧快照,返回时会把旧值
// 写回缓存,覆盖掉清缓存后新请求填入的新值 —— 谁后完成谁生效。
//
// 下面第一个用例复现旧行为(修复前失败),第二个用例锁定正确语义。
// ─────────────────────────────────────────────────────────────────────────────
test('cache epoch: 清缓存后在途的旧查询结果不得写回缓存(否则同步按钮点完仍是旧数据)', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const CODE = 'DNZTESTCACHE01';
const staleQty = 9; // 清缓存前那一刻库里/旧快照的值
const freshQty = 1; // 清缓存后重新查到的值
let phase = 'old';
let stockQueries = 0;
let releaseStale;
const staleGate = new Promise((resolve) => { releaseStale = resolve; });
let markStaleStarted;
const staleStarted = new Promise((resolve) => { markStaleStarted = resolve; });
cloudDatabase.query = async (sql) => {
if (!sql.includes('jst_inventoryInfo')) return [];
stockQueries += 1;
if (phase === 'old') {
markStaleStarted();
await staleGate; // 卡住旧查询:模拟「发起于此、期间用户点了同步」的慢查询
return [{ code: CODE, qty: staleQty }];
}
return [{ code: CODE, qty: freshQty }];
};
try {
// 1) 旧查询发起并卡住
const inFlight = svc._private.loadComponentStock([CODE]);
await staleStarted;
// 2) 用户点「同步最新数据」→ 清缓存并推进代际
const cleared = svc.clearMkhBomCache();
assert.ok(cleared.cacheEpoch >= 1, '清缓存必须推进代际号');
phase = 'new';
// 3) 清缓存后的新查询先返回,拿到新值并写缓存
const fresh = await svc._private.loadComponentStock([CODE]);
assert.equal(fresh.stockMap.get(CODE), freshQty);
assert.equal(stockQueries, 2, '清缓存后必须真正重查数据源');
// 4) 旧查询这才返回(它读到的仍是旧快照)
releaseStale();
const staleResult = await inFlight;
assert.equal(staleResult.stockMap.get(CODE), staleQty,
'过期结果仍应原样返回给发起它的那个请求,不能改变它自己的语义');
// 5) 🔥 关键断言:缓存里必须还是新值
const after = await svc._private.loadComponentStock([CODE]);
assert.equal(after.stockMap.get(CODE), freshQty,
'清缓存后返回的过期结果写回了缓存 → 用户会看到「点了同步还是旧数据」');
assert.equal(stockQueries, 2,
'第 5 步应直接命中缓存,不应再打数据库(说明缓存确实被写入了新值)');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const CODE = 'DNZTESTCACHE02';
let stockQueries = 0;
cloudDatabase.query = async (sql) => {
if (!sql.includes('jst_inventoryInfo')) return [];
stockQueries += 1;
return [{ code: CODE, qty: 3 }];
};
try {
svc.clearMkhBomCache();
const first = await svc._private.loadComponentStock([CODE]);
const second = await svc._private.loadComponentStock([CODE]);
assert.equal(first.stockMap.get(CODE), 3);
assert.equal(second.stockMap.get(CODE), 3);
assert.equal(stockQueries, 1, '同一次缓存代际内,第二次必须命中缓存不再查库');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});