From a8f3e3ab418840810a126377ecc2a543629ad318 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 02:01:03 +0900 Subject: [PATCH 1/6] perf(cli): parallelize deploy RPCs for version check and applies Deploy issued platform RPCs one at a time in three hot paths: the sdk-version change detection fetched metadata for every candidate TRN serially, and the function-registry and TailorDB type applies looped over creates/updates sequentially. Run each batch with Promise.all; total in-flight RPCs stay bounded by the operator client's shared concurrency limiter. Measured on the example app (37 metadata TRNs, 19 functions, 12 types): a no-change deploy drops from 8.8s to 4.1s, an update deploy from 20.5s to 10.3s. --- .changeset/parallel-deploy-rpcs.md | 5 ++ .../src/cli/commands/deploy/deploy.test.ts | 59 +++++++++++++++ .../sdk/src/cli/commands/deploy/deploy.ts | 37 ++++++---- .../commands/deploy/function-registry.test.ts | 49 +++++++++++++ .../cli/commands/deploy/function-registry.ts | 20 +++--- .../commands/deploy/tailordb/index.test.ts | 72 +++++++++++++++++++ .../src/cli/commands/deploy/tailordb/index.ts | 12 ++-- 7 files changed, 226 insertions(+), 28 deletions(-) create mode 100644 .changeset/parallel-deploy-rpcs.md diff --git a/.changeset/parallel-deploy-rpcs.md b/.changeset/parallel-deploy-rpcs.md new file mode 100644 index 0000000000..6dc608c24c --- /dev/null +++ b/.changeset/parallel-deploy-rpcs.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Speed up deploy by running SDK version detection, function uploads, and TailorDB type changes concurrently diff --git a/packages/sdk/src/cli/commands/deploy/deploy.test.ts b/packages/sdk/src/cli/commands/deploy/deploy.test.ts index 2ea62cef53..b3d049a870 100644 --- a/packages/sdk/src/cli/commands/deploy/deploy.test.ts +++ b/packages/sdk/src/cli/commands/deploy/deploy.test.ts @@ -18,6 +18,7 @@ import { planDeploymentTargets, printDeploymentPlans, printPlanResults, + shouldForceApplyAll, summarizePlanResults, } from "./deploy"; import type { PlannedDeployment } from "./apply-phases"; @@ -144,6 +145,64 @@ 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 () => { + let inFlight = 0; + let maxInFlight = 0; + const getMetadata = vi.fn().mockImplementation(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 0)); + inFlight -= 1; + 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(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); + }); +}); + 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..6b2af9f015 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,20 +387,20 @@ 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 metadataList = await Promise.all( + [...candidateTrns].map((trn) => + getOrNull(async () => { + const { metadata } = await client.getMetadata({ trn }); + return metadata; + }), + ), + ); - return false; + return metadataList.some( + (metadata) => + metadata?.labels[sdkNameLabelKey] === application.name && + !hasMatchingSdkVersion(metadata.labels, desiredLabels), + ); } /** 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..04487959c3 100644 --- a/packages/sdk/src/cli/commands/deploy/function-registry.test.ts +++ b/packages/sdk/src/cli/commands/deploy/function-registry.test.ts @@ -439,6 +439,55 @@ describe("applyFunctionRegistry phase separation", () => { expect(client.updateFunctionRegistry).not.toHaveBeenCalled(); expect(client.setMetadata).not.toHaveBeenCalled(); }); + + test("uploads functions concurrently in the create-update phase", async () => { + let inFlight = 0; + let maxInFlight = 0; + const trackConcurrency = async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 0)); + inFlight -= 1; + return {}; + }; + const client = { + createFunctionRegistry: vi.fn().mockImplementation(trackConcurrency), + updateFunctionRegistry: vi.fn().mockImplementation(trackConcurrency), + 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(maxInFlight).toBeGreaterThan(1); + }); }); 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..84c4ec0842 100644 --- a/packages/sdk/src/cli/commands/deploy/function-registry.ts +++ b/packages/sdk/src/cli/commands/deploy/function-registry.ts @@ -474,16 +474,20 @@ 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); - } + await Promise.all( + changeSet.creates.map(async (create) => { + 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); - } + await Promise.all( + changeSet.updates.map(async (update) => { + 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..8e2f9831c6 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts @@ -1298,3 +1298,75 @@ 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 concurrently", async () => { + let inFlight = 0; + let maxInFlight = 0; + const trackConcurrency = async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 0)); + inFlight -= 1; + return {}; + }; + const client = { + createTailorDBType: vi.fn().mockImplementation(trackConcurrency), + updateTailorDBType: vi.fn().mockImplementation(trackConcurrency), + 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(maxInFlight).toBeGreaterThan(1); + }); +}); diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts index 3e6399205e..8cf874c389 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts @@ -682,12 +682,12 @@ export async function applyTailorDB( // Types try { - for (const create of changeSet.type.creates) { - await client.createTailorDBType(create.request); - } - for (const update of changeSet.type.updates) { - await client.updateTailorDBType(update.request); - } + await Promise.all( + changeSet.type.creates.map((create) => client.createTailorDBType(create.request)), + ); + await Promise.all( + changeSet.type.updates.map((update) => client.updateTailorDBType(update.request)), + ); } catch (error) { handleOptionalToRequiredError(error, [ "Run 'tailor-sdk tailordb migration generate' to create migration files.", From 610711d4e2f20326691288a4e1ce36168344d0c2 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 02:05:13 +0900 Subject: [PATCH 2/6] refactor(cli): extract shared concurrency probe test helper --- .../src/cli/commands/deploy/deploy.test.ts | 11 +++----- .../commands/deploy/function-registry.test.ts | 17 ++++-------- .../commands/deploy/tailordb/index.test.ts | 17 ++++-------- .../shared/test-helpers/concurrency-probe.ts | 27 +++++++++++++++++++ 4 files changed, 41 insertions(+), 31 deletions(-) create mode 100644 packages/sdk/src/cli/shared/test-helpers/concurrency-probe.ts diff --git a/packages/sdk/src/cli/commands/deploy/deploy.test.ts b/packages/sdk/src/cli/commands/deploy/deploy.test.ts index b3d049a870..5c71d00000 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"; @@ -167,13 +168,9 @@ describe("shouldForceApplyAll", () => { } test("fetches candidate metadata concurrently", async () => { - let inFlight = 0; - let maxInFlight = 0; + const probe = createConcurrencyProbe(); const getMetadata = vi.fn().mockImplementation(async () => { - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - await new Promise((resolve) => setTimeout(resolve, 0)); - inFlight -= 1; + await probe.run(); return { metadata: { labels: {} } }; }); const client = { getMetadata } as unknown as Client; @@ -186,7 +183,7 @@ describe("shouldForceApplyAll", () => { expect(result).toBe(false); expect(getMetadata).toHaveBeenCalledTimes(3); - expect(maxInFlight).toBeGreaterThan(1); + expect(probe.maxInFlight()).toBeGreaterThan(1); }); test("returns true when an owned resource has a different sdk-version", async () => { 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 04487959c3..a621fc832a 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, @@ -441,18 +442,10 @@ describe("applyFunctionRegistry phase separation", () => { }); test("uploads functions concurrently in the create-update phase", async () => { - let inFlight = 0; - let maxInFlight = 0; - const trackConcurrency = async () => { - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - await new Promise((resolve) => setTimeout(resolve, 0)); - inFlight -= 1; - return {}; - }; + const probe = createConcurrencyProbe(); const client = { - createFunctionRegistry: vi.fn().mockImplementation(trackConcurrency), - updateFunctionRegistry: vi.fn().mockImplementation(trackConcurrency), + createFunctionRegistry: vi.fn().mockImplementation(probe.run), + updateFunctionRegistry: vi.fn().mockImplementation(probe.run), deleteFunctionRegistry: vi.fn().mockResolvedValue({}), setMetadata: vi.fn().mockResolvedValue({}), } as unknown as OperatorClient; @@ -486,7 +479,7 @@ describe("applyFunctionRegistry phase separation", () => { expect(client.createFunctionRegistry).toHaveBeenCalledTimes(3); expect(client.updateFunctionRegistry).toHaveBeenCalledTimes(3); - expect(maxInFlight).toBeGreaterThan(1); + expect(probe.maxInFlight()).toBeGreaterThan(1); }); }); 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 8e2f9831c6..3f55439e10 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"; @@ -1301,18 +1302,10 @@ describe("applyTailorDB migration label reconciliation", () => { describe("applyTailorDB type apply concurrency", () => { test("applies type creates and updates concurrently", async () => { - let inFlight = 0; - let maxInFlight = 0; - const trackConcurrency = async () => { - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - await new Promise((resolve) => setTimeout(resolve, 0)); - inFlight -= 1; - return {}; - }; + const probe = createConcurrencyProbe(); const client = { - createTailorDBType: vi.fn().mockImplementation(trackConcurrency), - updateTailorDBType: vi.fn().mockImplementation(trackConcurrency), + createTailorDBType: vi.fn().mockImplementation(probe.run), + updateTailorDBType: vi.fn().mockImplementation(probe.run), createTailorDBService: vi.fn().mockResolvedValue({}), createTailorDBGQLPermission: vi.fn().mockResolvedValue({}), updateTailorDBGQLPermission: vi.fn().mockResolvedValue({}), @@ -1367,6 +1360,6 @@ describe("applyTailorDB type apply concurrency", () => { expect(client.createTailorDBType).toHaveBeenCalledTimes(3); expect(client.updateTailorDBType).toHaveBeenCalledTimes(3); - expect(maxInFlight).toBeGreaterThan(1); + expect(probe.maxInFlight()).toBeGreaterThan(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, + }; +} From 034ba27f5bdfb7ec442ed7e2cf72ed98c1b22d8a Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 02:21:27 +0900 Subject: [PATCH 3/6] fix(cli): respect DDL serialization and bound function upload streams Re-serialize TailorDB type creates/updates: concurrent DDL within a namespace races platform-side schema mutation (the invariant fc0c46910 established), so only the metadata fetch and function-upload paths stay parallel. Pin the invariant with a sequential-apply test. Function upload streams bypass the operator client's unary concurrency interceptor, so cap them explicitly with the shared apply-concurrency limiter (TAILOR_APPLY_CONCURRENCY, default 16). --- .changeset/parallel-deploy-rpcs.md | 2 +- .../commands/deploy/function-registry.test.ts | 45 +++++++++++++++++++ .../cli/commands/deploy/function-registry.ts | 9 +++- .../commands/deploy/tailordb/index.test.ts | 4 +- .../src/cli/commands/deploy/tailordb/index.ts | 12 ++--- 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/.changeset/parallel-deploy-rpcs.md b/.changeset/parallel-deploy-rpcs.md index 6dc608c24c..9ab0901f50 100644 --- a/.changeset/parallel-deploy-rpcs.md +++ b/.changeset/parallel-deploy-rpcs.md @@ -2,4 +2,4 @@ "@tailor-platform/sdk": patch --- -Speed up deploy by running SDK version detection, function uploads, and TailorDB type changes concurrently +Speed up deploy by running SDK version detection and function uploads concurrently 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 a621fc832a..3fcc50f7e6 100644 --- a/packages/sdk/src/cli/commands/deploy/function-registry.test.ts +++ b/packages/sdk/src/cli/commands/deploy/function-registry.test.ts @@ -481,6 +481,51 @@ describe("applyFunctionRegistry phase separation", () => { expect(client.updateFunctionRegistry).toHaveBeenCalledTimes(3); expect(probe.maxInFlight()).toBeGreaterThan(1); }); + + test("caps concurrent uploads 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().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: [], + 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 84c4ec0842..b98c428a31 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,10 +474,14 @@ export async function applyFunctionRegistry( ) { const { changeSet } = result; if (phase === "create-update") { + // Streaming uploads bypass the client's unary concurrency cap, so bound + // them here with the same apply-concurrency budget. + const limitUpload = createApplyLimiter(); + // Upload new functions await Promise.all( changeSet.creates.map(async (create) => { - await uploadFunctionScript(client, workspaceId, create.entry, true); + await limitUpload(() => uploadFunctionScript(client, workspaceId, create.entry, true)); await client.setMetadata(create.metaRequest); }), ); @@ -484,7 +489,7 @@ export async function applyFunctionRegistry( // Update existing functions (server deduplicates content by hash) await Promise.all( changeSet.updates.map(async (update) => { - await uploadFunctionScript(client, workspaceId, update.entry, false); + await limitUpload(() => uploadFunctionScript(client, workspaceId, update.entry, false)); await client.setMetadata(update.metaRequest); }), ); 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 3f55439e10..3be8ae4734 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts @@ -1301,7 +1301,7 @@ describe("applyTailorDB migration label reconciliation", () => { }); describe("applyTailorDB type apply concurrency", () => { - test("applies type creates and updates concurrently", async () => { + test("applies type creates and updates sequentially", async () => { const probe = createConcurrencyProbe(); const client = { createTailorDBType: vi.fn().mockImplementation(probe.run), @@ -1360,6 +1360,6 @@ describe("applyTailorDB type apply concurrency", () => { expect(client.createTailorDBType).toHaveBeenCalledTimes(3); expect(client.updateTailorDBType).toHaveBeenCalledTimes(3); - expect(probe.maxInFlight()).toBeGreaterThan(1); + expect(probe.maxInFlight()).toBe(1); }); }); diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts index 8cf874c389..3e6399205e 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts @@ -682,12 +682,12 @@ export async function applyTailorDB( // Types try { - await Promise.all( - changeSet.type.creates.map((create) => client.createTailorDBType(create.request)), - ); - await Promise.all( - changeSet.type.updates.map((update) => client.updateTailorDBType(update.request)), - ); + for (const create of changeSet.type.creates) { + await client.createTailorDBType(create.request); + } + for (const update of changeSet.type.updates) { + await client.updateTailorDBType(update.request); + } } catch (error) { handleOptionalToRequiredError(error, [ "Run 'tailor-sdk tailordb migration generate' to create migration files.", From 33b5ccf9262311e62286da9025d18106ab7cff9e Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 02:32:38 +0900 Subject: [PATCH 4/6] fix(cli): share one apply-concurrency budget per function change Run each function's upload and setMetadata inside a single limiter slot so effective RPC concurrency stays within TAILOR_APPLY_CONCURRENCY, and let a detected sdk-version mismatch win over an unrelated metadata fetch failure instead of aborting the deploy. --- .../src/cli/commands/deploy/deploy.test.ts | 28 +++++++++++++++++++ .../sdk/src/cli/commands/deploy/deploy.ts | 19 +++++++++---- .../commands/deploy/function-registry.test.ts | 4 +-- .../cli/commands/deploy/function-registry.ts | 24 +++++++++------- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/packages/sdk/src/cli/commands/deploy/deploy.test.ts b/packages/sdk/src/cli/commands/deploy/deploy.test.ts index 5c71d00000..493fdb2a1b 100644 --- a/packages/sdk/src/cli/commands/deploy/deploy.test.ts +++ b/packages/sdk/src/cli/commands/deploy/deploy.test.ts @@ -198,6 +198,34 @@ describe("shouldForceApplyAll", () => { 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", () => { diff --git a/packages/sdk/src/cli/commands/deploy/deploy.ts b/packages/sdk/src/cli/commands/deploy/deploy.ts index 6b2af9f015..24f6f4b1fd 100644 --- a/packages/sdk/src/cli/commands/deploy/deploy.ts +++ b/packages/sdk/src/cli/commands/deploy/deploy.ts @@ -387,7 +387,7 @@ export async function shouldForceApplyAll( candidateTrns.add(resourceTrn(workspaceId, "function_registry", entry.name)); }); - const metadataList = await Promise.all( + const results = await Promise.allSettled( [...candidateTrns].map((trn) => getOrNull(async () => { const { metadata } = await client.getMetadata({ trn }); @@ -396,11 +396,20 @@ export async function shouldForceApplyAll( ), ); - return metadataList.some( - (metadata) => - metadata?.labels[sdkNameLabelKey] === application.name && - !hasMatchingSdkVersion(metadata.labels, desiredLabels), + 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 3fcc50f7e6..b814b7f640 100644 --- a/packages/sdk/src/cli/commands/deploy/function-registry.test.ts +++ b/packages/sdk/src/cli/commands/deploy/function-registry.test.ts @@ -482,7 +482,7 @@ describe("applyFunctionRegistry phase separation", () => { expect(probe.maxInFlight()).toBeGreaterThan(1); }); - test("caps concurrent uploads at TAILOR_APPLY_CONCURRENCY", async () => { + test("caps concurrent upload and metadata RPCs at TAILOR_APPLY_CONCURRENCY", async () => { vi.stubEnv("TAILOR_APPLY_CONCURRENCY", "2"); try { const probe = createConcurrencyProbe(); @@ -490,7 +490,7 @@ describe("applyFunctionRegistry phase separation", () => { createFunctionRegistry: vi.fn().mockImplementation(probe.run), updateFunctionRegistry: vi.fn().mockImplementation(probe.run), deleteFunctionRegistry: vi.fn().mockResolvedValue({}), - setMetadata: vi.fn().mockResolvedValue({}), + setMetadata: vi.fn().mockImplementation(probe.run), } as unknown as OperatorClient; const changeOf = (name: string) => ({ diff --git a/packages/sdk/src/cli/commands/deploy/function-registry.ts b/packages/sdk/src/cli/commands/deploy/function-registry.ts index b98c428a31..64dbea143f 100644 --- a/packages/sdk/src/cli/commands/deploy/function-registry.ts +++ b/packages/sdk/src/cli/commands/deploy/function-registry.ts @@ -475,23 +475,27 @@ export async function applyFunctionRegistry( const { changeSet } = result; if (phase === "create-update") { // Streaming uploads bypass the client's unary concurrency cap, so bound - // them here with the same apply-concurrency budget. - const limitUpload = createApplyLimiter(); + // each upload + metadata pair here with the same apply-concurrency budget. + const limitFunction = createApplyLimiter(); // Upload new functions await Promise.all( - changeSet.creates.map(async (create) => { - await limitUpload(() => uploadFunctionScript(client, workspaceId, create.entry, true)); - await client.setMetadata(create.metaRequest); - }), + changeSet.creates.map((create) => + limitFunction(async () => { + await uploadFunctionScript(client, workspaceId, create.entry, true); + await client.setMetadata(create.metaRequest); + }), + ), ); // Update existing functions (server deduplicates content by hash) await Promise.all( - changeSet.updates.map(async (update) => { - await limitUpload(() => uploadFunctionScript(client, workspaceId, update.entry, false)); - await client.setMetadata(update.metaRequest); - }), + changeSet.updates.map((update) => + limitFunction(async () => { + await uploadFunctionScript(client, workspaceId, update.entry, false); + await client.setMetadata(update.metaRequest); + }), + ), ); } else { await Promise.all( From b052079f4d4a966a3804761c3efb39a140273cc9 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 16:11:13 +0900 Subject: [PATCH 5/6] perf(cli): overlap function creates and updates --- .../commands/deploy/function-registry.test.ts | 62 +++++++++++++++++++ .../cli/commands/deploy/function-registry.ts | 13 ++-- 2 files changed, 66 insertions(+), 9 deletions(-) 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 b814b7f640..e23236e303 100644 --- a/packages/sdk/src/cli/commands/deploy/function-registry.test.ts +++ b/packages/sdk/src/cli/commands/deploy/function-registry.test.ts @@ -482,6 +482,68 @@ describe("applyFunctionRegistry phase separation", () => { 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 { diff --git a/packages/sdk/src/cli/commands/deploy/function-registry.ts b/packages/sdk/src/cli/commands/deploy/function-registry.ts index 64dbea143f..ef7495dd64 100644 --- a/packages/sdk/src/cli/commands/deploy/function-registry.ts +++ b/packages/sdk/src/cli/commands/deploy/function-registry.ts @@ -478,25 +478,20 @@ export async function applyFunctionRegistry( // each upload + metadata pair here with the same apply-concurrency budget. const limitFunction = createApplyLimiter(); - // Upload new functions - await Promise.all( - changeSet.creates.map((create) => + await Promise.all([ + ...changeSet.creates.map((create) => limitFunction(async () => { await uploadFunctionScript(client, workspaceId, create.entry, true); await client.setMetadata(create.metaRequest); }), ), - ); - - // Update existing functions (server deduplicates content by hash) - await Promise.all( - changeSet.updates.map((update) => + ...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) => From 2bef87886e822369ab381be23251e95795cd53b8 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 16:12:08 +0900 Subject: [PATCH 6/6] docs(cli): update apply concurrency description --- packages/sdk/docs/cli-reference.md | 42 ++++++++++----------- packages/sdk/docs/cli-reference.template.md | 42 ++++++++++----------- 2 files changed, 42 insertions(+), 42 deletions(-) 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