From 219b94cfeeb7c45b465102ff42513cdfb1cbd9ca Mon Sep 17 00:00:00 2001 From: SayantanCode Date: Sun, 26 Jul 2026 20:26:49 +0530 Subject: [PATCH] Bound per-endpoint DB concurrency and fix a false-empty UI flash on large APIs Found while running vayo scan/export and the docs UI against a real, 600+ endpoint production API: the sequential per-endpoint DB round-trips in scan/export/diff (and the identical logic in @vayo-hq/server's GET /api/spec and GET /api/diff) took minutes at that scale, easily mistaken for a hang. Added mapWithConcurrency to @vayo-hq/schema-engine and switched every one of these loops to it. Separately, the docs UI rendered "No endpoints yet"/"No endpoints captured yet" immediately on load, before the first fetch resolved - indistinguishable from a project with nothing captured, and a real, visible flash on an API slow enough to hit the issue above. DocsApp now tracks the initial-load state and shows a loading message instead. --- .../bounded-concurrency-and-loading-state.md | 27 ++++++++++++++ packages/cli/src/commands/diff.test.ts | 5 ++- packages/cli/src/commands/diff.ts | 11 ++++-- packages/cli/src/commands/export.test.ts | 5 ++- packages/cli/src/commands/export.ts | 21 +++++++---- packages/cli/src/commands/scan.ts | 13 +++++-- packages/schema-engine/src/index.test.ts | 37 +++++++++++++++++++ packages/schema-engine/src/index.ts | 29 +++++++++++++++ packages/server/src/routes/versions.ts | 28 ++++++++------ packages/ui/src/DocsApp.tsx | 16 ++++++-- packages/ui/src/components/FolderTree.tsx | 13 ++++++- packages/ui/src/components/FullDocView.tsx | 9 ++++- 12 files changed, 183 insertions(+), 31 deletions(-) create mode 100644 .changeset/bounded-concurrency-and-loading-state.md diff --git a/.changeset/bounded-concurrency-and-loading-state.md b/.changeset/bounded-concurrency-and-loading-state.md new file mode 100644 index 0000000..0662418 --- /dev/null +++ b/.changeset/bounded-concurrency-and-loading-state.md @@ -0,0 +1,27 @@ +--- +"@vayo-hq/schema-engine": patch +"@vayo-hq/cli": patch +"@vayo-hq/server": patch +"@vayo-hq/ui": patch +--- + +Fixed two real issues found while running `vayo scan`/`vayo export`/the docs +UI against a real, large production API (600+ endpoints): + +- `vayo scan`'s route-merge loop and `vayo export`/`vayo diff`'s per-endpoint + override/example/test-script lookups (and the identical logic in + `@vayo-hq/server`'s `GET /api/spec`/`GET /api/diff`) ran one DB round-trip + at a time in a sequential `for...of` loop — safe, but measured taking + minutes against a real remote MongoDB cluster at this scale, easily + mistaken for a hang. Added `mapWithConcurrency` to `@vayo-hq/schema-engine` + (bounded-concurrency `Promise.all`, 20 at a time — fast without firing + hundreds of simultaneous connections at the database) and switched every + one of these call sites to it. +- The docs UI's sidebar and main pane rendered "No endpoints yet"/"No + endpoints captured yet" immediately on load, before the first spec/folders + fetch had actually resolved — indistinguishable from a project with + nothing captured. A large real API can take several real seconds to + answer (see above), so this was a visible false-empty flash every time. + `DocsApp` now tracks whether the initial fetch is still pending and shows + "Loading endpoints…" instead, in the sidebar (`FolderTree`), the main pane, + and Full Docs mode (`FullDocView`). diff --git a/packages/cli/src/commands/diff.test.ts b/packages/cli/src/commands/diff.test.ts index ce96069..ead86f4 100644 --- a/packages/cli/src/commands/diff.test.ts +++ b/packages/cli/src/commands/diff.test.ts @@ -11,7 +11,10 @@ const diffSpecs = vi.fn(); vi.mock("@vayo-hq/db-mongo", () => ({ createAdapter: () => ({ listApiVersions, listEndpoints, listOverrides }), })); -vi.mock("@vayo-hq/schema-engine", () => ({ resolveEndpoint: (...args: unknown[]) => resolveEndpoint(...args) })); +vi.mock("@vayo-hq/schema-engine", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveEndpoint: (...args: unknown[]) => resolveEndpoint(...args) }; +}); vi.mock("@vayo-hq/openapi-compiler", () => ({ compile: (...args: unknown[]) => compile(...args), diffSpecs: (...args: unknown[]) => diffSpecs(...args), diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index be7e13c..358981b 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -4,11 +4,16 @@ // (docs/07-api-versioning.md). import type { ResolvedEndpoint } from "@vayo-hq/types"; -import { resolveEndpoint } from "@vayo-hq/schema-engine"; +import { resolveEndpoint, mapWithConcurrency } from "@vayo-hq/schema-engine"; import { compile, diffSpecs } from "@vayo-hq/openapi-compiler"; import { createAdapter } from "@vayo-hq/db-mongo"; import { requireMongoUri } from "../config.js"; +/** See export.ts's identical constant — bounded concurrency instead of a + * plain `Promise.all` so a real, large API's override lookups don't + * overwhelm the database's own connection pool. */ +const FETCH_CONCURRENCY = 20; + export interface DiffOptions { failOnBreaking?: boolean; } @@ -23,8 +28,8 @@ export async function diffCommand(from: string, to: string, options: DiffOptions async function compileVersion(version: string) { const endpoints = await db.listEndpoints(version); - const resolved: ResolvedEndpoint[] = await Promise.all( - endpoints.map(async (endpoint) => resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId))), + const resolved: ResolvedEndpoint[] = await mapWithConcurrency(endpoints, FETCH_CONCURRENCY, async (endpoint) => + resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId)), ); return compile(resolved, version); } diff --git a/packages/cli/src/commands/export.test.ts b/packages/cli/src/commands/export.test.ts index 8df27bb..a76952c 100644 --- a/packages/cli/src/commands/export.test.ts +++ b/packages/cli/src/commands/export.test.ts @@ -26,7 +26,10 @@ vi.mock("@vayo-hq/db-mongo", () => ({ listEnvironments, }), })); -vi.mock("@vayo-hq/schema-engine", () => ({ resolveEndpoint: (...args: unknown[]) => resolveEndpoint(...args) })); +vi.mock("@vayo-hq/schema-engine", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveEndpoint: (...args: unknown[]) => resolveEndpoint(...args) }; +}); vi.mock("@vayo-hq/openapi-compiler", () => ({ compile: (...args: unknown[]) => compile(...args) })); vi.mock("@vayo-hq/server", () => ({ compilePostmanCollection: (...args: unknown[]) => compilePostmanCollection(...args) })); vi.mock("../config.js", () => ({ requireMongoUri: () => "mongodb://localhost:27017/vayo" })); diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 74a6664..dc67875 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -6,12 +6,19 @@ import { writeFileSync } from "node:fs"; import path from "node:path"; import type { ExampleDoc, ResolvedEndpoint, TestScriptDoc } from "@vayo-hq/types"; -import { resolveEndpoint } from "@vayo-hq/schema-engine"; +import { resolveEndpoint, mapWithConcurrency } from "@vayo-hq/schema-engine"; import { compile } from "@vayo-hq/openapi-compiler"; import { compilePostmanCollection } from "@vayo-hq/server"; import { createAdapter } from "@vayo-hq/db-mongo"; import { requireMongoUri } from "../config.js"; +/** How many per-endpoint reads (examples, test scripts) run at once. A real + * API can have hundreds of endpoints; sequential (one-at-a-time) awaiting + * measured taking minutes against a real remote MongoDB cluster on a 600+ + * endpoint production API — bounded concurrency instead of a plain + * `Promise.all` to avoid overwhelming the database's own connection pool. */ +const FETCH_CONCURRENCY = 20; + export interface ExportOptions { version: string; format: "openapi" | "postman"; @@ -23,8 +30,8 @@ export async function exportCommand(options: ExportOptions): Promise { const db = createAdapter(mongoUri); const endpoints = await db.listEndpoints(options.version); - const resolved: ResolvedEndpoint[] = await Promise.all( - endpoints.map(async (endpoint) => resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId))), + const resolved: ResolvedEndpoint[] = await mapWithConcurrency(endpoints, FETCH_CONCURRENCY, async (endpoint) => + resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId)), ); // The equivalent of swagger-jsdoc's static options.definition.info/servers @@ -39,22 +46,22 @@ export async function exportCommand(options: ExportOptions): Promise { // — shared by both export formats so a team's saved Try It Now responses // show up in the OpenAPI export exactly as they already did in Postman's. const pinnedExamples = new Map(); - for (const endpoint of resolved) { + await mapWithConcurrency(resolved, FETCH_CONCURRENCY, async (endpoint) => { const pinned = (await db.listExamples(endpoint.vayoId)).filter((e) => e.pinned); if (pinned.length > 0) pinnedExamples.set(endpoint.vayoId, pinned); - } + }); if (options.format === "postman") { const folders = await db.listFolders(options.version); const placements = new Map(); const testScripts = new Map(); - for (const endpoint of resolved) { + await mapWithConcurrency(resolved, FETCH_CONCURRENCY, async (endpoint) => { const folderId = (endpoint as unknown as { folderId?: string | null }).folderId ?? null; placements.set(endpoint.vayoId, folderId); const script = await db.getTestScript(endpoint.vayoId); if (script) testScripts.set(endpoint.vayoId, script); - } + }); const collection = compilePostmanCollection( `${settings.title} (${options.version})`, resolved, diff --git a/packages/cli/src/commands/scan.ts b/packages/cli/src/commands/scan.ts index b6ad548..4dbc9cd 100644 --- a/packages/cli/src/commands/scan.ts +++ b/packages/cli/src/commands/scan.ts @@ -4,9 +4,16 @@ import { scanProject } from "@vayo-hq/ast"; import { createAdapter } from "@vayo-hq/db-mongo"; -import { resolveVersion } from "@vayo-hq/schema-engine"; +import { resolveVersion, mapWithConcurrency } from "@vayo-hq/schema-engine"; import { loadConfig, requireMongoUri } from "../config.js"; +/** How many `upsertStaticResult` writes run at once. A real API can have + * hundreds of routes; sequential (one-at-a-time) awaiting measured taking + * minutes against a real remote MongoDB cluster on a 600+ route production + * API — bounded concurrency instead of a plain `Promise.all` to avoid + * overwhelming the database's own connection pool. */ +const UPSERT_CONCURRENCY = 20; + export interface ScanOptions { config?: string; } @@ -25,7 +32,7 @@ export async function scanCommand(options: ScanOptions): Promise { const groups = new Set(); const versionsTouched = new Set(); const confirmedVayoIdsByVersion = new Map(); - for (const route of result.routes) { + await mapWithConcurrency(result.routes, UPSERT_CONCURRENCY, async (route) => { const version = resolveVersion(route.pathTemplate, configuredVersions); const saved = await db.upsertStaticResult(route, version); groups.add(route.group); @@ -36,7 +43,7 @@ export async function scanCommand(options: ScanOptions): Promise { console.log( `merged ${route.method} ${route.pathTemplate} (${version}) — scopes=${JSON.stringify(route.scopes)} middlewareChain=${JSON.stringify(route.middlewareChain)}`, ); - } + }); console.log(`\nvayo: scanned ${result.routes.length} route(s) across ${groups.size} group(s).`); diff --git a/packages/schema-engine/src/index.test.ts b/packages/schema-engine/src/index.test.ts index a2855a4..6484b9e 100644 --- a/packages/schema-engine/src/index.test.ts +++ b/packages/schema-engine/src/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { CapturedSample, EndpointDoc, OverrideDoc } from "@vayo-hq/types"; import { detectSchemaChange, + mapWithConcurrency, mergeCapturedSample, mergeStaticResult, resolveAuthRequired, @@ -757,3 +758,39 @@ describe("resolveEndpoint", () => { expect((resolved.responseSchemas["200"] as any).properties.newField).toBeDefined(); }); }); + +describe("mapWithConcurrency", () => { + it("maps every item and preserves result order regardless of completion order", async () => { + const delays = [30, 10, 20, 0, 15]; + const result = await mapWithConcurrency(delays, 3, async (delay, index) => { + await new Promise((resolve) => setTimeout(resolve, delay)); + return index * 2; + }); + expect(result).toEqual([0, 2, 4, 6, 8]); + }); + + it("never runs more than `concurrency` items at once", async () => { + let inFlight = 0; + let maxInFlight = 0; + const items = Array.from({ length: 20 }, (_, i) => i); + + await mapWithConcurrency(items, 4, async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight--; + }); + + expect(maxInFlight).toBeLessThanOrEqual(4); + }); + + it("handles an empty list without error", async () => { + const result = await mapWithConcurrency([], 10, async (x) => x); + expect(result).toEqual([]); + }); + + it("handles concurrency greater than the item count", async () => { + const result = await mapWithConcurrency([1, 2, 3], 100, async (x) => x * 10); + expect(result).toEqual([10, 20, 30]); + }); +}); diff --git a/packages/schema-engine/src/index.ts b/packages/schema-engine/src/index.ts index 180b636..3b11791 100644 --- a/packages/schema-engine/src/index.ts +++ b/packages/schema-engine/src/index.ts @@ -541,3 +541,32 @@ export function resolveEndpoint( return { ...(result as unknown as EndpointDoc), overridden }; } + +/** Bounded-concurrency version of `Promise.all`, used everywhere an + * endpoint list gets resolved (GET /api/spec, /api/diff, `vayo export`, + * `vayo diff`) via one DB round-trip per endpoint (`listOverrides`, + * `listExamples`, etc.). A real, large API can have hundreds of endpoints; + * running that many round-trips fully sequentially is safe but measured + * taking minutes against a real remote MongoDB cluster, and firing them all + * at once via a plain `Promise.all` risks overwhelming the database's own + * connection pool. This runs a fixed number of workers, each pulling the + * next item off a shared index, splitting the difference. */ +export async function mapWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < items.length) { + const index = nextIndex++; + results[index] = await fn(items[index]!, index); + } + } + + const workerCount = Math.min(concurrency, items.length); + await Promise.all(Array.from({ length: workerCount }, worker)); + return results; +} diff --git a/packages/server/src/routes/versions.ts b/packages/server/src/routes/versions.ts index 4e9489f..2ab5ca8 100644 --- a/packages/server/src/routes/versions.ts +++ b/packages/server/src/routes/versions.ts @@ -3,13 +3,21 @@ // versions. import { Router } from "express"; import { z } from "zod"; -import { resolveEndpoint } from "@vayo-hq/schema-engine"; +import { resolveEndpoint, mapWithConcurrency } from "@vayo-hq/schema-engine"; import { compile, diffSpecs, type CompileOptions } from "@vayo-hq/openapi-compiler"; import type { ExampleDoc, ResolvedEndpoint, VayoDbAdapter } from "@vayo-hq/types"; import { requireRole, type VayoAuthedRequest } from "../auth-middleware.js"; import { autoCatchAsyncErrors } from "../error-handling.js"; import type { RouteDeps } from "../server-deps.js"; +/** How many per-endpoint reads (overrides, examples) run at once. A real API + * can have hundreds of endpoints; a plain `Promise.all` firing that many + * simultaneous DB round-trips risks overwhelming the database's own + * connection pool — this is what the docs UI itself waits on every load, so + * a slow or stalled `/api/spec` response here is a real, visible loading + * delay, not just a CLI-only concern. */ +const FETCH_CONCURRENCY = 20; + /** `compile()`'s `title`/`description`/`servers`/pinned examples, sourced * from `vayo_settings`/`vayo_environments`/`vayo_examples` * (docs/03-data-model.md) — the equivalent of swagger-jsdoc's static @@ -26,12 +34,10 @@ async function compileOptionsFromDb(db: VayoDbAdapter, resolved: ResolvedEndpoin .map((env) => ({ url: env.variables.baseUrl!, description: env.name })); const pinnedExamplesByVayoId = new Map(); - await Promise.all( - resolved.map(async (endpoint) => { - const pinned = (await db.listExamples(endpoint.vayoId)).filter((example) => example.pinned); - if (pinned.length > 0) pinnedExamplesByVayoId.set(endpoint.vayoId, pinned); - }), - ); + await mapWithConcurrency(resolved, FETCH_CONCURRENCY, async (endpoint) => { + const pinned = (await db.listExamples(endpoint.vayoId)).filter((example) => example.pinned); + if (pinned.length > 0) pinnedExamplesByVayoId.set(endpoint.vayoId, pinned); + }); const contact = settings.contactName || settings.contactEmail || settings.contactUrl @@ -68,8 +74,8 @@ export function createVersionsRouter({ db, io }: RouteDeps): Router { router.get("/api/spec", requireRole("viewer"), async (req, res) => { const version = typeof req.query.version === "string" ? req.query.version : "v1"; const endpoints = await db.listEndpoints(version); - const resolved = await Promise.all( - endpoints.map(async (endpoint) => resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId))), + const resolved = await mapWithConcurrency(endpoints, FETCH_CONCURRENCY, async (endpoint) => + resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId)), ); try { const doc = await compile(resolved, version, await compileOptionsFromDb(db, resolved)); @@ -144,8 +150,8 @@ export function createVersionsRouter({ db, io }: RouteDeps): Router { async function compileVersion(version: string) { const endpoints = await db.listEndpoints(version); - const resolved = await Promise.all( - endpoints.map(async (endpoint) => resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId))), + const resolved = await mapWithConcurrency(endpoints, FETCH_CONCURRENCY, async (endpoint) => + resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId)), ); return compile(resolved, version); } diff --git a/packages/ui/src/DocsApp.tsx b/packages/ui/src/DocsApp.tsx index 7654153..76ea93e 100644 --- a/packages/ui/src/DocsApp.tsx +++ b/packages/ui/src/DocsApp.tsx @@ -107,6 +107,12 @@ export function DocsApp({ const [me, setMe] = useState(null); const [doc, setDoc] = useState(null); const [folders, setFolders] = useState([]); + // True until the first spec/folders fetch resolves — an empty `folders` + // during that window means "haven't heard back yet," not "there's + // nothing here." A large real API can take several real seconds to + // answer, and without this the sidebar/main pane flash "No endpoints + // yet" the whole time, then swap to the real content once it arrives. + const [initialLoadPending, setInitialLoadPending] = useState(true); const [selectedVayoId, setSelectedVayoId] = useState(null); const [activeTab, setActiveTab] = useState("details"); // "endpoint" = today's one-at-a-time workspace (Details/Flowmap/History/ @@ -297,7 +303,8 @@ export function DocsApp({ if (!token) return; refetchSpecAndFolders() .then(() => setError(null)) - .catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load spec")); + .catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load spec")) + .finally(() => setInitialLoadPending(false)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [config, token, activeVersion]); @@ -702,6 +709,7 @@ export function DocsApp({ onMoveToFolder={canEdit ? handleMoveToFolder : noop} onAutoOrganize={canEdit ? handleAutoOrganize : noop} onBlockedMove={setError} + isLoading={initialLoadPending} />
{error &&
{error}
} @@ -729,12 +737,14 @@ export function DocsApp({ onTryIt={tryItFromFullDoc} onSectionInView={setSelectedVayoId} settings={settings} + isLoading={initialLoadPending} /> )} {viewMode === "endpoint" && !selected && (
- No endpoints captured yet — hit some routes on your API, or create one manually, and they'll show up - here. + {initialLoadPending + ? "Loading endpoints…" + : "No endpoints captured yet — hit some routes on your API, or create one manually, and they'll show up here."}
)} {viewMode === "endpoint" && selected && ( diff --git a/packages/ui/src/components/FolderTree.tsx b/packages/ui/src/components/FolderTree.tsx index c7d7414..1bf1dce 100644 --- a/packages/ui/src/components/FolderTree.tsx +++ b/packages/ui/src/components/FolderTree.tsx @@ -86,6 +86,13 @@ interface FolderTreeProps { * any other failed action already uses), rather than the drag silently * doing nothing with no explanation. */ onBlockedMove: (message: string) => void; + /** True until the first spec/folders fetch resolves — an empty `tree` + * during that window means "haven't heard back yet," not "there's + * nothing here," so this keeps the sidebar from flashing "No endpoints + * yet" for a real project that's just slow to answer (a large API can + * take several real seconds), before the actual data arrives moments + * later and replaces it. */ + isLoading?: boolean; } function nodeIdentity(node: TreeNode): { kind: "folder" | "endpoint"; id: string; label: string; method?: string } { @@ -546,7 +553,11 @@ export function FolderTree(props: FolderTreeProps): JSX.Element { - {rows.length === 0 &&

No endpoints yet.

} + {rows.length === 0 && (props.isLoading ? ( +

Loading endpoints…

+ ) : ( +

No endpoints yet.

+ ))} {props.canEdit && (