diff --git a/.changeset/parallel-deploy-rpcs.md b/.changeset/parallel-deploy-rpcs.md new file mode 100644 index 0000000000..9ab0901f50 --- /dev/null +++ b/.changeset/parallel-deploy-rpcs.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Speed up deploy by running SDK version detection and function uploads concurrently diff --git a/packages/sdk/docs/cli-reference.md b/packages/sdk/docs/cli-reference.md index f63ce98480..0b12607d57 100644 --- a/packages/sdk/docs/cli-reference.md +++ b/packages/sdk/docs/cli-reference.md @@ -65,27 +65,27 @@ tailor-sdk deploy --env-file .env --env-file .env.production You can use environment variables to configure workspace and authentication: -| Variable | Description | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `TAILOR_PLATFORM_WORKSPACE_ID` | Workspace ID for deployment commands | -| `TAILOR_PLATFORM_ORGANIZATION_ID` | Organization ID for organization commands | -| `TAILOR_PLATFORM_FOLDER_ID` | Folder ID for folder commands | -| `TAILOR_PLATFORM_TOKEN` | Authentication token (alternative to `login`) | -| `TAILOR_TOKEN` | **Deprecated.** Use `TAILOR_PLATFORM_TOKEN` instead | -| `TAILOR_PLATFORM_PROFILE` | Workspace profile name | -| `TAILOR_PLATFORM_SDK_CONFIG_PATH` | Path to SDK config file | -| `TAILOR_PLATFORM_SDK_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | -| `TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID` | Client ID for `login --machine-user` | -| `TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET` | Client secret for `login --machine-user` | -| `TAILOR_PLATFORM_MACHINE_USER_NAME` | Default machine user name for `query`, `workflow start`, `function test-run`, `machineuser token` | -| `TAILOR_PLATFORM_URL` | Platform API base URL. Saved into profiles created with `profile create --platform-url` | -| `TAILOR_PLATFORM_OAUTH2_CLIENT_ID` | OAuth2 client ID for user login. Saved into profiles created with `profile create --oauth2-client-id` | -| `TAILOR_PLATFORM_CONSOLE_URL` | Console base URL. Saved into profiles created with `profile create --console-url` | -| `TAILOR_BUNDLE_CONCURRENCY` | Max concurrent bundle workers for `deploy` (resolvers/executors/workflows). Defaults to CPU count | -| `TAILOR_APPLY_CONCURRENCY` | Max concurrent unary platform RPCs during `apply`/`deploy` (streaming uploads are not gated). Defaults to 16 | -| `VISUAL` / `EDITOR` | Preferred editor for commands that open files (e.g., `vim`, `code`, `nano`) | -| `TAILOR_CRASH_REPORTS_LOCAL` | Local crash log writing: `on` (default) or `off` | -| `TAILOR_CRASH_REPORTS_REMOTE` | Automatic crash report submission: `off` (default) or `on` | +| Variable | Description | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `TAILOR_PLATFORM_WORKSPACE_ID` | Workspace ID for deployment commands | +| `TAILOR_PLATFORM_ORGANIZATION_ID` | Organization ID for organization commands | +| `TAILOR_PLATFORM_FOLDER_ID` | Folder ID for folder commands | +| `TAILOR_PLATFORM_TOKEN` | Authentication token (alternative to `login`) | +| `TAILOR_TOKEN` | **Deprecated.** Use `TAILOR_PLATFORM_TOKEN` instead | +| `TAILOR_PLATFORM_PROFILE` | Workspace profile name | +| `TAILOR_PLATFORM_SDK_CONFIG_PATH` | Path to SDK config file | +| `TAILOR_PLATFORM_SDK_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | +| `TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID` | Client ID for `login --machine-user` | +| `TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET` | Client secret for `login --machine-user` | +| `TAILOR_PLATFORM_MACHINE_USER_NAME` | Default machine user name for `query`, `workflow start`, `function test-run`, `machineuser token` | +| `TAILOR_PLATFORM_URL` | Platform API base URL. Saved into profiles created with `profile create --platform-url` | +| `TAILOR_PLATFORM_OAUTH2_CLIENT_ID` | OAuth2 client ID for user login. Saved into profiles created with `profile create --oauth2-client-id` | +| `TAILOR_PLATFORM_CONSOLE_URL` | Console base URL. Saved into profiles created with `profile create --console-url` | +| `TAILOR_BUNDLE_CONCURRENCY` | Max concurrent bundle workers for `deploy` (resolvers/executors/workflows). Defaults to CPU count | +| `TAILOR_APPLY_CONCURRENCY` | Max concurrent platform RPCs during `apply`/`deploy`. Defaults to 16 | +| `VISUAL` / `EDITOR` | Preferred editor for commands that open files (e.g., `vim`, `code`, `nano`) | +| `TAILOR_CRASH_REPORTS_LOCAL` | Local crash log writing: `on` (default) or `off` | +| `TAILOR_CRASH_REPORTS_REMOTE` | Automatic crash report submission: `off` (default) or `on` | ### Authentication Token Priority diff --git a/packages/sdk/docs/cli-reference.template.md b/packages/sdk/docs/cli-reference.template.md index af7582f7ad..a5619ed2f9 100644 --- a/packages/sdk/docs/cli-reference.template.md +++ b/packages/sdk/docs/cli-reference.template.md @@ -58,27 +58,27 @@ tailor-sdk deploy --env-file .env --env-file .env.production You can use environment variables to configure workspace and authentication: -| Variable | Description | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `TAILOR_PLATFORM_WORKSPACE_ID` | Workspace ID for deployment commands | -| `TAILOR_PLATFORM_ORGANIZATION_ID` | Organization ID for organization commands | -| `TAILOR_PLATFORM_FOLDER_ID` | Folder ID for folder commands | -| `TAILOR_PLATFORM_TOKEN` | Authentication token (alternative to `login`) | -| `TAILOR_TOKEN` | **Deprecated.** Use `TAILOR_PLATFORM_TOKEN` instead | -| `TAILOR_PLATFORM_PROFILE` | Workspace profile name | -| `TAILOR_PLATFORM_SDK_CONFIG_PATH` | Path to SDK config file | -| `TAILOR_PLATFORM_SDK_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | -| `TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID` | Client ID for `login --machine-user` | -| `TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET` | Client secret for `login --machine-user` | -| `TAILOR_PLATFORM_MACHINE_USER_NAME` | Default machine user name for `query`, `workflow start`, `function test-run`, `machineuser token` | -| `TAILOR_PLATFORM_URL` | Platform API base URL. Saved into profiles created with `profile create --platform-url` | -| `TAILOR_PLATFORM_OAUTH2_CLIENT_ID` | OAuth2 client ID for user login. Saved into profiles created with `profile create --oauth2-client-id` | -| `TAILOR_PLATFORM_CONSOLE_URL` | Console base URL. Saved into profiles created with `profile create --console-url` | -| `TAILOR_BUNDLE_CONCURRENCY` | Max concurrent bundle workers for `deploy` (resolvers/executors/workflows). Defaults to CPU count | -| `TAILOR_APPLY_CONCURRENCY` | Max concurrent unary platform RPCs during `apply`/`deploy` (streaming uploads are not gated). Defaults to 16 | -| `VISUAL` / `EDITOR` | Preferred editor for commands that open files (e.g., `vim`, `code`, `nano`) | -| `TAILOR_CRASH_REPORTS_LOCAL` | Local crash log writing: `on` (default) or `off` | -| `TAILOR_CRASH_REPORTS_REMOTE` | Automatic crash report submission: `off` (default) or `on` | +| Variable | Description | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `TAILOR_PLATFORM_WORKSPACE_ID` | Workspace ID for deployment commands | +| `TAILOR_PLATFORM_ORGANIZATION_ID` | Organization ID for organization commands | +| `TAILOR_PLATFORM_FOLDER_ID` | Folder ID for folder commands | +| `TAILOR_PLATFORM_TOKEN` | Authentication token (alternative to `login`) | +| `TAILOR_TOKEN` | **Deprecated.** Use `TAILOR_PLATFORM_TOKEN` instead | +| `TAILOR_PLATFORM_PROFILE` | Workspace profile name | +| `TAILOR_PLATFORM_SDK_CONFIG_PATH` | Path to SDK config file | +| `TAILOR_PLATFORM_SDK_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | +| `TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID` | Client ID for `login --machine-user` | +| `TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET` | Client secret for `login --machine-user` | +| `TAILOR_PLATFORM_MACHINE_USER_NAME` | Default machine user name for `query`, `workflow start`, `function test-run`, `machineuser token` | +| `TAILOR_PLATFORM_URL` | Platform API base URL. Saved into profiles created with `profile create --platform-url` | +| `TAILOR_PLATFORM_OAUTH2_CLIENT_ID` | OAuth2 client ID for user login. Saved into profiles created with `profile create --oauth2-client-id` | +| `TAILOR_PLATFORM_CONSOLE_URL` | Console base URL. Saved into profiles created with `profile create --console-url` | +| `TAILOR_BUNDLE_CONCURRENCY` | Max concurrent bundle workers for `deploy` (resolvers/executors/workflows). Defaults to CPU count | +| `TAILOR_APPLY_CONCURRENCY` | Max concurrent platform RPCs during `apply`/`deploy`. Defaults to 16 | +| `VISUAL` / `EDITOR` | Preferred editor for commands that open files (e.g., `vim`, `code`, `nano`) | +| `TAILOR_CRASH_REPORTS_LOCAL` | Local crash log writing: `on` (default) or `off` | +| `TAILOR_CRASH_REPORTS_REMOTE` | Automatic crash report submission: `off` (default) or `on` | ### Authentication Token Priority diff --git a/packages/sdk/src/cli/commands/deploy/deploy.test.ts b/packages/sdk/src/cli/commands/deploy/deploy.test.ts index 2ea62cef53..493fdb2a1b 100644 --- a/packages/sdk/src/cli/commands/deploy/deploy.test.ts +++ b/packages/sdk/src/cli/commands/deploy/deploy.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { logger } from "#/cli/shared/logger"; +import { createConcurrencyProbe } from "#/cli/shared/test-helpers/concurrency-probe"; import { jsonMode } from "#/cli/shared/test-helpers/json-mode"; import { silenceLogger } from "#/cli/shared/test-helpers/silence-logger"; import { createChangeSet } from "./change-set"; @@ -18,6 +19,7 @@ import { planDeploymentTargets, printDeploymentPlans, printPlanResults, + shouldForceApplyAll, summarizePlanResults, } from "./deploy"; import type { PlannedDeployment } from "./apply-phases"; @@ -144,6 +146,88 @@ function plannedDeployment(name: string, results: PlanResults): PlannedDeploymen } as unknown as PlannedDeployment; } +describe("shouldForceApplyAll", () => { + type Client = Parameters[0]; + type App = Parameters[2]; + + function minimalApplication(): App { + return { + name: "test-app", + id: "test-app-id", + subgraphs: [], + staticWebsiteServices: [], + aiGatewayServices: [], + resolverServices: [], + idpServices: [], + authService: undefined, + executorService: undefined, + workflowService: undefined, + tailorDBServices: [], + secrets: [], + } as unknown as App; + } + + test("fetches candidate metadata concurrently", async () => { + const probe = createConcurrencyProbe(); + const getMetadata = vi.fn().mockImplementation(async () => { + await probe.run(); + return { metadata: { labels: {} } }; + }); + const client = { getMetadata } as unknown as Client; + + const result = await shouldForceApplyAll(client, "test-workspace", minimalApplication(), [ + { name: "fn-a" }, + { name: "fn-b" }, + { name: "fn-c" }, + ]); + + expect(result).toBe(false); + expect(getMetadata).toHaveBeenCalledTimes(3); + expect(probe.maxInFlight()).toBeGreaterThan(1); + }); + + test("returns true when an owned resource has a different sdk-version", async () => { + const getMetadata = vi.fn().mockResolvedValue({ + metadata: { labels: { "sdk-name": "test-app", "sdk-version": "v0-0-0-other" } }, + }); + const client = { getMetadata } as unknown as Client; + + const result = await shouldForceApplyAll(client, "test-workspace", minimalApplication(), [ + { name: "fn-a" }, + ]); + + expect(result).toBe(true); + }); + + test("prefers a detected sdk-version mismatch over an unrelated fetch failure", async () => { + const getMetadata = vi.fn().mockImplementation(({ trn }: { trn: string }) => { + if (trn.endsWith(":fn-a")) { + return Promise.resolve({ + metadata: { labels: { "sdk-name": "test-app", "sdk-version": "v0-0-0-other" } }, + }); + } + return Promise.reject(new Error("unavailable")); + }); + const client = { getMetadata } as unknown as Client; + + const result = await shouldForceApplyAll(client, "test-workspace", minimalApplication(), [ + { name: "fn-a" }, + { name: "fn-b" }, + ]); + + expect(result).toBe(true); + }); + + test("propagates a fetch failure when no mismatch was detected", async () => { + const getMetadata = vi.fn().mockRejectedValue(new Error("unavailable")); + const client = { getMetadata } as unknown as Client; + + await expect( + shouldForceApplyAll(client, "test-workspace", minimalApplication(), [{ name: "fn-a" }]), + ).rejects.toThrow("unavailable"); + }); +}); + describe("summarizePlanResults", () => { test("counts display entries and service actions", () => { const displayEntries: GroupedDisplayEntry[] = [ diff --git a/packages/sdk/src/cli/commands/deploy/deploy.ts b/packages/sdk/src/cli/commands/deploy/deploy.ts index f57fe07432..24f6f4b1fd 100644 --- a/packages/sdk/src/cli/commands/deploy/deploy.ts +++ b/packages/sdk/src/cli/commands/deploy/deploy.ts @@ -329,7 +329,16 @@ export function collectVisibleResolverNamespaces( }); } -async function shouldForceApplyAll( +/** + * Detect whether any resource owned by this application was last applied by a + * different SDK version, in which case every resource is re-applied. + * @param client - Operator client instance + * @param workspaceId - Workspace ID + * @param application - Application being deployed + * @param functionEntries - Function registry entries of the application + * @returns True when an owned resource carries a different sdk-version label + */ +export async function shouldForceApplyAll( client: OperatorClient, workspaceId: string, application: Readonly, @@ -378,19 +387,28 @@ async function shouldForceApplyAll( candidateTrns.add(resourceTrn(workspaceId, "function_registry", entry.name)); }); - for (const trn of candidateTrns) { - const metadata = await getOrNull(async () => { - const { metadata } = await client.getMetadata({ trn }); - return metadata; - }); - if (metadata?.labels[sdkNameLabelKey] !== application.name) { - continue; - } - if (!hasMatchingSdkVersion(metadata.labels, desiredLabels)) { - return true; - } - } + const results = await Promise.allSettled( + [...candidateTrns].map((trn) => + getOrNull(async () => { + const { metadata } = await client.getMetadata({ trn }); + return metadata; + }), + ), + ); + const hasMismatch = results.some( + (result) => + result.status === "fulfilled" && + result.value?.labels[sdkNameLabelKey] === application.name && + !hasMatchingSdkVersion(result.value.labels, desiredLabels), + ); + if (hasMismatch) { + return true; + } + const failure = results.find((result) => result.status === "rejected"); + if (failure) { + throw failure.reason; + } return false; } diff --git a/packages/sdk/src/cli/commands/deploy/function-registry.test.ts b/packages/sdk/src/cli/commands/deploy/function-registry.test.ts index e6cf3f3692..e23236e303 100644 --- a/packages/sdk/src/cli/commands/deploy/function-registry.test.ts +++ b/packages/sdk/src/cli/commands/deploy/function-registry.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, vi, beforeEach } from "vitest"; import { resolverBundleKey } from "#/cli/shared/resolver-bundle-key"; +import { createConcurrencyProbe } from "#/cli/shared/test-helpers/concurrency-probe"; import { applyFunctionRegistry, authHookFunctionName, @@ -439,6 +440,154 @@ describe("applyFunctionRegistry phase separation", () => { expect(client.updateFunctionRegistry).not.toHaveBeenCalled(); expect(client.setMetadata).not.toHaveBeenCalled(); }); + + test("uploads functions concurrently in the create-update phase", async () => { + const probe = createConcurrencyProbe(); + const client = { + createFunctionRegistry: vi.fn().mockImplementation(probe.run), + updateFunctionRegistry: vi.fn().mockImplementation(probe.run), + deleteFunctionRegistry: vi.fn().mockResolvedValue({}), + setMetadata: vi.fn().mockResolvedValue({}), + } as unknown as OperatorClient; + + const changeOf = (name: string) => ({ + name, + entry: { + name, + scriptContent: "// script", + contentHash: `hash-${name}`, + description: `Function: ${name}`, + }, + metaRequest: { trn: `trn:${name}`, labels: {} }, + }); + const planResult = { + changeSet: { + creates: ["create-a", "create-b", "create-c"].map(changeOf), + updates: ["update-a", "update-b", "update-c"].map(changeOf), + deletes: [], + unchanged: [], + title: "Function registry", + isEmpty: () => false, + lines: () => [], + }, + conflicts: [], + unmanaged: [], + resourceOwners: new Set(), + } as unknown as Awaited>; + + await applyFunctionRegistry(client, "test-workspace", planResult, "create-update"); + + expect(client.createFunctionRegistry).toHaveBeenCalledTimes(3); + expect(client.updateFunctionRegistry).toHaveBeenCalledTimes(3); + expect(probe.maxInFlight()).toBeGreaterThan(1); + }); + + test("starts updates while the tail of the creates wave is still running", async () => { + vi.stubEnv("TAILOR_APPLY_CONCURRENCY", "2"); + let releaseFirstCreate!: () => void; + let releaseSecondCreate!: () => void; + const createFunctionRegistry = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (releaseFirstCreate = resolve))) + .mockImplementationOnce( + () => new Promise((resolve) => (releaseSecondCreate = resolve)), + ); + const updateFunctionRegistry = vi.fn().mockResolvedValue({}); + const client = { + createFunctionRegistry, + updateFunctionRegistry, + deleteFunctionRegistry: vi.fn().mockResolvedValue({}), + setMetadata: vi.fn().mockResolvedValue({}), + } as unknown as OperatorClient; + const changeOf = (name: string) => ({ + name, + entry: { + name, + scriptContent: "// script", + contentHash: `hash-${name}`, + description: `Function: ${name}`, + }, + metaRequest: { trn: `trn:${name}`, labels: {} }, + }); + const planResult = { + changeSet: { + creates: ["create-a", "create-b"].map(changeOf), + updates: ["update-a"].map(changeOf), + deletes: [], + unchanged: [], + title: "Function registry", + isEmpty: () => false, + lines: () => [], + }, + conflicts: [], + unmanaged: [], + resourceOwners: new Set(), + } as unknown as Awaited>; + + const applyPromise = applyFunctionRegistry( + client, + "test-workspace", + planResult, + "create-update", + ); + + await vi.waitFor(() => expect(createFunctionRegistry).toHaveBeenCalledTimes(2)); + releaseFirstCreate(); + try { + await vi.waitFor(() => expect(updateFunctionRegistry).toHaveBeenCalledTimes(1), { + timeout: 100, + }); + } finally { + releaseSecondCreate(); + await applyPromise; + vi.unstubAllEnvs(); + } + }); + + test("caps concurrent upload and metadata RPCs at TAILOR_APPLY_CONCURRENCY", async () => { + vi.stubEnv("TAILOR_APPLY_CONCURRENCY", "2"); + try { + const probe = createConcurrencyProbe(); + const client = { + createFunctionRegistry: vi.fn().mockImplementation(probe.run), + updateFunctionRegistry: vi.fn().mockImplementation(probe.run), + deleteFunctionRegistry: vi.fn().mockResolvedValue({}), + setMetadata: vi.fn().mockImplementation(probe.run), + } as unknown as OperatorClient; + + const changeOf = (name: string) => ({ + name, + entry: { + name, + scriptContent: "// script", + contentHash: `hash-${name}`, + description: `Function: ${name}`, + }, + metaRequest: { trn: `trn:${name}`, labels: {} }, + }); + const planResult = { + changeSet: { + creates: [], + updates: ["update-a", "update-b", "update-c", "update-d"].map(changeOf), + deletes: [], + unchanged: [], + title: "Function registry", + isEmpty: () => false, + lines: () => [], + }, + conflicts: [], + unmanaged: [], + resourceOwners: new Set(), + } as unknown as Awaited>; + + await applyFunctionRegistry(client, "test-workspace", planResult, "create-update"); + + expect(client.updateFunctionRegistry).toHaveBeenCalledTimes(4); + expect(probe.maxInFlight()).toBeLessThanOrEqual(2); + } finally { + vi.unstubAllEnvs(); + } + }); }); describe("collectFunctionEntries", () => { diff --git a/packages/sdk/src/cli/commands/deploy/function-registry.ts b/packages/sdk/src/cli/commands/deploy/function-registry.ts index dcc36a5fd3..ef7495dd64 100644 --- a/packages/sdk/src/cli/commands/deploy/function-registry.ts +++ b/packages/sdk/src/cli/commands/deploy/function-registry.ts @@ -1,4 +1,5 @@ import * as crypto from "node:crypto"; +import { createApplyLimiter } from "#/cli/shared/apply-concurrency"; import { logger } from "#/cli/shared/logger"; import { resolverBundleKey } from "#/cli/shared/resolver-bundle-key"; import { createChangeSet, type ChangeSet, type HasName } from "./change-set"; @@ -473,17 +474,24 @@ export async function applyFunctionRegistry( ) { const { changeSet } = result; if (phase === "create-update") { - // Upload new functions - for (const create of changeSet.creates) { - await uploadFunctionScript(client, workspaceId, create.entry, true); - await client.setMetadata(create.metaRequest); - } - - // Update existing functions (server deduplicates content by hash) - for (const update of changeSet.updates) { - await uploadFunctionScript(client, workspaceId, update.entry, false); - await client.setMetadata(update.metaRequest); - } + // Streaming uploads bypass the client's unary concurrency cap, so bound + // each upload + metadata pair here with the same apply-concurrency budget. + const limitFunction = createApplyLimiter(); + + await Promise.all([ + ...changeSet.creates.map((create) => + limitFunction(async () => { + await uploadFunctionScript(client, workspaceId, create.entry, true); + await client.setMetadata(create.metaRequest); + }), + ), + ...changeSet.updates.map((update) => + limitFunction(async () => { + await uploadFunctionScript(client, workspaceId, update.entry, false); + await client.setMetadata(update.metaRequest); + }), + ), + ]); } else { await Promise.all( changeSet.deletes.map((del) => diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts index 8c630a4083..3be8ae4734 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts @@ -3,6 +3,7 @@ import * as os from "node:os"; import * as path from "pathe"; import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; import { applyPreMigrationFieldAdjustments } from "#/cli/commands/tailordb/migrate/pre-migration-schema"; +import { createConcurrencyProbe } from "#/cli/shared/test-helpers/concurrency-probe"; import { sdkNameLabelKey } from "../label"; import { applyTailorDB, formatTailorDBResourceChangeEntries, planTailorDB } from "."; import type { FieldDiffChange } from "#/cli/commands/tailordb/migrate/diff-calculator"; @@ -1298,3 +1299,67 @@ describe("applyTailorDB migration label reconciliation", () => { await expect(applyTailorDB(client, planResult, "create-update")).resolves.toBeUndefined(); }); }); + +describe("applyTailorDB type apply concurrency", () => { + test("applies type creates and updates sequentially", async () => { + const probe = createConcurrencyProbe(); + const client = { + createTailorDBType: vi.fn().mockImplementation(probe.run), + updateTailorDBType: vi.fn().mockImplementation(probe.run), + createTailorDBService: vi.fn().mockResolvedValue({}), + createTailorDBGQLPermission: vi.fn().mockResolvedValue({}), + updateTailorDBGQLPermission: vi.fn().mockResolvedValue({}), + deleteTailorDBGQLPermission: vi.fn().mockResolvedValue({}), + deleteTailorDBType: vi.fn().mockResolvedValue({}), + setMetadata: vi.fn().mockResolvedValue({}), + } as unknown as OperatorClient; + + const changeOf = (name: string) => ({ + name, + request: { + workspaceId: "test-workspace", + namespaceName: "test-tailordb", + tailordbType: { name }, + }, + }); + const emptyChanges = (title: string) => ({ + creates: [], + updates: [], + deletes: [], + title, + isEmpty: () => true, + lines: () => [], + }); + const planResult = { + changeSet: { + service: emptyChanges("TailorDB Services"), + type: { + creates: ["CreateA", "CreateB", "CreateC"].map(changeOf), + updates: ["UpdateA", "UpdateB", "UpdateC"].map(changeOf), + deletes: [], + title: "TailorDB Types", + isEmpty: () => false, + lines: () => [], + }, + gqlPermission: emptyChanges("TailorDB GQL Permissions"), + }, + conflicts: [], + unmanaged: [], + resourceOwners: new Set(), + context: { + workspaceId: "test-workspace", + application: { name: "test-app", tailorDBServices: [] } as unknown as Application, + tailorDBInputs: [], + executorUsedTypes: new Set(), + config: { path: "/nonexistent/tailor.config.ts", name: "test-app", db: {} }, + noSchemaCheck: true, + }, + } as unknown as Awaited>; + + await applyTailorDB(client, planResult, "create-update"); + + expect(client.createTailorDBType).toHaveBeenCalledTimes(3); + expect(client.updateTailorDBType).toHaveBeenCalledTimes(3); + expect(probe.maxInFlight()).toBe(1); + }); +}); diff --git a/packages/sdk/src/cli/shared/test-helpers/concurrency-probe.ts b/packages/sdk/src/cli/shared/test-helpers/concurrency-probe.ts new file mode 100644 index 0000000000..e5ed1690ce --- /dev/null +++ b/packages/sdk/src/cli/shared/test-helpers/concurrency-probe.ts @@ -0,0 +1,27 @@ +/** + * Probe that records how many overlapping invocations of a mocked async + * operation are in flight. Each `run` call yields one macrotask so + * concurrent callers can overlap before it resolves: + * + * ```ts + * const probe = createConcurrencyProbe(); + * const client = { updateThing: vi.fn().mockImplementation(probe.run) }; + * // ...exercise code under test... + * expect(probe.maxInFlight()).toBeGreaterThan(1); + * ``` + * @returns A probe with a `run` mock implementation and a `maxInFlight` reader + */ +export function createConcurrencyProbe() { + let inFlight = 0; + let maxInFlight = 0; + return { + run: async (): Promise => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 0)); + inFlight -= 1; + return {}; + }, + maxInFlight: () => maxInFlight, + }; +}