From 3e938e17d6beca420a7c928f1c81d97cd58e8753 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 11 Aug 2026 11:59:18 +0000 Subject: [PATCH] perf: share Turbo cache across release worktrees --- .../src/orchestrator/buildRunner.ts | 30 ++++++++++++ .../src/orchestrator/gatewayOrchestrator.ts | 39 +++++++-------- app/gateway-api/test/buildRunner.test.ts | 49 ++++++++++++++++++- app/gateway-api/test/orchestratorPlan.test.ts | 21 +++++--- app/release-controller/README.md | 11 ++--- .../src/releaseController.ts | 21 +++++--- app/release-controller/src/selfUpgrade.ts | 9 +--- .../test/releaseController.test.ts | 18 +++++-- docs/release-operations.md | 13 ++++- turbo.json | 7 ++- 10 files changed, 159 insertions(+), 59 deletions(-) diff --git a/app/gateway-api/src/orchestrator/buildRunner.ts b/app/gateway-api/src/orchestrator/buildRunner.ts index 56c3cebf..c936832d 100644 --- a/app/gateway-api/src/orchestrator/buildRunner.ts +++ b/app/gateway-api/src/orchestrator/buildRunner.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import path from 'node:path'; export interface BuildCommand { command: string; @@ -25,6 +26,35 @@ export interface BuildRunner { } export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024; +export const RELEASE_TURBO_CONCURRENCY = 2; + +export const resolveReleaseTurboCacheDir = (cacheAnchorRoot: string, env?: Record): string => { + const configured = env?.TURBO_CACHE_DIR?.trim(); + if (!configured) return path.join(path.resolve(cacheAnchorRoot), '.turbo', 'release-cache'); + return path.isAbsolute(configured) ? configured : path.resolve(cacheAnchorRoot, configured); +}; + +export const buildTurboReleaseCommand = ( + workspaceRoot: string, + cacheAnchorRoot: string, + packageNames: string[], + env?: Record +): BuildCommand => ({ + command: 'pnpm', + args: [ + 'exec', + 'turbo', + 'run', + 'build', + ...packageNames.map((packageName) => `--filter=${packageName}`), + `--cache-dir=${resolveReleaseTurboCacheDir(cacheAnchorRoot, env)}`, + `--concurrency=${RELEASE_TURBO_CONCURRENCY}`, + '--ui=stream', + '--output-logs=new-only', + ], + cwd: workspaceRoot, + env, +}); const appendOutputTail = (current: string, chunk: unknown): string => `${current}${String(chunk)}`.slice(-MAX_BUILD_OUTPUT_CHARS); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index fc0055e5..3f72c3ee 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -12,7 +12,7 @@ import { } from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; -import type { BuildCommand, BuildRunner } from './buildRunner.js'; +import { buildTurboReleaseCommand, type BuildCommand, type BuildRunner } from './buildRunner.js'; import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js'; import type { GatewayClaimedProfileUpdate, @@ -494,7 +494,8 @@ export const buildProfileFrontendCommands = ( export const buildWorkspaceCommands = ( workspaceRoot: string, needsInstall: boolean, - env?: Record + env?: Record, + cacheAnchorRoot: string = workspaceRoot ): BuildCommand[] => { const commands: BuildCommand[] = []; if (needsInstall) { @@ -505,23 +506,9 @@ export const buildWorkspaceCommands = ( env, }); } - const buildSteps: Array<[filter: string, script: string]> = [ - ['@sammo-ts/common', 'build'], - ['@sammo-ts/infra', 'prisma:generate'], - ['@sammo-ts/infra', 'build'], - ['@sammo-ts/logic', 'build'], - ['@sammo-ts/game-api', 'build'], - ['@sammo-ts/game-engine', 'build'], - ['@sammo-ts/gateway-api', 'build'], - ]; - for (const [filter, script] of buildSteps) { - commands.push({ - command: 'pnpm', - args: ['--filter', filter, script], - cwd: workspaceRoot, - env, - }); - } + commands.push( + buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, ['@sammo-ts/game-api', '@sammo-ts/gateway-api'], env) + ); return commands; }; @@ -1061,7 +1048,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const manifest = await readReleaseManifest(workspace.root); assertReleaseComponents(manifest, ['game-api', 'game-engine', 'game-frontend']); const commands = [ - ...buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv), + ...buildWorkspaceCommands( + workspace.root, + workspace.needsInstall, + this.processConfig.baseEnv, + this.processConfig.workspaceRoot + ), ...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv), ]; const result = await this.buildRunner.run(commands); @@ -1552,7 +1544,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { }> { const workspace = await this.workspaceManager.prepare(commitSha); const commands = [ - ...buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv), + ...buildWorkspaceCommands( + workspace.root, + workspace.needsInstall, + this.processConfig.baseEnv, + this.processConfig.workspaceRoot + ), ...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []), ]; return { result: await this.buildRunner.run(commands), workspace }; diff --git a/app/gateway-api/test/buildRunner.test.ts b/app/gateway-api/test/buildRunner.test.ts index 134561c6..b0ec6cb7 100644 --- a/app/gateway-api/test/buildRunner.test.ts +++ b/app/gateway-api/test/buildRunner.test.ts @@ -2,7 +2,54 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { MAX_BUILD_OUTPUT_CHARS, PnpmBuildRunner } from '../src/orchestrator/buildRunner.js'; +import { + buildTurboReleaseCommand, + MAX_BUILD_OUTPUT_CHARS, + PnpmBuildRunner, + resolveReleaseTurboCacheDir, +} from '../src/orchestrator/buildRunner.js'; + +describe('Turbo release build plan', () => { + it('anchors the default cache outside commit worktrees and allows an operator override', () => { + expect(resolveReleaseTurboCacheDir('/srv/core/repository')).toBe('/srv/core/repository/.turbo/release-cache'); + expect( + resolveReleaseTurboCacheDir('/srv/core/repository', { + TURBO_CACHE_DIR: '/srv/core/cache/turbo', + }) + ).toBe('/srv/core/cache/turbo'); + expect( + resolveReleaseTurboCacheDir('/srv/core/repository', { + TURBO_CACHE_DIR: '.cache/turbo', + }) + ).toBe('/srv/core/repository/.cache/turbo'); + }); + + it('uses a bounded streaming Turbo build for the selected packages', () => { + expect( + buildTurboReleaseCommand( + '/srv/core/profile-worktrees/commit', + '/srv/core/repository', + ['@sammo-ts/game-api'], + { NODE_ENV: 'production' } + ) + ).toEqual({ + command: 'pnpm', + args: [ + 'exec', + 'turbo', + 'run', + 'build', + '--filter=@sammo-ts/game-api', + '--cache-dir=/srv/core/repository/.turbo/release-cache', + '--concurrency=2', + '--ui=stream', + '--output-logs=new-only', + ], + cwd: '/srv/core/profile-worktrees/commit', + env: { NODE_ENV: 'production' }, + }); + }); +}); describe('PnpmBuildRunner', () => { it('returns a failed result when a command cannot be spawned', async () => { diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 4d739886..3c5609a2 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -225,17 +225,22 @@ describe('sanitizeManagedProcessEnv', () => { describe('buildWorkspaceCommands', () => { it('installs and builds runtime dependencies before the profile processes', () => { const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef'; - const commands = buildWorkspaceCommands(workspaceRoot, true); + const commands = buildWorkspaceCommands(workspaceRoot, true, undefined, '/srv/sammo/controller'); expect(commands.map(({ args }) => args)).toEqual([ ['install', '--frozen-lockfile'], - ['--filter', '@sammo-ts/common', 'build'], - ['--filter', '@sammo-ts/infra', 'prisma:generate'], - ['--filter', '@sammo-ts/infra', 'build'], - ['--filter', '@sammo-ts/logic', 'build'], - ['--filter', '@sammo-ts/game-api', 'build'], - ['--filter', '@sammo-ts/game-engine', 'build'], - ['--filter', '@sammo-ts/gateway-api', 'build'], + [ + 'exec', + 'turbo', + 'run', + 'build', + '--filter=@sammo-ts/game-api', + '--filter=@sammo-ts/gateway-api', + '--cache-dir=/srv/sammo/controller/.turbo/release-cache', + '--concurrency=2', + '--ui=stream', + '--output-logs=new-only', + ], ]); expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true); }); diff --git a/app/release-controller/README.md b/app/release-controller/README.md index 05d895c2..33523f5b 100644 --- a/app/release-controller/README.md +++ b/app/release-controller/README.md @@ -29,6 +29,9 @@ Gateway process 환경에 전달하지 않습니다. 이 값이 frontend 정의 frontend build 계약입니다. - `RELEASE_CONTROLLER_POLL_MS`, `RELEASE_CONTROLLER_READINESS_TIMEOUT_MS`: queue poll과 준비 제한 시간입니다. +- `TURBO_CACHE_DIR`: 선택 사항인 공유 local cache 경로입니다. 없으면 원래 + `RELEASE_CONTROLLER_WORKSPACE_ROOT/.turbo/release-cache`를 사용합니다. 상대 경로는 + 원래 workspace 기준으로 해석합니다. 비밀값은 Git에서 제외된 환경 파일 또는 process 환경으로 전달해 주세요. `VITE_*`에는 공개 URL만 넣어 주세요. @@ -46,13 +49,7 @@ DEPLOY의 rollback이 frontend build가 없는 controller worktree를 이전 Gat ```sh pnpm install --frozen-lockfile -pnpm --filter @sammo-ts/infra prisma:generate -pnpm --filter @sammo-ts/common build -pnpm --filter @sammo-ts/infra build -pnpm --filter @sammo-ts/logic build -pnpm --filter @sammo-ts/game-engine build -pnpm --filter @sammo-ts/gateway-api build -pnpm --filter @sammo-ts/release-controller build +pnpm exec turbo run build --filter=@sammo-ts/release-controller --concurrency=2 --ui=stream pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway pnpm --filter @sammo-ts/release-controller start ``` diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index 879a3b5c..5461da2c 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -4,6 +4,7 @@ import { stripVTControlCharacters } from 'node:util'; import { assertReleaseComponents, + buildTurboReleaseCommand, type BuildCommand, type BuildProgressEvent, type BuildRunner, @@ -38,13 +39,12 @@ export const buildGatewayReleaseCommands = ( }; return [ ...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []), - { command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:generate'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/logic', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/game-engine', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-api', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-frontend', 'build'], cwd: workspaceRoot, env }, + buildTurboReleaseCommand( + workspaceRoot, + config.workspaceRoot, + ['@sammo-ts/gateway-api', '@sammo-ts/gateway-frontend'], + env + ), ]; }; @@ -244,7 +244,12 @@ export class GatewayReleaseController { await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config), operation.id); await this.waitForReadiness(operation.id); } catch (error) { - await this.appendLog(operation.id, 'rollback', '새 Gateway 시작에 실패하여 이전 process를 복구합니다.', 'ERROR'); + await this.appendLog( + operation.id, + 'rollback', + '새 Gateway 시작에 실패하여 이전 process를 복구합니다.', + 'ERROR' + ); await this.stopManagedProcesses(operation.id); if (previousDefinitions.length) { await this.startDefinitions(previousDefinitions, operation.id); diff --git a/app/release-controller/src/selfUpgrade.ts b/app/release-controller/src/selfUpgrade.ts index 9f5cdfe8..8fd87c2b 100644 --- a/app/release-controller/src/selfUpgrade.ts +++ b/app/release-controller/src/selfUpgrade.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { assertReleaseComponents, + buildTurboReleaseCommand, type BuildCommand, type BuildRunner, type GitWorkspaceManager, @@ -24,13 +25,7 @@ export const buildReleaseControllerCommands = ( const env = sanitizeManagedProcessEnv(config.baseEnv); return [ ...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []), - { command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:generate'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/logic', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/game-engine', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-api', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/release-controller', 'build'], cwd: workspaceRoot, env }, + buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/release-controller'], env), ]; }; diff --git a/app/release-controller/test/releaseController.test.ts b/app/release-controller/test/releaseController.test.ts index 1f9fd3f7..213dc704 100644 --- a/app/release-controller/test/releaseController.test.ts +++ b/app/release-controller/test/releaseController.test.ts @@ -209,6 +209,8 @@ describe('GatewayReleaseController', () => { expect(commandGroups).toHaveLength(2); expect(commandGroups[0]?.[0]).toBe('install --frozen-lockfile'); + expect(commandGroups[0]?.[1]).toContain('turbo run build'); + expect(commandGroups[0]?.[1]).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache'); expect(commandGroups[1]).toEqual(['--filter @sammo-ts/infra prisma:migrate:deploy:gateway']); expect([...running.keys()].sort()).toEqual([...gatewayNames].sort()); expect(harness.published).toEqual([ @@ -216,7 +218,16 @@ describe('GatewayReleaseController', () => { ]); expect(harness.completions).toEqual(['SUCCEEDED']); expect(harness.logs.map((entry) => entry.phase)).toEqual( - expect.arrayContaining(['claim', 'resolve', 'workspace', 'build', 'migration', 'switch', 'readiness', 'publish']) + expect.arrayContaining([ + 'claim', + 'resolve', + 'workspace', + 'build', + 'migration', + 'switch', + 'readiness', + 'publish', + ]) ); }); @@ -275,8 +286,7 @@ describe('GatewayReleaseController', () => { await onProgress?.({ type: 'OUTPUT', stream: 'stdout', - message: - 'bootstrap-secret-value postgresql://operator:visible-password@db.invalid/sammo', + message: 'bootstrap-secret-value postgresql://operator:visible-password@db.invalid/sammo', }); return { ok: true, exitCode: 0, output: '' }; }, @@ -405,7 +415,7 @@ describe('upgradeReleaseController', () => { ).resolves.toEqual({ commitSha: SHA, workspace }); expect(commandGroups).toHaveLength(2); - expect(commandGroups[0]?.at(-1)).toBe('--filter @sammo-ts/release-controller build'); + expect(commandGroups[0]?.at(-1)).toContain('turbo run build --filter=@sammo-ts/release-controller'); expect(starts.at(-1)).toMatchObject({ name: 'sammo:release-controller', cwd: path.join(workspace, 'app', 'release-controller'), diff --git a/docs/release-operations.md b/docs/release-operations.md index 7bd570fd..83348466 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -44,6 +44,15 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필 - Root와 server package의 `tsdown`은 0.22.14 계열로 통일합니다. Docker runtime의 Node heap/Rayon 상한을 상속한 동일 toolchain으로 초기 Gateway와 profile worktree를 빌드하여 구형 Rolldown의 과도한 native thread 생성을 피합니다. +- Profile, Gateway와 controller self-upgrade의 server package build는 Turbo DAG를 + 동시성 2로 실행합니다. 기본 local cache는 원래 Core checkout의 + `.turbo/release-cache`이므로 commit별 worktree가 달라도 재사용됩니다. 별도 + persistent 경로가 필요하면 controller/orchestrator 환경에 `TURBO_CACHE_DIR`을 + 설정합니다. Cache는 재생성 가능한 build artifact이며 DB/Redis backup이 아닙니다. +- `NODE_ENV`와 Vite가 추론한 `VITE_*`는 build hash에 포함됩니다. 따라서 base path나 + API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지 않습니다. + `NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로 + build child에 전달됩니다. - migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이 유지됩니다. @@ -113,7 +122,7 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면 1. Source ref를 commit SHA로 고정하고 commit worktree를 준비합니다. 2. Release manifest의 protocol, component와 migration head를 검증합니다. -3. Gateway API와 frontend를 빌드하고 gateway migration을 적용합니다. +3. Gateway API와 frontend를 공유 Turbo cache로 빌드하고 gateway migration을 적용합니다. 4. `sammo:gateway-api`, `sammo:gateway-frontend`, `sammo:gateway-orchestrator`를 새 worktree definition으로 전환합니다. 5. Gateway API `/healthz`, `/gateway/`와 세 PM2 process의 `online` 상태를 @@ -137,6 +146,8 @@ Gateway 전체에는 활성 릴리스 작업을 동시에 하나만 둘 수 있 commit 해석, worktree 준비, build 명령 출력, migration, process 전환, readiness와 rollback 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도 같은 이력에서 다시 열 수 있으며 화면은 최근 1,000줄을 유지합니다. +Build 로그의 `cache hit`/`cache miss`와 마지막 `Cached: N cached, M total`은 실제 +이번 릴리스의 cache 사용 여부를 나타냅니다. 로그 원본은 Gateway DB의 `GatewayReleaseLog`에 작업별로 저장되고 작업 삭제 시 함께 제거됩니다. Controller는 ANSI 제어 문자를 제거하고 secret·token·password diff --git a/turbo.json b/turbo.json index b791d8e2..f83d3229 100644 --- a/turbo.json +++ b/turbo.json @@ -1,17 +1,20 @@ { "$schema": "https://turbo.build/schema.json", "ui": "tui", + "globalPassThroughEnv": ["NODE_OPTIONS", "RAYON_NUM_THREADS"], "tasks": { "build": { "dependsOn": ["^build", "typecheck"], + "env": ["NODE_ENV"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, "typecheck": { - "dependsOn": ["^typecheck", "prisma:generate"], + "dependsOn": ["^build", "prisma:generate"], "cache": true }, "prisma:generate": { - "inputs": ["prisma/*.prisma"], + "inputs": ["$TURBO_DEFAULT$"], "outputs": ["node_modules/.prisma/client/**", "prisma/generated/**"], "cache": true },