perf(editor): one batched lib resolve, and ydoc file downloads
Measured on a real Leonardo load against the local platform stack: per-lib sync-stack POSTs : 174-200 -> 0 batch sync-stacks POSTs : 0 -> 1 (216 stacks in one request) file GETs negotiating ydoc: 0 -> 107 of 133 Lib resolve: syncedScopeLibsSource now resolves every stack up front through the shared paged batch client and feeds them to the per-lib sources, which skip their own POST on a hit. Best-effort throughout — a failure (older backend without the route, a network blip) leaves the map empty and every lib resolves exactly as before, so this can only remove requests, never break a load. A batched `null` is recorded as "backend says unresolvable" so a stale pin isn't then retried one-by-one. File downloads: fetchFileBytes sends Accept: application/x-pcbjam-ydoc and converts client-side with the converters shared already exports, moving the per-request materialize cost off the metered Worker. A backend that doesn't negotiate answers with text and the branch never fires; a ydoc we fail to convert re-fetches without negotiating rather than making the file undownloadable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
This commit is contained in:
parent
1ab46eacd7
commit
ad777bcbba
3 changed files with 110 additions and 16 deletions
|
|
@ -1 +1 @@
|
||||||
Subproject commit f8feab8f8ac66a6a0bf5fa7dd1e83bf0d24b7d8d
|
Subproject commit 0f839cda767b428fa5677c62591ad289c7260e19
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import {
|
import {
|
||||||
DEMO_SCOPE,
|
DEMO_SCOPE,
|
||||||
|
docToFile,
|
||||||
|
isYdocResponse,
|
||||||
type Project,
|
type Project,
|
||||||
type ProjectFile,
|
type ProjectFile,
|
||||||
type ProjectWithFiles,
|
type ProjectWithFiles,
|
||||||
|
YDOC_CONTENT_TYPE,
|
||||||
|
ydocUpdateToKicadDoc,
|
||||||
} from "@pcbjam/shared";
|
} from "@pcbjam/shared";
|
||||||
import {
|
import {
|
||||||
API_BASE_URL,
|
API_BASE_URL,
|
||||||
|
|
@ -83,11 +87,32 @@ function remoteProjectSource(): ProjectSource {
|
||||||
// credentials: session-cookie auth (see contract-client.ts). The static
|
// credentials: session-cookie auth (see contract-client.ts). The static
|
||||||
// gallery fetches below stay credential-less — a CDN's wildcard CORS
|
// gallery fetches below stay credential-less — a CDN's wildcard CORS
|
||||||
// rejects credentialed requests.
|
// rejects credentialed requests.
|
||||||
|
//
|
||||||
|
// Accept a ydoc (backend-wire.ts): for a file whose collab room has been
|
||||||
|
// opened, the backend would otherwise re-derive the KiCad text on EVERY
|
||||||
|
// download — a Y -> KicadDoc traversal plus an s-expr build that measured
|
||||||
|
// ~2.0 s on a ~2300-item board and exceeded the Workers CPU limit in
|
||||||
|
// production. We hold the same converters, so we ask for the raw update
|
||||||
|
// and do it here, where CPU is free. A backend that doesn't negotiate
|
||||||
|
// simply answers with text and the branch below never fires.
|
||||||
const res = await fetch(fileUrl(slug, relPath), {
|
const res = await fetch(fileUrl(slug, relPath), {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
headers: { accept: `${YDOC_CONTENT_TYPE}, */*` },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
|
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
|
||||||
return new Uint8Array(await res.arrayBuffer());
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||||
|
if (!isYdocResponse(res)) return bytes;
|
||||||
|
try {
|
||||||
|
return new TextEncoder().encode(docToFile(ydocUpdateToKicadDoc(bytes)));
|
||||||
|
} catch (err) {
|
||||||
|
// A ydoc we can't convert must not make the file undownloadable: retry
|
||||||
|
// without negotiating and let the backend materialize it as before.
|
||||||
|
const plain = await fetch(fileUrl(slug, relPath), { credentials: "include" });
|
||||||
|
if (!plain.ok) {
|
||||||
|
throw new Error(`download failed (${plain.status}): ${relPath} (${String(err)})`);
|
||||||
|
}
|
||||||
|
return new Uint8Array(await plain.arrayBuffer());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async uploadFileBytes(slug, relPath, bytes) {
|
async uploadFileBytes(slug, relPath, bytes) {
|
||||||
const name = relPath.split("/").pop() ?? relPath;
|
const name = relPath.split("/").pop() ?? relPath;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import { PROJECT_HEADER, SCOPE_HEADER, USER_HEADER } from "@pcbjam/shared";
|
import {
|
||||||
|
fetchSyncStacks,
|
||||||
|
PROJECT_HEADER,
|
||||||
|
SCOPE_HEADER,
|
||||||
|
SYNC_STACKS_BATCH_MAX,
|
||||||
|
type SyncStackDescriptor,
|
||||||
|
USER_HEADER,
|
||||||
|
} from "@pcbjam/shared";
|
||||||
import {
|
import {
|
||||||
peekNamespaces,
|
peekNamespaces,
|
||||||
SyncStack,
|
SyncStack,
|
||||||
|
|
@ -35,6 +42,13 @@ export function syncedLibsSource(
|
||||||
user?: string;
|
user?: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
log?: (msg: string) => void;
|
log?: (msg: string) => void;
|
||||||
|
/**
|
||||||
|
* Already-resolved stack for this lib, from the scope-level batch resolve
|
||||||
|
* (see syncedScopeLibsSource). When it answers, the per-lib POST is skipped
|
||||||
|
* entirely — that request is the one a board load makes 156-200 times.
|
||||||
|
* Returning undefined falls back to resolving this lib on its own.
|
||||||
|
*/
|
||||||
|
stackFor?: (libId: string) => SyncStackDescriptor | null | undefined;
|
||||||
/** Test seams (default: global fetch / IDB stores / real WebSockets). */
|
/** Test seams (default: global fetch / IDB stores / real WebSockets). */
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
storeFactory?: (namespace: string) => LayerStore;
|
storeFactory?: (namespace: string) => LayerStore;
|
||||||
|
|
@ -200,6 +214,7 @@ async function resolveAndOpen(
|
||||||
scope: string;
|
scope: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
|
stackFor?: (libId: string) => SyncStackDescriptor | null | undefined;
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
storeFactory?: (namespace: string) => LayerStore;
|
storeFactory?: (namespace: string) => LayerStore;
|
||||||
channelFactory?: ChannelFactory;
|
channelFactory?: ChannelFactory;
|
||||||
|
|
@ -212,18 +227,26 @@ async function resolveAndOpen(
|
||||||
...(opts.user ? { [USER_HEADER]: opts.user } : {}),
|
...(opts.user ? { [USER_HEADER]: opts.user } : {}),
|
||||||
...(opts.project ? { [PROJECT_HEADER]: opts.project } : {}),
|
...(opts.project ? { [PROJECT_HEADER]: opts.project } : {}),
|
||||||
};
|
};
|
||||||
const res = await baseFetch(
|
// Batch-resolved already? Then this lib costs no request at all. `null` is a
|
||||||
`${opts.apiBase}/api/scopes/${encodeURIComponent(opts.scope)}/libs/${encodeURIComponent(libId)}/sync-stack`,
|
// deliberate "the backend says this lib does not resolve" and must not be
|
||||||
// credentials: session-cookie auth, here and on every layer fetch below —
|
// retried per-lib; only `undefined` (not in the batch) falls through.
|
||||||
// live layers are membership-gated by the API worker per request.
|
const prefetched = opts.stackFor?.(libId);
|
||||||
{ method: "POST", headers, credentials: "include" },
|
if (prefetched === null) throw new Error(`sync-stack resolve failed: unknown lib ${libId}`);
|
||||||
);
|
let body: SyncStackDescriptor;
|
||||||
if (!res.ok) throw new Error(`sync-stack resolve failed: HTTP ${res.status}`);
|
if (prefetched) {
|
||||||
const body = (await res.json()) as {
|
body = prefetched;
|
||||||
lib: { id: string; name: string };
|
log(`[synced] resolved ${body.layers.length} layer(s) for lib ${libId} (batched)`);
|
||||||
layers: LayerDescriptor[];
|
} else {
|
||||||
};
|
const res = await baseFetch(
|
||||||
log(`[synced] resolved ${body.layers.length} layer(s) for lib ${libId}`);
|
`${opts.apiBase}/api/scopes/${encodeURIComponent(opts.scope)}/libs/${encodeURIComponent(libId)}/sync-stack`,
|
||||||
|
// credentials: session-cookie auth, here and on every layer fetch below —
|
||||||
|
// live layers are membership-gated by the API worker per request.
|
||||||
|
{ method: "POST", headers, credentials: "include" },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`sync-stack resolve failed: HTTP ${res.status}`);
|
||||||
|
body = (await res.json()) as SyncStackDescriptor;
|
||||||
|
log(`[synced] resolved ${body.layers.length} layer(s) for lib ${libId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// The descriptors carry no bearer token — live-layer HTTP ops authenticate
|
// The descriptors carry no bearer token — live-layer HTTP ops authenticate
|
||||||
// with the session cookie, so the stack's fetch must send credentials. The
|
// with the session cookie, so the stack's fetch must send credentials. The
|
||||||
|
|
@ -279,15 +302,58 @@ export function syncedScopeLibsSource(
|
||||||
},
|
},
|
||||||
): LibsSource {
|
): LibsSource {
|
||||||
const perLib = new Map<string, LibsSource>();
|
const perLib = new Map<string, LibsSource>();
|
||||||
|
// Stacks resolved in bulk by `prefetchStacks`. A hit means the per-lib source
|
||||||
|
// makes NO resolve request; `null` records "backend says unresolvable" so a
|
||||||
|
// stale pin isn't retried one-by-one. Misses simply fall back per-lib, which
|
||||||
|
// is also what happens against a backend predating the batch route.
|
||||||
|
const batchedStacks = new Map<string, SyncStackDescriptor | null>();
|
||||||
const forLib = (libId: string): LibsSource => {
|
const forLib = (libId: string): LibsSource => {
|
||||||
let src = perLib.get(libId);
|
let src = perLib.get(libId);
|
||||||
if (!src) {
|
if (!src) {
|
||||||
src = syncedLibsSource(libId, opts);
|
src = syncedLibsSource(libId, {
|
||||||
|
...opts,
|
||||||
|
stackFor: (id) => (batchedStacks.has(id) ? batchedStacks.get(id) : undefined),
|
||||||
|
});
|
||||||
perLib.set(libId, src);
|
perLib.set(libId, src);
|
||||||
}
|
}
|
||||||
return src;
|
return src;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve every lib's stack up front, in a handful of paged requests instead
|
||||||
|
* of one per lib. A board load resolves 156-200 libraries, and each per-lib
|
||||||
|
* POST is a separate serverless invocation before any library CONTENT is
|
||||||
|
* fetched — the dominant cost of the whole phase.
|
||||||
|
*
|
||||||
|
* Best-effort by design: any failure (older backend without the route, a
|
||||||
|
* network blip) leaves the map empty and every lib resolves the old way, so
|
||||||
|
* this can only ever remove requests, never break the load.
|
||||||
|
*/
|
||||||
|
async function prefetchStacks(libs: LibInfo[]): Promise<void> {
|
||||||
|
const missing = libs.map((l) => l.id).filter((id) => !batchedStacks.has(id));
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
[SCOPE_HEADER]: opts.scope,
|
||||||
|
...(opts.user ? { [USER_HEADER]: opts.user } : {}),
|
||||||
|
...(opts.project ? { [PROJECT_HEADER]: opts.project } : {}),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const resolved = await fetchSyncStacks({
|
||||||
|
url: `${opts.apiBase}/api/scopes/${encodeURIComponent(opts.scope)}/libs/sync-stacks`,
|
||||||
|
libIds: missing,
|
||||||
|
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
for (const [id, stack] of resolved) batchedStacks.set(id, stack);
|
||||||
|
opts.log?.(
|
||||||
|
`[synced] batch-resolved ${resolved.size} lib stack(s) in ` +
|
||||||
|
`${Math.ceil(missing.length / SYNC_STACKS_BATCH_MAX)} request(s)`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
opts.log?.(`[synced] batch resolve unavailable (${String(err)}) — falling back per-lib`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listLibs: (kind) => remote.listLibs(kind),
|
listLibs: (kind) => remote.listLibs(kind),
|
||||||
createLib: remote.createLib?.bind(remote),
|
createLib: remote.createLib?.bind(remote),
|
||||||
|
|
@ -341,6 +407,9 @@ export function syncedScopeLibsSource(
|
||||||
const total = libs.length;
|
const total = libs.length;
|
||||||
let done = 0;
|
let done = 0;
|
||||||
presyncOpts?.onProgress?.({ done, total, current: "libraries" });
|
presyncOpts?.onProgress?.({ done, total, current: "libraries" });
|
||||||
|
// One batched resolve for the whole set before the per-lib fan-out, so
|
||||||
|
// the workers below open stacks without a request each.
|
||||||
|
if (!presyncOpts?.signal?.aborted) await prefetchStacks(libs);
|
||||||
const concurrency = presyncOpts?.concurrency ?? 8;
|
const concurrency = presyncOpts?.concurrency ?? 8;
|
||||||
const queue = [...libs];
|
const queue = [...libs];
|
||||||
const worker = async (): Promise<void> => {
|
const worker = async (): Promise<void> => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue