From 2cb1b8754158a3eae0b81aa753c273569e510d60 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Thu, 16 Jul 2026 23:11:45 +0300 Subject: [PATCH 1/2] fix(cdn): skip content-less builds so the manifest never outruns the bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A webhook build for a push that changes no content models (a pure code push) still ran the full pipeline: it re-uploaded `_manifest.json` with the new commit SHA but skipped the locale-bundle block (which only runs when a model is affected). That left `_manifest.json.commitSha` ahead of every `_bundle/*.json`, and CDN consumers that key content freshness off the manifest rendered stale/empty content until a manual full rebuild re-aligned the two — reproduced on staging (Lanista/collabers), where every 22-file code-only webhook build stranded content and forced an 88-file manual rebuild, while 28-file content builds were always fine. The manifest tracks the CONTENT version, so a content-less push must not bump it. Early-return a 0-file no-op from executeCDNBuild when a selective build resolves zero affected models. Manual rebuilds (fullRebuild) and config/model-def changes never reach this branch, so they are unaffected; the same push also stops wasting a build cycle. --- server/utils/cdn-builder.ts | 24 +++++++++ tests/unit/cdn-builder.test.ts | 91 ++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/server/utils/cdn-builder.ts b/server/utils/cdn-builder.ts index c2693e6..cb9c5fb 100644 --- a/server/utils/cdn-builder.ts +++ b/server/utils/cdn-builder.ts @@ -231,6 +231,30 @@ export async function executeCDNBuild(options: BuildOptions): Promise m.id)) + // A selective build whose diff touches no content models (a pure code + // push: only app/, server/, etc.) must be a true no-op. Uploading the + // manifest here would advance `_manifest.json.commitSha` to the code + // commit while the bundle block below is skipped (targetModels empty) — + // leaving `_manifest.json.commitSha` ahead of every `_bundle/*.json`. + // Consumers that key content freshness off the manifest then read stale/ + // empty content until a full rebuild re-aligns them. The manifest tracks + // the CONTENT version, so a content-less push must not bump it. + // fullRebuild (manual trigger) and config/model-def changes never reach + // here: the former skips the `else` branch above, the latter make + // getAffectedModels non-empty. + if (!options.fullRebuild && options.changedPaths?.length && targetModels.length === 0) { + return { + projectId, + buildId, + commitSha, + filesUploaded: 0, + filesDeleted: 0, + totalSizeBytes: 0, + changedModels: [], + durationMs: Date.now() - start, + } + } + // 4. Upload manifest progress({ phase: 'upload', message: 'Uploading manifest...', current: 0, total: targetModels.length }) const manifest = { diff --git a/tests/unit/cdn-builder.test.ts b/tests/unit/cdn-builder.test.ts index 563026e..8002310 100644 --- a/tests/unit/cdn-builder.test.ts +++ b/tests/unit/cdn-builder.test.ts @@ -484,6 +484,97 @@ describe('cdn builder', () => { expect(bundle.paths['content/team/en.json']).toEqual({ m1: { name: 'Existing member' } }) }) + // Regression: a selective build whose diff touches zero content models (a + // pure code push — app/, server/, …) must be a true no-op. Uploading the + // manifest would advance `_manifest.json.commitSha` past every + // `_bundle/*.json` (the bundle block is skipped when no models change), + // leaving the manifest ahead of the bundle until a full rebuild re-aligns + // them — the divergence that stranded content in CDN consumers. + it('is a no-op when a selective build touches no content models', async () => { + const files = { + '.contentrain/config.json': JSON.stringify({ + stack: 'nuxt', + locales: { default: 'en', supported: ['en'] }, + domains: ['marketing'], + }), + '.contentrain/models/faq.json': JSON.stringify({ + id: 'faq', + name: 'FAQ', + kind: 'collection', + domain: 'marketing', + i18n: true, + fields: {}, + }), + '.contentrain/content/marketing/faq/en.json': JSON.stringify({ a1: { question: 'Q' } }), + } + const { provider, objects } = createCDNProvider() + + // Seed a pre-existing bundle + manifest at an OLD commit, as a healthy + // full rebuild would have left them. The no-op must not touch either. + objects.set('proj:_manifest.json', JSON.stringify({ version: '1', commitSha: 'old' })) + objects.set('proj:_bundle/en.json', JSON.stringify({ version: '1', commitSha: 'old', paths: {} })) + + const result = await executeCDNBuild({ + projectId: 'proj', + buildId: 'b', + git: createGitProvider(files), + cdn: provider, + contentRoot: '', + commitSha: 'newcode', + branch: 'main', + changedPaths: ['app/pages/index.vue', 'server/api/foo.ts'], + }) + + expect(result.error).toBeUndefined() + expect(result.filesUploaded).toBe(0) + expect(result.filesDeleted).toBe(0) + expect(result.changedModels).toEqual([]) + // Nothing written or deleted — manifest/bundle stay at the old commit. + expect(provider.putObject).not.toHaveBeenCalled() + expect(provider.deleteObject).not.toHaveBeenCalled() + expect(JSON.parse(objects.get('proj:_manifest.json') ?? '{}').commitSha).toBe('old') + expect(JSON.parse(objects.get('proj:_bundle/en.json') ?? '{}').commitSha).toBe('old') + }) + + // Counter-case: a config change reaching the same selective path must still + // full-build (getAffectedModels returns every model), so the no-op guard + // never suppresses a real content-affecting build. + it('still builds when a selective diff includes the config file', async () => { + const files = { + '.contentrain/config.json': JSON.stringify({ + stack: 'nuxt', + locales: { default: 'en', supported: ['en'] }, + domains: ['marketing'], + }), + '.contentrain/models/faq.json': JSON.stringify({ + id: 'faq', + name: 'FAQ', + kind: 'collection', + domain: 'marketing', + i18n: true, + fields: {}, + }), + '.contentrain/content/marketing/faq/en.json': JSON.stringify({ a1: { question: 'Q' } }), + } + const { provider, objects } = createCDNProvider() + + const result = await executeCDNBuild({ + projectId: 'proj', + buildId: 'b', + git: createGitProvider(files), + cdn: provider, + contentRoot: '', + commitSha: 'newcfg', + branch: 'main', + changedPaths: ['.contentrain/config.json'], + }) + + expect(result.error).toBeUndefined() + expect(result.filesUploaded).toBeGreaterThan(0) + expect(result.changedModels).toEqual(['faq']) + expect(JSON.parse(objects.get('proj:_manifest.json') ?? '{}').commitSha).toBe('newcfg') + }) + // Regression: for i18n:false content, meta lives under the DEFAULT locale // (MCP's contract), which is NOT necessarily supported[0]. The builder used // `locales[0]` (= supported[0]) to resolve the meta path, so when From 4eb1ece27309c4e2ff9c528984102bb77c706c0f Mon Sep 17 00:00:00 2001 From: Contentrain Date: Thu, 16 Jul 2026 23:51:24 +0300 Subject: [PATCH 2/2] fix(cdn): serialize builds per project to stop concurrent-build corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CDN build triggers (the GitHub push webhook and the manual "Rebuild now" endpoint) ran fire-and-forget with no coordination, so two builds for one project could run at once and race on the shared R2 namespace — a manual full rebuild's "delete every object not in this build" cleanup can wipe a concurrent webhook build's fresh uploads, stranding content. Gate both triggers through a single atomic claim: `createCDNBuild` now calls the `claim_cdn_build` SQL function (per-project transaction-scoped advisory lock + stale-slot reclaim + conditional insert) and returns the new row, or null when a build is already in flight. The webhook skips on null; the manual trigger returns 409. Cross-instance safe (single Postgres), crash-safe (a dead build's slot is reclaimed after 15 min), and provider-agnostic (both DatabaseProvider impls call the same function, mirroring increment_mcp_cloud_usage_if_allowed). A shared runner (server/utils/cdn-build-runner.ts) persists each build's result and performs a bounded catch-up: if the branch head advanced while the build ran (a mid-build push whose own webhook was turned away by the claim), it claims and runs one more build at the new head, so no push is stranded. Serialization makes catch-up's full rebuilds safe. Validated against a real postgres:16: the claim returns an id, blocks the second concurrent claim (null), reclaims a >15-min-stale slot, and keeps exactly one in-flight row. db:verify:pg + full suite green. --- .../content/system/error-messages/en.json | 1 + server/api/webhooks/github.post.ts | 25 ++-- .../[projectId]/cdn/builds/trigger.post.ts | 22 +-- server/providers/database.ts | 10 +- server/providers/postgres-db/cdn.ts | 28 ++-- server/providers/supabase-db/cdn.ts | 23 ++- server/utils/cdn-build-runner.ts | 140 ++++++++++++++++++ .../019_cdn_build_single_inflight.sql | 64 ++++++++ .../cdn-routes.integration.test.ts | 45 +++++- .../github-webhook.integration.test.ts | 53 +++++-- tests/unit/cdn-build-runner.test.ts | 99 +++++++++++++ 11 files changed, 436 insertions(+), 74 deletions(-) create mode 100644 server/utils/cdn-build-runner.ts create mode 100644 supabase/migrations/019_cdn_build_single_inflight.sql create mode 100644 tests/unit/cdn-build-runner.test.ts diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index baf2fb0..e17e529 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -46,6 +46,7 @@ "branches.permanent_branch": "The contentrain branch is permanent and cannot be rejected.", "branches.reject_forbidden": "You do not have permission to reject branches in this project.", "cdn.build_failed": "Failed to create build record: {detail}", + "cdn.build_in_progress": "A CDN build is already in progress for this project. Wait for it to finish before starting another.", "cdn.content_not_found": "The requested content was not found in CDN storage.", "cdn.key_expired": "This API key has expired. Please generate a new one in project settings.", "cdn.key_invalid": "Invalid or missing API key. Please check your API key and try again.", diff --git a/server/api/webhooks/github.post.ts b/server/api/webhooks/github.post.ts index 3d3ccb2..16f28f7 100644 --- a/server/api/webhooks/github.post.ts +++ b/server/api/webhooks/github.post.ts @@ -83,7 +83,10 @@ export default defineEventHandler(async (event) => { }) if (pushDiff.action === 'skip') continue - // Create build record + execute async + // Claim the single in-flight slot + execute async. A null claim means + // a build is already running for this project — skip; that build's + // catch-up (runCDNBuild) chases this push's commit when it finishes, so + // nothing is stranded. const build = await db.createCDNBuild({ projectId: proj.id as string, triggerType: 'webhook', @@ -91,10 +94,12 @@ export default defineEventHandler(async (event) => { branch: targetBranch, }) - if (build) { - executeCDNBuild({ + if (build?.id) { + const buildId = build.id as string + runCDNBuild({ + db, projectId: proj.id as string, - buildId: build.id as string, + buildId, git, cdn, contentRoot, @@ -102,20 +107,10 @@ export default defineEventHandler(async (event) => { branch: targetBranch, changedPaths: pushDiff.changedPaths, fullRebuild: pushDiff.fullRebuild, - }).then(async (result) => { - await db.updateCDNBuild(build.id as string, { - status: result.error ? 'failed' : 'success', - file_count: result.filesUploaded, - total_size_bytes: result.totalSizeBytes, - changed_models: result.changedModels, - build_duration_ms: result.durationMs, - error_message: result.error ?? null, - completed_at: new Date().toISOString(), - }) }).catch(async (err: unknown) => { // Ensure build never stays stuck in 'building' const msg = err instanceof Error ? err.message : 'Build failed' - await db.updateCDNBuild(build.id as string, { + await db.updateCDNBuild(buildId, { status: 'failed', error_message: msg, completed_at: new Date().toISOString(), diff --git a/server/api/workspaces/[workspaceId]/projects/[projectId]/cdn/builds/trigger.post.ts b/server/api/workspaces/[workspaceId]/projects/[projectId]/cdn/builds/trigger.post.ts index ec4a52a..fc6aabc 100644 --- a/server/api/workspaces/[workspaceId]/projects/[projectId]/cdn/builds/trigger.post.ts +++ b/server/api/workspaces/[workspaceId]/projects/[projectId]/cdn/builds/trigger.post.ts @@ -45,7 +45,9 @@ export default defineEventHandler(async (event) => { } catch { /* use 'manual' */ } - // Create build record + // Claim the single in-flight slot. Null → a build is already running for + // this project; a manual rebuild must not race it (its full-rebuild cleanup + // would fight the in-flight build's uploads), so surface a clear 409. const build = await db.createCDNBuild({ projectId, triggerType: 'manual', @@ -54,14 +56,17 @@ export default defineEventHandler(async (event) => { }) if (!build?.id) - throw createError({ statusCode: 500, message: errorMessage('cdn.build_failed', { detail: 'unknown' }) }) + throw createError({ statusCode: 409, message: errorMessage('cdn.build_in_progress') }) // SSE stream for progress const eventStream = createEventStream(event) const processBuild = async () => { try { - const result = await executeCDNBuild({ + // runCDNBuild persists the build row's result and runs the mid-build + // catch-up (chasing any push that landed while we built). + const result = await runCDNBuild({ + db, projectId, buildId: build.id as string, git, @@ -75,17 +80,6 @@ export default defineEventHandler(async (event) => { }, }) - // Update build record - await db.updateCDNBuild(build.id as string, { - status: result.error ? 'failed' : 'success', - file_count: result.filesUploaded, - total_size_bytes: result.totalSizeBytes, - changed_models: result.changedModels, - build_duration_ms: result.durationMs, - error_message: result.error ?? null, - completed_at: new Date().toISOString(), - }) - // Emit webhook event (fire-and-forget) emitWebhookEvent(projectId, workspaceId, 'cdn.build_complete', { buildId: build.id, diff --git a/server/providers/database.ts b/server/providers/database.ts index 9d144cc..9904201 100644 --- a/server/providers/database.ts +++ b/server/providers/database.ts @@ -585,12 +585,20 @@ export interface DatabaseProvider { // CDN BUILDS // ═══════════════════════════════════════════════════ + /** + * Atomically claim the single in-flight build slot for a project and create + * the build row (status 'building'). Returns the new row, or `null` when a + * build is already in flight for this project — callers must then skip or + * defer rather than start a concurrent build that would race on CDN storage. + * Slots held by dead builds are reclaimed after a staleness window. Backed by + * the `claim_cdn_build` SQL function (per-project advisory lock). + */ createCDNBuild: (input: { projectId: string triggerType: string commitSha?: string branch?: string - }) => Promise + }) => Promise updateCDNBuild: (buildId: string, updates: Record) => Promise listCDNBuilds: (projectId: string, options?: PaginationOptions & { sort?: string }) => Promise diff --git a/server/providers/postgres-db/cdn.ts b/server/providers/postgres-db/cdn.ts index bbc00f4..072b130 100644 --- a/server/providers/postgres-db/cdn.ts +++ b/server/providers/postgres-db/cdn.ts @@ -180,22 +180,20 @@ export function cdnMethods(): CDNMethods { async createCDNBuild(input) { try { - const row = await getAdmin() - .insertInto('cdn_builds') - .values({ - project_id: input.projectId, - trigger_type: input.triggerType, - commit_sha: (input.commitSha ?? null) as never, - branch: (input.branch ?? null) as never, - status: 'building', - }) - .returning('id') - .executeTakeFirst() - - if (!row) - throw createError({ statusCode: 500, message: 'Invalid database response' }) + // Atomic single-in-flight claim (per-project advisory lock + stale + // reclaim). Returns the new id, or NULL when a build is already running. + const outcome = await sql<{ result: string | null }>` + SELECT public.claim_cdn_build( + p_project_id => ${input.projectId}::uuid, + p_trigger_type => ${input.triggerType}, + p_commit_sha => ${input.commitSha ?? null}, + p_branch => ${input.branch ?? null}, + p_stale_seconds => 900 + ) AS result + `.execute(getAdmin()) - return row as DatabaseRow + const id = outcome.rows[0]?.result ?? null + return id ? ({ id } as DatabaseRow) : null } catch (error) { throwDbError(error) diff --git a/server/providers/supabase-db/cdn.ts b/server/providers/supabase-db/cdn.ts index b39fd23..980f18c 100644 --- a/server/providers/supabase-db/cdn.ts +++ b/server/providers/supabase-db/cdn.ts @@ -140,20 +140,19 @@ export function cdnMethods(): CDNMethods { }, async createCDNBuild(input) { - const { data, error } = await getAdmin() - .from('cdn_builds') - .insert({ - project_id: input.projectId, - trigger_type: input.triggerType, - commit_sha: input.commitSha ?? null, - branch: input.branch ?? null, - status: 'building', - }) - .select('id') - .single() + // Atomic single-in-flight claim (per-project advisory lock + stale + // reclaim). Returns the new id, or NULL when a build is already running. + const { data, error } = await getAdmin().rpc('claim_cdn_build', { + p_project_id: input.projectId, + p_trigger_type: input.triggerType, + p_commit_sha: input.commitSha ?? null, + p_branch: input.branch ?? null, + p_stale_seconds: 900, + }) if (error) throw createError({ statusCode: 500, message: error.message }) - return data as DatabaseRow + const id = (data as string | null) ?? null + return id ? ({ id } as DatabaseRow) : null }, async updateCDNBuild(buildId, updates) { diff --git a/server/utils/cdn-build-runner.ts b/server/utils/cdn-build-runner.ts new file mode 100644 index 0000000..5e3ae6c --- /dev/null +++ b/server/utils/cdn-build-runner.ts @@ -0,0 +1,140 @@ +/** + * CDN build runner — the shared completion path for both build triggers + * (the GitHub push webhook and the manual "Rebuild now" endpoint). + * + * It runs one build, records the result on its `cdn_builds` row, and then + * performs a bounded "catch-up": if the target branch advanced WHILE the build + * was running (a push landed mid-build — its own webhook was turned away by the + * single-in-flight claim), it claims and runs one more build at the new HEAD so + * that mid-build pushes are never stranded. Catch-up builds are full rebuilds + * (always correct) and are safe because the claim serializes them — only one + * build per project runs at a time, so no destructive cleanup ever races + * another build's uploads. + * + * The claim (DatabaseProvider.createCDNBuild) is what guarantees the mutual + * exclusion across instances; this runner only decides WHETHER a follow-up is + * warranted and drives it. + */ +import type { DatabaseProvider } from '../providers/database' +import type { GitProvider } from '../providers/git' +import type { CDNProvider } from '../providers/cdn' +import type { BuildProgressEvent, BuildResult } from './cdn-builder' +import { executeCDNBuild } from './cdn-builder' + +// Hard ceiling on catch-up chaining so a branch under continuous pushes can't +// spin builds forever. Each iteration still reflects the latest HEAD, so the +// CDN converges; this only bounds how many times a single trigger chases it. +const MAX_CATCHUP_BUILDS = 5 + +export interface RunCDNBuildInput { + db: DatabaseProvider + projectId: string + buildId: string + git: GitProvider + cdn: CDNProvider + contentRoot: string + commitSha: string + branch: string + changedPaths?: string[] + fullRebuild?: boolean + /** Progress sink for the initial (user-facing) build only — catch-up builds run silently. */ + onProgress?: (event: BuildProgressEvent) => void +} + +/** Current head SHA of `branch`, or null if it can't be resolved. */ +async function resolveBranchHead(git: GitProvider, branch: string): Promise { + try { + const branches = await git.listBranches() + return branches.find(b => b.name === branch)?.sha ?? null + } + catch { + return null + } +} + +/** Execute a build, persist its result row, then chase any mid-build push. */ +export async function runCDNBuild(input: RunCDNBuildInput): Promise { + const { db, projectId, git, cdn, contentRoot, branch } = input + + const result = await executeCDNBuild({ + projectId, + buildId: input.buildId, + git, + cdn, + contentRoot, + commitSha: input.commitSha, + branch, + changedPaths: input.changedPaths, + fullRebuild: input.fullRebuild, + onProgress: input.onProgress, + }) + + await db.updateCDNBuild(input.buildId, { + status: result.error ? 'failed' : 'success', + file_count: result.filesUploaded, + total_size_bytes: result.totalSizeBytes, + changed_models: result.changedModels, + build_duration_ms: result.durationMs, + error_message: result.error ?? null, + completed_at: new Date().toISOString(), + }) + + // Catch-up only after a clean build — a failed build is retried by the next + // push, not by chasing HEAD on top of a broken state. + if (!result.error) + await catchUp({ db, projectId, git, cdn, contentRoot, branch, builtSha: input.commitSha, depth: 0 }) + + return result +} + +async function catchUp(args: { + db: DatabaseProvider + projectId: string + git: GitProvider + cdn: CDNProvider + contentRoot: string + branch: string + builtSha: string + depth: number +}): Promise { + if (args.depth >= MAX_CATCHUP_BUILDS) return + + const head = await resolveBranchHead(args.git, args.branch) + // Branch unchanged (or unresolvable) → nothing stranded. + if (!head || head === args.builtSha) return + + // Claim the in-flight slot for the follow-up. Null → another trigger already + // grabbed it (e.g. the mid-build push's own retry, or a concurrent instance); + // that build will chase HEAD, so we stop here. + const build = await args.db.createCDNBuild({ + projectId: args.projectId, + triggerType: 'webhook', + commitSha: head, + branch: args.branch, + }) + if (!build?.id) return + + const result = await executeCDNBuild({ + projectId: args.projectId, + buildId: build.id as string, + git: args.git, + cdn: args.cdn, + contentRoot: args.contentRoot, + commitSha: head, + branch: args.branch, + fullRebuild: true, + }) + + await args.db.updateCDNBuild(build.id as string, { + status: result.error ? 'failed' : 'success', + file_count: result.filesUploaded, + total_size_bytes: result.totalSizeBytes, + changed_models: result.changedModels, + build_duration_ms: result.durationMs, + error_message: result.error ?? null, + completed_at: new Date().toISOString(), + }) + + if (!result.error) + await catchUp({ ...args, builtSha: head, depth: args.depth + 1 }) +} diff --git a/supabase/migrations/019_cdn_build_single_inflight.sql b/supabase/migrations/019_cdn_build_single_inflight.sql new file mode 100644 index 0000000..11498f7 --- /dev/null +++ b/supabase/migrations/019_cdn_build_single_inflight.sql @@ -0,0 +1,64 @@ +-- Serialize CDN builds: at most one in-flight build per project. +-- +-- CDN builds are triggered fire-and-forget from two callsites (the GitHub +-- push webhook and the manual "Rebuild now" endpoint) with no coordination. +-- Two builds for the same project could run concurrently and race on the +-- shared R2 namespace — in particular a manual full rebuild's "delete every +-- object not in this build" cleanup can wipe a concurrent webhook build's +-- fresh uploads, stranding content until the next rebuild. +-- +-- `claim_cdn_build` is the single atomic gate both callsites now go through +-- (via DatabaseProvider.createCDNBuild). It returns the new build id, or NULL +-- when a build is already in flight so the caller can skip/defer. A per-project +-- advisory lock serializes the claim decision only (it is transaction-scoped — +-- released when this function's statement commits, NOT held for the build's +-- lifetime, so it never ties up a pool connection). Builds whose process died +-- before flipping status out of 'building' are reclaimed after p_stale_seconds +-- so a crash can't block a project's builds forever. +-- +-- SECURITY DEFINER + no explicit grant, mirroring increment_mcp_cloud_usage_ +-- if_allowed: both provider impls call it through the service-role path. Shared +-- lineage (cdn_builds lives on both provider pairs); applied by the Supabase +-- CLI and by scripts/migrate-postgres.mjs alike. + +CREATE FUNCTION public.claim_cdn_build( + p_project_id uuid, + p_trigger_type text, + p_commit_sha text, + p_branch text, + p_stale_seconds integer +) RETURNS uuid + LANGUAGE plpgsql SECURITY DEFINER + AS $$ +DECLARE + v_id uuid; +BEGIN + -- Serialize concurrent claims for this project (txn-scoped advisory lock). + PERFORM pg_advisory_xact_lock(hashtext('cdn_build:' || p_project_id::text)); + + -- Reclaim slots held by builds whose process died mid-flight. + UPDATE public.cdn_builds + SET status = 'failed', + error_message = 'stale build reclaimed (process likely died)', + completed_at = now() + WHERE project_id = p_project_id + AND status IN ('pending', 'building') + AND started_at < now() - make_interval(secs => p_stale_seconds); + + -- A fresh build is already running → caller must not start another. + IF EXISTS ( + SELECT 1 + FROM public.cdn_builds + WHERE project_id = p_project_id + AND status IN ('pending', 'building') + ) THEN + RETURN NULL; + END IF; + + INSERT INTO public.cdn_builds (project_id, trigger_type, commit_sha, branch, status) + VALUES (p_project_id, p_trigger_type, p_commit_sha, p_branch, 'building') + RETURNING id INTO v_id; + + RETURN v_id; +END; +$$; diff --git a/tests/integration/cdn-routes.integration.test.ts b/tests/integration/cdn-routes.integration.test.ts index a555e13..61c9f0c 100644 --- a/tests/integration/cdn-routes.integration.test.ts +++ b/tests/integration/cdn-routes.integration.test.ts @@ -472,7 +472,9 @@ describe('CDN route integration', () => { })) vi.stubGlobal('useCDNProvider', vi.fn().mockReturnValue({})) vi.stubGlobal('emitWebhookEvent', vi.fn().mockResolvedValue(undefined)) - vi.stubGlobal('executeCDNBuild', vi.fn().mockImplementation(async ({ onProgress }) => { + // The handler delegates to runCDNBuild (execute + persist + catch-up); it + // forwards onProgress to the SSE stream and acts on the returned result. + const runCDNBuild = vi.fn().mockImplementation(async ({ onProgress }) => { onProgress?.({ phase: 'upload', message: 'Uploading files', current: 1, total: 2 }) return { filesUploaded: 2, @@ -481,7 +483,8 @@ describe('CDN route integration', () => { durationMs: 321, error: null, } - })) + }) + vi.stubGlobal('runCDNBuild', runCDNBuild) vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ requireWorkspaceRole: vi.fn().mockResolvedValue('owner'), getWorkspaceById: vi.fn().mockResolvedValue({ plan: 'pro' }), @@ -499,8 +502,9 @@ describe('CDN route integration', () => { await Promise.resolve() await Promise.resolve() - expect(updateCDNBuild).toHaveBeenCalledWith('build-1', expect.objectContaining({ - status: 'success', + expect(runCDNBuild).toHaveBeenCalledWith(expect.objectContaining({ + buildId: 'build-1', + fullRebuild: true, })) expect(eventStreamState.stream.push).toHaveBeenCalledWith(expect.stringContaining('"phase":"upload"')) expect(eventStreamState.stream.push).toHaveBeenCalledWith(expect.stringContaining('"phase":"complete"')) @@ -515,6 +519,39 @@ describe('CDN route integration', () => { })) }) + it('returns 409 when a build is already in flight (claim blocked)', async () => { + const event = { context: {} } as never + const runCDNBuild = vi.fn() + + vi.stubGlobal('getRouterParam', vi.fn((_: unknown, key: string) => { + if (key === 'workspaceId') return 'workspace-1' + if (key === 'projectId') return 'project-1' + return undefined + })) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ user: { id: 'user-1' }, accessToken: 'token-1' })) + vi.stubGlobal('getWorkspacePlan', vi.fn().mockReturnValue('pro')) + vi.stubGlobal('hasFeature', vi.fn().mockReturnValue(true)) + vi.stubGlobal('resolveProjectContext', vi.fn().mockResolvedValue({ + git: { listBranches: vi.fn().mockResolvedValue([{ name: 'main', sha: 'abc123' }]) }, + contentRoot: '.', + })) + vi.stubGlobal('useCDNProvider', vi.fn().mockReturnValue({})) + vi.stubGlobal('runCDNBuild', runCDNBuild) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + requireWorkspaceRole: vi.fn().mockResolvedValue('owner'), + getWorkspaceById: vi.fn().mockResolvedValue({ plan: 'pro' }), + getProjectForWorkspace: vi.fn().mockResolvedValue({ cdn_enabled: true, cdn_branch: null, default_branch: 'main' }), + // Claim blocked → another build already holds the in-flight slot. + createCDNBuild: vi.fn().mockResolvedValue(null), + updateCDNBuild: vi.fn(), + })) + + const handler = await loadCDNBuildTriggerHandler() + await expect(handler(event)).rejects.toMatchObject({ statusCode: 409 }) + // No build started while one is in flight. + expect(runCDNBuild).not.toHaveBeenCalled() + }) + it('returns 404 for CDN build history requested through the wrong workspace path', async () => { const event = {} as never vi.stubGlobal('getRouterParam', vi.fn((_: unknown, key: string) => { diff --git a/tests/integration/github-webhook.integration.test.ts b/tests/integration/github-webhook.integration.test.ts index 8c23131..f80aede 100644 --- a/tests/integration/github-webhook.integration.test.ts +++ b/tests/integration/github-webhook.integration.test.ts @@ -284,14 +284,14 @@ describe('GitHub webhook integration', () => { }) it('ignores CDN builds when a push hits a different branch than the configured CDN branch', async () => { - const executeCDNBuild = vi.fn() + const runCDNBuild = vi.fn() const createCDNBuild = vi.fn() vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({ github: { webhookSecret: 'webhook-secret' }, })) vi.stubGlobal('useCDNProvider', vi.fn().mockReturnValue({})) - vi.stubGlobal('executeCDNBuild', executeCDNBuild) + vi.stubGlobal('runCDNBuild', runCDNBuild) vi.stubGlobal('hasFeature', vi.fn().mockReturnValue(true)) vi.stubGlobal('getWorkspacePlan', vi.fn().mockReturnValue('pro')) vi.stubGlobal('normalizeContentRoot', vi.fn().mockImplementation((value: string | null) => value ?? '')) @@ -340,7 +340,7 @@ describe('GitHub webhook integration', () => { repo: 'acme/site', }) expect(createCDNBuild).not.toHaveBeenCalled() - expect(executeCDNBuild).not.toHaveBeenCalled() + expect(runCDNBuild).not.toHaveBeenCalled() }) }) @@ -348,18 +348,20 @@ describe('GitHub webhook integration', () => { function stubCdnPushGlobals(input: { getBranchDiff: ReturnType - executeCDNBuild?: ReturnType + runCDNBuild?: ReturnType createCDNBuild?: ReturnType installationId?: number | null }) { - const executeCDNBuild = input.executeCDNBuild ?? vi.fn().mockResolvedValue({}) + // The webhook delegates the build to runCDNBuild (execute + persist + + // mid-build catch-up); stub it so the real builder/git aren't exercised. + const runCDNBuild = input.runCDNBuild ?? vi.fn().mockResolvedValue({}) const createCDNBuild = input.createCDNBuild ?? vi.fn().mockResolvedValue({ id: 'build-1' }) vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({ github: { webhookSecret: 'webhook-secret' }, })) vi.stubGlobal('useCDNProvider', vi.fn().mockReturnValue({})) - vi.stubGlobal('executeCDNBuild', executeCDNBuild) + vi.stubGlobal('runCDNBuild', runCDNBuild) vi.stubGlobal('resolvePushDiff', resolvePushDiff) vi.stubGlobal('hasFeature', vi.fn().mockReturnValue(true)) vi.stubGlobal('getWorkspacePlan', vi.fn().mockReturnValue('pro')) @@ -383,7 +385,7 @@ describe('GitHub webhook integration', () => { updateCDNBuild: vi.fn().mockResolvedValue(undefined), })) - return { executeCDNBuild, createCDNBuild } + return { runCDNBuild, createCDNBuild } } async function postPush(payload: unknown) { @@ -414,7 +416,7 @@ describe('GitHub webhook integration', () => { const getBranchDiff = vi.fn().mockResolvedValue([ { path: '.contentrain/content/blog/posts/en.json', status: 'modified', before: null, after: null }, ]) - const { executeCDNBuild, createCDNBuild } = stubCdnPushGlobals({ getBranchDiff }) + const { runCDNBuild, createCDNBuild } = stubCdnPushGlobals({ getBranchDiff }) const status = await postPush({ ref: 'refs/heads/main', @@ -427,14 +429,14 @@ describe('GitHub webhook integration', () => { expect(status).toBe(200) expect(getBranchDiff).toHaveBeenCalledWith('b'.repeat(40), 'a'.repeat(40)) expect(createCDNBuild).toHaveBeenCalled() - expect(executeCDNBuild).toHaveBeenCalledWith(expect.objectContaining({ + expect(runCDNBuild).toHaveBeenCalledWith(expect.objectContaining({ changedPaths: ['.contentrain/content/blog/posts/en.json'], fullRebuild: undefined, })) }) it('skips the build entirely when the compare shows an identical tree', async () => { - const { executeCDNBuild, createCDNBuild } = stubCdnPushGlobals({ + const { runCDNBuild, createCDNBuild } = stubCdnPushGlobals({ getBranchDiff: vi.fn().mockResolvedValue([]), }) @@ -448,11 +450,11 @@ describe('GitHub webhook integration', () => { expect(status).toBe(200) expect(createCDNBuild).not.toHaveBeenCalled() - expect(executeCDNBuild).not.toHaveBeenCalled() + expect(runCDNBuild).not.toHaveBeenCalled() }) it('never creates a build record when the installation is missing (no stuck pending rows)', async () => { - const { executeCDNBuild, createCDNBuild } = stubCdnPushGlobals({ + const { runCDNBuild, createCDNBuild } = stubCdnPushGlobals({ getBranchDiff: vi.fn(), installationId: null, }) @@ -467,6 +469,31 @@ describe('GitHub webhook integration', () => { expect(status).toBe(200) expect(createCDNBuild).not.toHaveBeenCalled() - expect(executeCDNBuild).not.toHaveBeenCalled() + expect(runCDNBuild).not.toHaveBeenCalled() + }) + + it('skips the build (no createCDNBuild) when a claim reports a build already in flight', async () => { + // createCDNBuild returns null when another build holds the in-flight slot; + // the webhook must simply not start a build (the running build's catch-up + // chases this commit when it finishes). + const createCDNBuild = vi.fn().mockResolvedValue(null) + const { runCDNBuild } = stubCdnPushGlobals({ + getBranchDiff: vi.fn().mockResolvedValue([ + { path: '.contentrain/content/blog/posts/en.json', status: 'modified' }, + ]), + createCDNBuild, + }) + + const status = await postPush({ + ref: 'refs/heads/main', + before: 'a'.repeat(40), + after: 'b'.repeat(40), + repository: { full_name: 'acme/site' }, + commits: [{ added: [], modified: [], removed: [] }], + }) + + expect(status).toBe(200) + expect(createCDNBuild).toHaveBeenCalled() + expect(runCDNBuild).not.toHaveBeenCalled() }) }) diff --git a/tests/unit/cdn-build-runner.test.ts b/tests/unit/cdn-build-runner.test.ts new file mode 100644 index 0000000..e14d1a4 --- /dev/null +++ b/tests/unit/cdn-build-runner.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitProvider } from '../../server/providers/git' +import type { CDNProvider } from '../../server/providers/cdn' +import type { DatabaseProvider } from '../../server/providers/database' +import type { BuildResult } from '../../server/utils/cdn-builder' +import { executeCDNBuild } from '../../server/utils/cdn-builder' +import { runCDNBuild } from '../../server/utils/cdn-build-runner' + +// The runner delegates the actual build to executeCDNBuild; stub it so these +// tests exercise only the orchestration (status persistence + catch-up). +vi.mock('../../server/utils/cdn-builder', () => ({ executeCDNBuild: vi.fn() })) + +const mockedExecute = vi.mocked(executeCDNBuild) + +function ok(overrides: Partial = {}): BuildResult { + return { + projectId: 'p', + buildId: 'b', + commitSha: 's', + filesUploaded: 3, + filesDeleted: 0, + totalSizeBytes: 100, + changedModels: ['faq'], + durationMs: 10, + ...overrides, + } +} + +function makeDeps(headSequence: string[]) { + const listBranches = vi.fn() + for (const sha of headSequence) + listBranches.mockResolvedValueOnce([{ name: 'main', sha }]) + // Any extra calls resolve to the last head (stable). + listBranches.mockResolvedValue([{ name: 'main', sha: headSequence.at(-1) }]) + + const git = { listBranches } as unknown as GitProvider + const cdn = {} as CDNProvider + const createCDNBuild = vi.fn().mockResolvedValue({ id: 'catchup-build' }) + const updateCDNBuild = vi.fn().mockResolvedValue(undefined) + const db = { createCDNBuild, updateCDNBuild } as unknown as DatabaseProvider + return { git, cdn, db, createCDNBuild, updateCDNBuild, listBranches } +} + +const base = { contentRoot: '', branch: 'main' as const } + +describe('runCDNBuild', () => { + beforeEach(() => mockedExecute.mockReset()) + + it('persists the result and does not catch up when the branch head is unchanged', async () => { + mockedExecute.mockResolvedValueOnce(ok()) + const { db, git, cdn, createCDNBuild, updateCDNBuild } = makeDeps(['sha1']) + + await runCDNBuild({ db, projectId: 'p', buildId: 'b', git, cdn, commitSha: 'sha1', ...base }) + + expect(mockedExecute).toHaveBeenCalledTimes(1) + expect(updateCDNBuild).toHaveBeenCalledWith('b', expect.objectContaining({ status: 'success', file_count: 3 })) + // Head equals the built commit → nothing stranded → no follow-up. + expect(createCDNBuild).not.toHaveBeenCalled() + }) + + it('chases a mid-build push: rebuilds at the new head, then stops when it stabilizes', async () => { + mockedExecute.mockResolvedValue(ok()) + // Head advanced to sha2 during the initial build; after rebuilding sha2 it is stable. + const { db, git, cdn, createCDNBuild } = makeDeps(['sha2', 'sha2']) + + await runCDNBuild({ db, projectId: 'p', buildId: 'b', git, cdn, commitSha: 'sha1', ...base }) + + // Initial build + one catch-up build. + expect(mockedExecute).toHaveBeenCalledTimes(2) + expect(createCDNBuild).toHaveBeenCalledTimes(1) + expect(createCDNBuild).toHaveBeenCalledWith(expect.objectContaining({ commitSha: 'sha2', triggerType: 'webhook' })) + // Catch-up builds are full rebuilds (always correct under serialization). + expect(mockedExecute).toHaveBeenLastCalledWith(expect.objectContaining({ commitSha: 'sha2', fullRebuild: true })) + }) + + it('stops the catch-up when the follow-up claim is blocked (null)', async () => { + mockedExecute.mockResolvedValue(ok()) + const { db, git, cdn, createCDNBuild } = makeDeps(['sha2']) + // Another build already holds the in-flight slot. + vi.mocked(createCDNBuild).mockResolvedValue(null) + + await runCDNBuild({ db, projectId: 'p', buildId: 'b', git, cdn, commitSha: 'sha1', ...base }) + + expect(createCDNBuild).toHaveBeenCalledTimes(1) + // No second executeCDNBuild — the slot holder will chase the head. + expect(mockedExecute).toHaveBeenCalledTimes(1) + }) + + it('does not catch up after a failed build', async () => { + mockedExecute.mockResolvedValueOnce(ok({ error: 'boom' })) + const { db, git, cdn, createCDNBuild, updateCDNBuild, listBranches } = makeDeps(['sha2']) + + await runCDNBuild({ db, projectId: 'p', buildId: 'b', git, cdn, commitSha: 'sha1', ...base }) + + expect(updateCDNBuild).toHaveBeenCalledWith('b', expect.objectContaining({ status: 'failed', error_message: 'boom' })) + expect(listBranches).not.toHaveBeenCalled() + expect(createCDNBuild).not.toHaveBeenCalled() + }) +})