Merge pull request #4778 from Dokploy/feat/oss-concurrent-builds

feat: make concurrent builds an OSS feature
This commit is contained in:
Mauricio Siu
2026-07-09 02:25:00 -06:00
committed by GitHub
8 changed files with 31 additions and 173 deletions

View File

@@ -1,16 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hasValidLicense = vi.fn();
const getWebServerSettings = vi.fn();
const findFirstOrg = vi.fn();
const findFirstServer = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
organization: {
findFirst: (...args: unknown[]) => findFirstOrg(...args),
},
server: {
findFirst: (...args: unknown[]) => findFirstServer(...args),
},
@@ -19,91 +14,56 @@ vi.mock("@dokploy/server/db", () => ({
}));
vi.mock("@dokploy/server/db/schema", () => ({
organization: {},
server: {},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: (...args: unknown[]) => hasValidLicense(...args),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
}));
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
import {
assertBuildsConcurrencyAllowed,
resolveBuildsConcurrency,
} from "../../server/queues/concurrency";
import { resolveBuildsConcurrency } from "../../server/queues/concurrency";
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
describe("resolveBuildsConcurrency (enterprise gating)", () => {
describe("resolveBuildsConcurrency", () => {
beforeEach(() => {
vi.clearAllMocks();
findFirstOrg.mockResolvedValue({ id: "org-1" });
});
describe("local web server partition", () => {
it("returns the configured concurrency when licensed", async () => {
it("returns the configured concurrency", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
});
it("clamps to the free max (2) when there is no valid license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("allows the free max (2) without a license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 2 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("does not cap the value when licensed (N allowed)", async () => {
it("does not cap high values", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
999,
);
});
it("floors values below 1 to 1", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 0 });
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
it("defaults to 1 when settings are missing", async () => {
getWebServerSettings.mockResolvedValue(undefined);
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("remote server partition", () => {
it("returns the server concurrency when its org is licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 4,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(true);
it("returns the server concurrency", async () => {
findFirstServer.mockResolvedValue({ buildsConcurrency: 4 });
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4);
expect(hasValidLicense).toHaveBeenCalledWith("org-1");
});
it("clamps to the free max (2) when the server org is not licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 8,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(2);
});
it("defaults to 1 for an unknown server", async () => {
@@ -119,30 +79,3 @@ describe("resolveBuildsConcurrency (enterprise gating)", () => {
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("assertBuildsConcurrencyAllowed", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("allows up to the free max (2) without checking the license", async () => {
await expect(
assertBuildsConcurrencyAllowed(2, "org-1"),
).resolves.toBeUndefined();
expect(hasValidLicense).not.toHaveBeenCalled();
});
it("allows more than 2 when licensed", async () => {
hasValidLicense.mockResolvedValue(true);
await expect(
assertBuildsConcurrencyAllowed(5, "org-1"),
).resolves.toBeUndefined();
});
it("rejects more than 2 without a license", async () => {
hasValidLicense.mockResolvedValue(false);
await expect(assertBuildsConcurrencyAllowed(3, "org-1")).rejects.toThrow(
/enterprise license/i,
);
});
});

View File

@@ -4,9 +4,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
// Free tier may set up to 2 concurrent builds; enterprise unlocks more.
const FREE_MAX_CONCURRENCY = 2;
const ENTERPRISE_MAX_CONCURRENCY = 100;
const MAX_CONCURRENCY = 100;
interface Props {
/**
@@ -20,14 +18,10 @@ interface Props {
/**
* Control to set the number of concurrent builds, either for a remote server
* (`serverId` provided) or the local web server (omitted). Available to
* everyone self-hosted up to FREE_MAX_CONCURRENCY; higher values require a
* valid enterprise license. Not shown in cloud.
* (`serverId` provided) or the local web server (omitted). Not shown in cloud.
*/
export const BuildsConcurrency = ({ serverId, label }: Props) => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const serverQuery = api.server.one.useQuery(
{ serverId: serverId ?? "" },
@@ -59,10 +53,7 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
// Concurrent builds are a self-hosted feature; not shown in cloud.
if (isCloud) return null;
const max = haveValidLicense
? ENTERPRISE_MAX_CONCURRENCY
: FREE_MAX_CONCURRENCY;
const clamp = (n: number) => Math.min(max, Math.max(1, n));
const clamp = (n: number) => Math.min(MAX_CONCURRENCY, Math.max(1, n));
const handleSave = async () => {
const parsed = clamp(Number.parseInt(value, 10) || 1);
@@ -101,7 +92,7 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
<Input
type="number"
min={1}
max={max}
max={MAX_CONCURRENCY}
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-20"

View File

@@ -29,8 +29,6 @@ const Page = () => {
<CardDescription>
Configure how many deployments can build at the same time on
each server. Builds of the same service are always serialized.
Free plan allows up to 2 concurrent builds; an enterprise
license unlocks more.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">

View File

@@ -46,7 +46,6 @@ import {
redis,
server,
} from "@/server/db/schema";
import { assertBuildsConcurrencyAllowed } from "@/server/queues/concurrency";
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
export const serverRouter = createTRPCRouter({
@@ -491,10 +490,6 @@ export const serverRouter = createTRPCRouter({
message: "You are not authorized to update this server",
});
}
await assertBuildsConcurrencyAllowed(
input.buildsConcurrency,
ctx.session.activeOrganizationId,
);
return await updateServerById(input.serverId, {
buildsConcurrency: input.buildsConcurrency,
});

View File

@@ -71,7 +71,6 @@ import {
projects,
server,
} from "@/server/db/schema";
import { assertBuildsConcurrencyAllowed } from "@/server/queues/concurrency";
import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup";
import { removeJob, schedule } from "@/server/utils/backup";
import packageInfo from "../../../package.json";
@@ -480,11 +479,6 @@ export const settingsRouter = createTRPCRouter({
});
}
await assertBuildsConcurrencyAllowed(
input.buildsConcurrency,
ctx.session.activeOrganizationId,
);
await updateWebServerSettings({
buildsConcurrency: input.buildsConcurrency,
});

View File

@@ -1,32 +1,30 @@
import { db } from "@dokploy/server/db";
import { organization, server } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { server } from "@dokploy/server/db/schema";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { LOCAL_PARTITION } from "./in-memory-queue";
/**
* Resolve the effective builds concurrency for a queue partition.
*
* Concurrent deployments (concurrency > 1) are an enterprise feature: without a
* valid license the effective concurrency is always clamped to 1, so the
* community experience is unchanged and an expired license degrades gracefully
* back to sequential deployments instead of breaking anything.
*
* - `LOCAL_PARTITION` -> concurrency stored on the web server settings (the
* local Dokploy web server), gated by the owner organization's license.
* - any other partition -> concurrency stored on the matching `server` row,
* gated by that server's organization license.
* local Dokploy web server).
* - any other partition -> concurrency stored on the matching `server` row.
*/
export const resolveBuildsConcurrency = async (
partition: string,
): Promise<number> => {
try {
if (partition === LOCAL_PARTITION) {
return await resolveLocalConcurrency();
const settings = await getWebServerSettings();
return normalize(settings?.buildsConcurrency ?? 1);
}
return await resolveServerConcurrency(partition);
const currentServer = await db.query.server.findFirst({
where: eq(server.serverId, partition),
columns: { buildsConcurrency: true },
});
return normalize(currentServer?.buildsConcurrency ?? 1);
} catch (error) {
console.error(
"Failed to resolve builds concurrency, defaulting to 1",
@@ -36,55 +34,4 @@ export const resolveBuildsConcurrency = async (
}
};
// Max concurrent builds allowed without an enterprise license. With a valid
// license the value is unbounded (N) — only the free tier is capped.
export const FREE_MAX_CONCURRENCY = 2;
const clamp = (value: number, licensed: boolean): number => {
const min = Math.max(1, Math.floor(value));
return licensed ? min : Math.min(FREE_MAX_CONCURRENCY, min);
};
/**
* Validate a requested builds-concurrency value before persisting it. Free tier
* may set up to FREE_MAX_CONCURRENCY; anything higher requires a valid
* enterprise license. Throws a TRPCError when the value is not allowed.
*/
export const assertBuildsConcurrencyAllowed = async (
value: number,
organizationId: string,
): Promise<void> => {
if (value <= FREE_MAX_CONCURRENCY) return;
const licensed = await hasValidLicense(organizationId);
if (!licensed) {
throw new TRPCError({
code: "FORBIDDEN",
message: `A valid enterprise license is required to set more than ${FREE_MAX_CONCURRENCY} concurrent builds.`,
});
}
};
const resolveLocalConcurrency = async (): Promise<number> => {
const settings = await getWebServerSettings();
const buildsConcurrency = settings?.buildsConcurrency ?? 1;
// Self-hosted is single-tenant; gate on any organization's license.
const anyOrg = await db.query.organization.findFirst({
columns: { id: true },
});
const licensed = anyOrg ? await hasValidLicense(anyOrg.id) : false;
return clamp(buildsConcurrency, licensed);
};
const resolveServerConcurrency = async (serverId: string): Promise<number> => {
const currentServer = await db.query.server.findFirst({
where: eq(server.serverId, serverId),
columns: { buildsConcurrency: true, organizationId: true },
});
if (!currentServer) return 1;
const licensed = await hasValidLicense(currentServer.organizationId);
return clamp(currentServer.buildsConcurrency, licensed);
};
const normalize = (value: number): number => Math.max(1, Math.floor(value));

View File

@@ -12,8 +12,8 @@ import type { DeploymentJob } from "./queue-types";
* Deployment queue.
*
* Self-hosted uses an in-memory, per-group FIFO queue with configurable
* concurrency per server (enterprise-gated). Cloud does not use the queue at
* all — deployments run directly in the background — so we expose a no-op.
* concurrency per server. Cloud does not use the queue at all — deployments
* run directly in the background — so we expose a no-op.
*/
interface DeploymentQueue {

View File

@@ -105,7 +105,7 @@ export const webServerSettings = pgTable("webServerSettings", {
}),
// Deployment Configuration (self-hosted only)
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
// Concurrent builds on the local web server (enterprise-gated to > 1)
// Concurrent builds on the local web server
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
// Auth Configuration (self-hosted only)
enforceSSO: boolean("enforceSSO").notNull().default(false),