Merge pull request 'fix: MKH-BOM 修复在途旧查询回写缓存,导致「同步最新数据」点完仍是旧数据' (#20) from codex/mkh-cache-stale-write-fix-0922 into main
This commit is contained in:
commit
a58fc7d67b
@ -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;
|
||||
// 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]) => {
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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 })),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user