fix(data): 串行化 AppData 同 key 保存
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user