fix(data): 串行化 AppData 同 key 保存

This commit is contained in:
Script Generator
2026-07-07 18:22:06 +08:00
parent 0054854158
commit f1e2fb0b99
2 changed files with 96 additions and 1 deletions

View File

@@ -142,6 +142,87 @@ test('server data shares an in-flight load for the same AppData key', async () =
} }
}); });
test('server data serializes same-key saves so later writes use the latest AppData version', async () => {
const originalFetch = globalThis.fetch;
const saveCalls: Array<{ value: unknown; version?: string | null }> = [];
let releaseFirstSave!: () => void;
let markFirstSaveStarted!: () => void;
const firstSaveStarted = new Promise<void>((resolve) => {
markFirstSaveStarted = resolve;
});
const firstSaveCanFinish = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
__resetApiAvailabilityForTests();
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/config/ai')) {
return new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.endsWith('/data/requirements') && (!init?.method || init.method === 'GET')) {
return new Response(JSON.stringify({
key: 'requirements',
value: { requirements: [] },
version: 'requirements-version-1',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.endsWith('/data/requirements') && init?.method === 'PUT') {
const body = JSON.parse(String(init.body));
saveCalls.push(body);
if (body.value.marker === 'first') {
markFirstSaveStarted();
await firstSaveCanFinish;
return new Response(JSON.stringify({
key: 'requirements',
value: body.value,
version: 'requirements-version-2',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({
key: 'requirements',
value: body.value,
version: 'requirements-version-3',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`);
}) as typeof fetch;
try {
await loadServerData('requirements', { force: true });
const firstSave = saveServerData('requirements', { marker: 'first' });
await firstSaveStarted;
const secondSave = saveServerData('requirements', { marker: 'second' });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(saveCalls.length, 1);
releaseFirstSave();
await Promise.all([firstSave, secondSave]);
assert.deepEqual(saveCalls, [
{ value: { marker: 'first' }, version: 'requirements-version-1' },
{ value: { marker: 'second' }, version: 'requirements-version-2' },
]);
} finally {
globalThis.fetch = originalFetch;
__resetApiAvailabilityForTests();
}
});
test('server data reuses a fresh cached load for repeated AppData reads', async () => { test('server data reuses a fresh cached load for repeated AppData reads', async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
let dataRequests = 0; let dataRequests = 0;

View File

@@ -36,6 +36,7 @@ const serverDataCache = new Map<ServerDataKey, {
loadedAt: number; loadedAt: number;
}>(); }>();
const serverDataLoadPromises = new Map<ServerDataKey, Promise<unknown | null>>(); const serverDataLoadPromises = new Map<ServerDataKey, Promise<unknown | null>>();
const serverDataSaveQueues = new Map<ServerDataKey, Promise<void>>();
export const SERVER_DATA_CACHE_MS = 30_000; export const SERVER_DATA_CACHE_MS = 30_000;
export const SERVER_DATA_SAVE_ERROR_EVENT = 'ftb:server-data-save-error'; export const SERVER_DATA_SAVE_ERROR_EVENT = 'ftb:server-data-save-error';
@@ -110,7 +111,20 @@ export async function loadServerData<T>(
return promise; return promise;
} }
export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> { export function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
const previousSave = serverDataSaveQueues.get(key) ?? Promise.resolve();
const save = previousSave.then(() => putServerData(key, value));
const queueTail = save.catch(() => undefined);
serverDataSaveQueues.set(key, queueTail);
void queueTail.then(() => {
if (serverDataSaveQueues.get(key) === queueTail) {
serverDataSaveQueues.delete(key);
}
});
return save;
}
async function putServerData<T>(key: ServerDataKey, value: T): Promise<void> {
const payload: { value: T; version?: string | null } = { value }; const payload: { value: T; version?: string | null } = { value };
if (serverDataVersions.has(key)) { if (serverDataVersions.has(key)) {
payload.version = serverDataVersions.get(key) ?? null; payload.version = serverDataVersions.get(key) ?? null;