release concurrency system reliability improvements (#2081)

* Refill release concurrency tokens when a run is cancelled

* Improved release concurrency accounting system + a sweeper to auto-refill tokens for snapshots that are no longer the latest snapshot on a run (e.g. the run has moved to a new snapshot state)

* Fix order of arguments to the releasings sweeper

* Add a heartbeat for SUSPENDED snapshots, where when stalled will attempt to continue the run if unblocked
This commit is contained in:
Eric Allam
2025-05-21 13:21:17 +01:00
committed by GitHub
parent 500a173d67
commit 14e081e17d
15 changed files with 863 additions and 126 deletions
+61 -1
View File
@@ -66,7 +66,15 @@ function DebugRunContent({ friendlyId }: { friendlyId: string }) {
);
}
function DebugRunData({
function DebugRunData(props: UseDataFunctionReturn<typeof loader>) {
if (props.engine === "V1") {
return <DebugRunDataEngineV1 {...props} />;
}
return <DebugRunDataEngineV2 {...props} />;
}
function DebugRunDataEngineV1({
run,
queueConcurrencyLimit,
queueCurrentConcurrency,
@@ -338,3 +346,55 @@ function DebugRunData({
</Property.Table>
);
}
function DebugRunDataEngineV2({
run,
queueConcurrencyLimit,
queueCurrentConcurrency,
envConcurrencyLimit,
envCurrentConcurrency,
keys,
}: UseDataFunctionReturn<typeof loader>) {
return (
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField value={run.id} variant="tertiary/small" iconButton />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue current concurrency</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{queueCurrentConcurrency ?? "0"}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue concurrency limit</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{queueConcurrencyLimit ?? "Not set"}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env current concurrency</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{envCurrentConcurrency ?? "0"}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env concurrency limit</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{envConcurrencyLimit ?? "Not set"}</span>
</Property.Value>
</Property.Item>
{keys.map((key) => (
<Property.Item>
<Property.Label>{key.label}</Property.Label>
<Property.Value className="flex items-center gap-2">
<ClipboardField value={key.key} variant="tertiary/small" iconButton />
</Property.Value>
</Property.Item>
))}
</Property.Table>
);
}
+9
View File
@@ -452,6 +452,10 @@ const EnvironmentSchema = z.object({
RUN_ENGINE_TIMEOUT_PENDING_CANCEL: z.coerce.number().int().default(60_000),
RUN_ENGINE_TIMEOUT_EXECUTING: z.coerce.number().int().default(60_000),
RUN_ENGINE_TIMEOUT_EXECUTING_WITH_WAITPOINTS: z.coerce.number().int().default(60_000),
RUN_ENGINE_TIMEOUT_SUSPENDED: z.coerce
.number()
.int()
.default(60_000 * 10),
RUN_ENGINE_DEBUG_WORKER_NOTIFICATIONS: z.coerce.boolean().default(false),
RUN_ENGINE_PARENT_QUEUE_LIMIT: z.coerce.number().int().default(1000),
RUN_ENGINE_CONCURRENCY_LIMIT_BIAS: z.coerce.number().default(0.75),
@@ -605,6 +609,11 @@ const EnvironmentSchema = z.object({
RUN_ENGINE_RELEASE_CONCURRENCY_ENABLED: z.string().default("0"),
RUN_ENGINE_RELEASE_CONCURRENCY_DISABLE_CONSUMERS: z.string().default("0"),
RUN_ENGINE_RELEASE_CONCURRENCY_MAX_TOKENS_RATIO: z.coerce.number().default(1),
RUN_ENGINE_RELEASE_CONCURRENCY_RELEASINGS_MAX_AGE: z.coerce
.number()
.int()
.default(60_000 * 30),
RUN_ENGINE_RELEASE_CONCURRENCY_RELEASINGS_POLL_INTERVAL: z.coerce.number().int().default(60_000),
RUN_ENGINE_RELEASE_CONCURRENCY_MAX_RETRIES: z.coerce.number().int().default(3),
RUN_ENGINE_RELEASE_CONCURRENCY_CONSUMERS_COUNT: z.coerce.number().int().default(1),
RUN_ENGINE_RELEASE_CONCURRENCY_POLL_INTERVAL: z.coerce.number().int().default(500),
@@ -4,6 +4,7 @@ import { z } from "zod";
import { $replica } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { marqs } from "~/v3/marqs/index.server";
import { engine } from "~/v3/runEngine.server";
const ParamSchema = z.object({
runParam: z.string(),
@@ -17,6 +18,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
where: { friendlyId: runParam, project: { organization: { members: { some: { userId } } } } },
select: {
id: true,
engine: true,
friendlyId: true,
queue: true,
concurrencyKey: true,
@@ -27,6 +29,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
type: true,
slug: true,
organizationId: true,
project: true,
maximumConcurrencyLimit: true,
organization: {
select: {
id: true,
@@ -41,33 +45,132 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
throw new Response("Not Found", { status: 404 });
}
const queueConcurrencyLimit = await marqs.getQueueConcurrencyLimit(
run.runtimeEnvironment,
run.queue
);
const envConcurrencyLimit = await marqs.getEnvConcurrencyLimit(run.runtimeEnvironment);
const queueCurrentConcurrency = await marqs.currentConcurrencyOfQueue(
run.runtimeEnvironment,
run.queue,
run.concurrencyKey ?? undefined
);
const envCurrentConcurrency = await marqs.currentConcurrencyOfEnvironment(run.runtimeEnvironment);
if (run.engine === "V1") {
const queueConcurrencyLimit = await marqs.getQueueConcurrencyLimit(
run.runtimeEnvironment,
run.queue
);
const envConcurrencyLimit = await marqs.getEnvConcurrencyLimit(run.runtimeEnvironment);
const queueCurrentConcurrency = await marqs.currentConcurrencyOfQueue(
run.runtimeEnvironment,
run.queue,
run.concurrencyKey ?? undefined
);
const envCurrentConcurrency = await marqs.currentConcurrencyOfEnvironment(
run.runtimeEnvironment
);
const queueReserveConcurrency = await marqs.reserveConcurrencyOfQueue(
run.runtimeEnvironment,
run.queue,
run.concurrencyKey ?? undefined
);
const queueReserveConcurrency = await marqs.reserveConcurrencyOfQueue(
run.runtimeEnvironment,
run.queue,
run.concurrencyKey ?? undefined
);
const envReserveConcurrency = await marqs.reserveConcurrencyOfEnvironment(run.runtimeEnvironment);
const envReserveConcurrency = await marqs.reserveConcurrencyOfEnvironment(
run.runtimeEnvironment
);
return typedjson({
run,
queueConcurrencyLimit,
envConcurrencyLimit,
queueCurrentConcurrency,
envCurrentConcurrency,
queueReserveConcurrency,
envReserveConcurrency,
});
return typedjson({
engine: "V1",
run,
queueConcurrencyLimit,
envConcurrencyLimit,
queueCurrentConcurrency,
envCurrentConcurrency,
queueReserveConcurrency,
envReserveConcurrency,
keys: [],
});
} else {
const queueConcurrencyLimit = await engine.runQueue.getQueueConcurrencyLimit(
run.runtimeEnvironment,
run.queue
);
const envConcurrencyLimit = await engine.runQueue.getEnvConcurrencyLimit(
run.runtimeEnvironment
);
const queueCurrentConcurrency = await engine.runQueue.currentConcurrencyOfQueue(
run.runtimeEnvironment,
run.queue,
run.concurrencyKey ?? undefined
);
const envCurrentConcurrency = await engine.runQueue.currentConcurrencyOfEnvironment(
run.runtimeEnvironment
);
const queueCurrentConcurrencyKey = engine.runQueue.keys.currentConcurrencyKey(
run.runtimeEnvironment,
run.queue,
run.concurrencyKey ?? undefined
);
const envCurrentConcurrencyKey = engine.runQueue.keys.envCurrentConcurrencyKey(
run.runtimeEnvironment
);
const queueConcurrencyLimitKey = engine.runQueue.keys.queueConcurrencyLimitKey(
run.runtimeEnvironment,
run.queue
);
const envConcurrencyLimitKey = engine.runQueue.keys.envConcurrencyLimitKey(
run.runtimeEnvironment
);
const releaseConcurrencyBucketKey = `engine:release-concurrency:org:${run.runtimeEnvironment.organizationId}:proj:${run.runtimeEnvironment.project.id}:env:${run.runtimeEnvironment.id}:bucket`;
const releaseConcurrencyQueueKey = `engine:release-concurrency:org:${run.runtimeEnvironment.organizationId}:proj:${run.runtimeEnvironment.project.id}:env:${run.runtimeEnvironment.id}:queue`;
const releaseConcurrencyMetadataKey = `engine:release-concurrency:org:${run.runtimeEnvironment.organizationId}:proj:${run.runtimeEnvironment.project.id}:env:${run.runtimeEnvironment.id}:metadata`;
const withPrefix = (key: string) => `engine:runqueue:${key}`;
const keys = [
{
label: "Queue current concurrency set",
key: withPrefix(queueCurrentConcurrencyKey),
},
{
label: "Env current concurrency set",
key: withPrefix(envCurrentConcurrencyKey),
},
{
label: "Queue concurrency limit",
key: withPrefix(queueConcurrencyLimitKey),
},
{
label: "Env concurrency limit",
key: withPrefix(envConcurrencyLimitKey),
},
{
label: "Release concurrency bucket",
key: releaseConcurrencyBucketKey,
},
{
label: "Release concurrency queue",
key: releaseConcurrencyQueueKey,
},
{
label: "Release concurrency metadata",
key: releaseConcurrencyMetadataKey,
},
{
label: "Release concurrency releasings",
key: "engine:release-concurrency:releasings",
},
];
return typedjson({
engine: "V2",
run,
queueConcurrencyLimit,
envConcurrencyLimit,
queueCurrentConcurrency,
envCurrentConcurrency,
queueReserveConcurrency: undefined,
envReserveConcurrency: undefined,
keys,
});
}
}
+3
View File
@@ -76,11 +76,14 @@ function createRunEngine() {
PENDING_CANCEL: env.RUN_ENGINE_TIMEOUT_PENDING_CANCEL,
EXECUTING: env.RUN_ENGINE_TIMEOUT_EXECUTING,
EXECUTING_WITH_WAITPOINTS: env.RUN_ENGINE_TIMEOUT_EXECUTING_WITH_WAITPOINTS,
SUSPENDED: env.RUN_ENGINE_TIMEOUT_SUSPENDED,
},
releaseConcurrency: {
disabled: env.RUN_ENGINE_RELEASE_CONCURRENCY_ENABLED === "0",
disableConsumers: env.RUN_ENGINE_RELEASE_CONCURRENCY_DISABLE_CONSUMERS === "1",
maxTokensRatio: env.RUN_ENGINE_RELEASE_CONCURRENCY_MAX_TOKENS_RATIO,
releasingsMaxAge: env.RUN_ENGINE_RELEASE_CONCURRENCY_RELEASINGS_MAX_AGE,
releasingsPollInterval: env.RUN_ENGINE_RELEASE_CONCURRENCY_RELEASINGS_POLL_INTERVAL,
maxRetries: env.RUN_ENGINE_RELEASE_CONCURRENCY_MAX_RETRIES,
consumersCount: env.RUN_ENGINE_RELEASE_CONCURRENCY_CONSUMERS_COUNT,
pollInterval: env.RUN_ENGINE_RELEASE_CONCURRENCY_POLL_INTERVAL,
@@ -182,6 +182,7 @@ export class RunEngine {
PENDING_CANCEL: 60_000,
EXECUTING: 60_000,
EXECUTING_WITH_WAITPOINTS: 60_000,
SUSPENDED: 60_000 * 10,
};
this.heartbeatTimeouts = {
...defaultHeartbeatTimeouts,
@@ -201,6 +202,9 @@ export class RunEngine {
this.releaseConcurrencySystem = new ReleaseConcurrencySystem({
resources,
maxTokensRatio: options.releaseConcurrency?.maxTokensRatio,
releasingsMaxAge: options.releaseConcurrency?.releasingsMaxAge,
releasingsPollInterval: options.releaseConcurrency?.releasingsPollInterval,
queueOptions:
typeof options.releaseConcurrency?.disabled === "boolean" &&
options.releaseConcurrency.disabled
@@ -223,33 +227,6 @@ export class RunEngine {
consumersCount: options.releaseConcurrency?.consumersCount ?? 1,
pollInterval: options.releaseConcurrency?.pollInterval ?? 1000,
batchSize: options.releaseConcurrency?.batchSize ?? 10,
executor: async (descriptor, snapshotId) => {
return await this.releaseConcurrencySystem.executeReleaseConcurrencyForSnapshot(
snapshotId
);
},
maxTokens: async (descriptor) => {
const environment = await this.prisma.runtimeEnvironment.findFirstOrThrow({
where: { id: descriptor.envId },
select: {
maximumConcurrencyLimit: true,
},
});
return (
environment.maximumConcurrencyLimit *
(options.releaseConcurrency?.maxTokensRatio ?? 1.0)
);
},
keys: {
fromDescriptor: (descriptor) =>
`org:${descriptor.orgId}:proj:${descriptor.projectId}:env:${descriptor.envId}`,
toDescriptor: (name) => ({
orgId: name.split(":")[1],
projectId: name.split(":")[3],
envId: name.split(":")[5],
}),
},
tracer: this.tracer,
},
});
@@ -306,6 +283,7 @@ export class RunEngine {
delayedRunSystem: this.delayedRunSystem,
machines: this.options.machines,
retryWarmStartThresholdMs: this.options.retryWarmStartThresholdMs,
releaseConcurrencySystem: this.releaseConcurrencySystem,
});
this.dequeueSystem = new DequeueSystem({
@@ -1297,9 +1275,29 @@ export class RunEngine {
break;
}
case "SUSPENDED": {
//todo should we do a periodic check here for whether waitpoints are actually still blocking?
//we could at least log some things out if a run has been in this state for a long time
throw new NotImplementedError("Not implemented SUSPENDED");
const result = await this.waitpointSystem.continueRunIfUnblocked({ runId });
this.logger.info("handleStalledSnapshot SUSPENDED continueRunIfUnblocked", {
runId,
result,
snapshotId: latestSnapshot.id,
});
switch (result) {
case "blocked": {
// Reschedule the heartbeat
await this.executionSnapshotSystem.restartHeartbeatForRun({
runId,
});
break;
}
case "unblocked":
case "skipped": {
break;
}
}
break;
}
case "PENDING_CANCEL": {
//if the run is waiting to cancel but the worker hasn't confirmed that,
@@ -14,12 +14,23 @@ export type ReleaseConcurrencyQueueRetryOptions = {
};
};
export type ReleaseConcurrencyValidatorResult<T> = {
releaseQueue: T;
releaserId: string;
shouldRefill: boolean;
};
export type ReleaseConcurrencyQueueOptions<T> = {
redis: RedisOptions;
/**
* @returns true if the run was successful, false if the token should be returned to the bucket
*/
executor: (releaseQueue: T, releaserId: string) => Promise<boolean>;
validateReleaserId?: (
releaserId: string
) => Promise<ReleaseConcurrencyValidatorResult<T> | undefined>;
releasingsMaxAge?: number;
releasingsPollInterval?: number;
keys: {
fromDescriptor: (releaseQueue: T) => string;
toDescriptor: (releaseQueue: string) => T;
@@ -47,6 +58,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
private logger: Logger;
private abortController: AbortController;
private consumers: ReleaseConcurrencyQueueConsumer<T>[];
private sweeper?: ReleaseConcurrencyReleasingsSweeper;
private keyPrefix: string;
private masterQueuesKey: string;
@@ -61,7 +73,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
constructor(private readonly options: ReleaseConcurrencyQueueOptions<T>) {
this.redis = createRedisClient(options.redis);
this.keyPrefix = options.redis.keyPrefix ?? "re2:release-concurrency-queue:";
this.logger = options.logger ?? new Logger("ReleaseConcurrencyQueue");
this.logger = options.logger ?? new Logger("ReleaseConcurrencyQueue", "debug");
this.abortController = new AbortController();
this.consumers = [];
@@ -83,6 +95,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
if (!options.disableConsumers) {
this.#startConsumers();
this.#startMetricsProducer();
this.#startReleasingsSweeper();
}
}
@@ -118,6 +131,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
this.#bucketKey(releaseQueue),
this.#queueKey(releaseQueue),
this.#metadataKey(releaseQueue),
this.#releasingsKey(),
releaseQueue,
releaserId,
String(maxTokens),
@@ -172,6 +186,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
this.#bucketKey(releaseQueue),
this.#queueKey(releaseQueue),
this.#metadataKey(releaseQueue),
this.#releasingsKey(),
releaseQueue,
releaserId,
String(maxTokens),
@@ -204,6 +219,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
this.#bucketKey(releaseQueue),
this.#queueKey(releaseQueue),
this.#metadataKey(releaseQueue),
this.#releasingsKey(),
releaseQueue,
releaserId
);
@@ -270,10 +286,10 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
}
/**
* Refill a token only if the releaserId is not in the release queue.
* Returns true if the token was refilled, false if the releaserId was found in the queue.
* Refill a token only if the releaserId is in the releasings set.
* Returns true if the token was refilled, false if the releaserId was not found in the releasings set.
*/
public async refillTokenIfNotInQueue(
public async refillTokenIfInReleasings(
releaseQueueDescriptor: T,
releaserId: string
): Promise<boolean> {
@@ -291,17 +307,18 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
return false;
}
const result = await this.redis.refillTokenIfNotInQueue(
const result = await this.redis.refillTokenIfInReleasings(
this.masterQueuesKey,
this.#bucketKey(releaseQueue),
this.#queueKey(releaseQueue),
this.#metadataKey(releaseQueue),
this.#releasingsKey(),
releaseQueue,
releaserId,
String(maxTokens)
);
this.logger.debug("Attempted to refill token if not in queue", {
this.logger.debug("Attempted to refill token if in releasings", {
releaseQueueDescriptor,
releaserId,
maxTokens,
@@ -360,6 +377,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
this.#bucketKey(releaseQueue),
this.#queueKey(releaseQueue),
this.#metadataKey(releaseQueue),
this.#releasingsKey(),
releaseQueue,
releaserId
);
@@ -381,6 +399,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
this.#bucketKey(releaseQueue),
this.#queueKey(releaseQueue),
this.#metadataKey(releaseQueue),
this.#releasingsKey(),
releaseQueue,
releaserId
);
@@ -434,6 +453,10 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
return `${releaseQueue}:metadata`;
}
#releasingsKey() {
return "releasings";
}
#startConsumers() {
const consumerCount = this.consumersCount;
@@ -452,6 +475,20 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
}
}
#startReleasingsSweeper() {
if (this.options.validateReleaserId) {
this.sweeper = new ReleaseConcurrencyReleasingsSweeper(
this,
this.options.validateReleaserId,
this.options.releasingsPollInterval ?? 60_000,
this.options.releasingsMaxAge ?? 60_000 * 30,
this.abortController.signal,
this.logger
);
this.sweeper.start();
}
}
async #startMetricsProducer() {
try {
// Produce metrics every 60 seconds, using a tracer span
@@ -615,14 +652,28 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
return promise;
}
async getReleasings(maxAge: number) {
const releasings = await this.redis.zrangebyscore(
this.#releasingsKey(),
0,
Date.now() - maxAge
);
return releasings;
}
async removeReleaserIdFromReleasings(releaserId: string) {
await this.redis.zrem(this.#releasingsKey(), releaserId);
}
#registerCommands() {
this.redis.defineCommand("consumeToken", {
numberOfKeys: 4,
numberOfKeys: 5,
lua: `
local masterQueuesKey = KEYS[1]
local bucketKey = KEYS[2]
local queueKey = KEYS[3]
local metadataKey = KEYS[4]
local releasingsKey = KEYS[5]
local releaseQueue = ARGV[1]
local releaserId = ARGV[2]
@@ -638,6 +689,7 @@ if currentTokens >= 1 then
redis.call("SET", bucketKey, newCurrentTokens)
redis.call("ZREM", queueKey, releaserId)
redis.call("ZADD", releasingsKey, score, releaserId)
-- Clean up metadata when successfully consuming
redis.call("HDEL", metadataKey, releaserId)
@@ -802,21 +854,28 @@ else
redis.call("ZREM", masterQueuesKey, releaseQueue)
end
return true
return redis.status_reply("true")
`,
});
this.redis.defineCommand("returnTokenOnly", {
numberOfKeys: 4,
numberOfKeys: 5,
lua: `
local masterQueuesKey = KEYS[1]
local bucketKey = KEYS[2]
local queueKey = KEYS[3]
local metadataKey = KEYS[4]
local releasingsKey = KEYS[5]
local releaseQueue = ARGV[1]
local releaserId = ARGV[2]
local removedFromReleasings = redis.call("ZREM", releasingsKey, releaserId)
if removedFromReleasings == 0 then
return redis.status_reply("false")
end
-- Return the token to the bucket
local currentTokens = tonumber(redis.call("GET", bucketKey))
local remainingTokens = currentTokens + 1
@@ -833,26 +892,26 @@ else
redis.call("ZREM", masterQueuesKey, releaseQueue)
end
return true
return redis.status_reply("true")
`,
});
this.redis.defineCommand("refillTokenIfNotInQueue", {
numberOfKeys: 4,
this.redis.defineCommand("refillTokenIfInReleasings", {
numberOfKeys: 5,
lua: `
local masterQueuesKey = KEYS[1]
local bucketKey = KEYS[2]
local queueKey = KEYS[3]
local metadataKey = KEYS[4]
local releasingsKey = KEYS[5]
local releaseQueue = ARGV[1]
local releaserId = ARGV[2]
local maxTokens = tonumber(ARGV[3])
-- Check if the releaserId is in the queue
local score = redis.call("ZSCORE", queueKey, releaserId)
if score then
-- Item is in queue, don't refill token
local removedFromReleasings = redis.call("ZREM", releasingsKey, releaserId)
if removedFromReleasings == 0 then
return redis.status_reply("false")
end
@@ -891,6 +950,7 @@ declare module "@internal/redis" {
bucketKey: string,
queueKey: string,
metadataKey: string,
releasingsKey: string,
releaseQueue: string,
releaserId: string,
maxTokens: string,
@@ -933,16 +993,18 @@ declare module "@internal/redis" {
bucketKey: string,
queueKey: string,
metadataKey: string,
releasingsKey: string,
releaseQueue: string,
releaserId: string,
callback?: Callback<void>
): Result<void, Context>;
refillTokenIfNotInQueue(
refillTokenIfInReleasings(
masterQueuesKey: string,
bucketKey: string,
queueKey: string,
metadataKey: string,
releasingsKey: string,
releaseQueue: string,
releaserId: string,
maxTokens: string,
@@ -982,3 +1044,59 @@ class ReleaseConcurrencyQueueConsumer<T> {
}
}
}
class ReleaseConcurrencyReleasingsSweeper {
private readonly logger: Logger;
constructor(
private readonly queue: ReleaseConcurrencyTokenBucketQueue<any>,
private readonly validateReleaserId: (
releaserId: string
) => Promise<ReleaseConcurrencyValidatorResult<any> | undefined>,
private readonly pollInterval: number,
private readonly maxAge: number,
private readonly signal: AbortSignal,
logger?: Logger
) {
this.queue = queue;
this.logger = logger ?? new Logger("ReleaseConcurrencyReleasingsSweeper");
}
async start() {
try {
for await (const _ of setInterval(this.pollInterval, null, { signal: this.signal })) {
try {
await this.sweep();
} catch (error) {
this.logger.error("Error sweeping releasings:", { error });
}
}
} catch (error) {
if (error instanceof Error && error.name !== "AbortError") {
this.logger.error("Sweeper loop error:", { error });
}
}
}
private async sweep() {
const releasings = await this.queue.getReleasings(this.maxAge);
this.logger.debug("Sweeping releasings:", { releasings });
for (const releaserId of releasings) {
const result = await this.validateReleaserId(releaserId);
this.logger.debug("Validated releaserId:", { releaserId, result });
if (!result) {
// We need to remove the releaserId from the releasings set
await this.queue.removeReleaserIdFromReleasings(releaserId);
continue;
}
if (result.shouldRefill) {
await this.queue.refillTokenIfInReleasings(result.releaseQueue, result.releaserId);
}
}
}
}
@@ -195,8 +195,14 @@ export class CheckpointSystem {
checkpointId: taskRunCheckpoint.id,
});
this.$.logger.debug("Refilling token bucket for release concurrency queue", {
snapshot,
});
// Refill the token bucket for the release concurrency queue
await this.releaseConcurrencySystem.checkpointCreatedOnEnvironment(run.runtimeEnvironment);
await this.releaseConcurrencySystem.refillTokensForSnapshot(
snapshot.previousSnapshotId ?? snapshot.id
);
return {
ok: true as const,
@@ -227,8 +233,12 @@ export class CheckpointSystem {
runnerId,
});
this.$.logger.debug("Refilling token bucket for release concurrency queue", {
snapshot,
});
// Refill the token bucket for the release concurrency queue
await this.releaseConcurrencySystem.checkpointCreatedOnEnvironment(run.runtimeEnvironment);
await this.releaseConcurrencySystem.refillTokensForSnapshot(snapshot.id);
return {
ok: true as const,
@@ -364,7 +364,7 @@ export class ExecutionSnapshotSystem {
return executionResultFromSnapshot(latestSnapshot);
}
if (latestSnapshot.workerId !== workerId) {
if (latestSnapshot.workerId && latestSnapshot.workerId !== workerId) {
this.$.logger.debug("heartbeatRun: worker ID does not match the latest snapshot", {
runId,
snapshotId,
@@ -394,6 +394,38 @@ export class ExecutionSnapshotSystem {
return executionResultFromSnapshot(latestSnapshot);
}
public async restartHeartbeatForRun({
runId,
tx,
}: {
runId: string;
tx?: PrismaClientOrTransaction;
}): Promise<ExecutionResult> {
const prisma = tx ?? this.$.prisma;
const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId);
//extending the heartbeat
const intervalMs = this.#getHeartbeatIntervalMs(latestSnapshot.executionStatus);
if (intervalMs !== null) {
this.$.logger.debug("restartHeartbeatForRun: enqueuing heartbeat", {
runId,
snapshotId: latestSnapshot.id,
intervalMs,
});
await this.$.worker.enqueue({
id: `heartbeatSnapshot.${runId}`,
job: "heartbeatSnapshot",
payload: { snapshotId: latestSnapshot.id, runId },
availableAt: new Date(Date.now() + intervalMs),
});
}
return executionResultFromSnapshot(latestSnapshot);
}
#getHeartbeatIntervalMs(status: TaskRunExecutionStatus): number | null {
switch (status) {
case "PENDING_EXECUTING": {
@@ -408,6 +440,9 @@ export class ExecutionSnapshotSystem {
case "EXECUTING_WITH_WAITPOINTS": {
return this.heartbeatTimeouts.EXECUTING_WITH_WAITPOINTS;
}
case "SUSPENDED": {
return this.heartbeatTimeouts.SUSPENDED;
}
default: {
return null;
}
@@ -1,12 +1,12 @@
import { RuntimeEnvironment, TaskRunExecutionSnapshot } from "@trigger.dev/database";
import { SystemResources } from "./systems.js";
import { getLatestExecutionSnapshot } from "./executionSnapshotSystem.js";
import { canReleaseConcurrency } from "../statuses.js";
import { TaskRunExecutionSnapshot } from "@trigger.dev/database";
import { z } from "zod";
import {
ReleaseConcurrencyQueueOptions,
ReleaseConcurrencyTokenBucketQueue,
} from "../releaseConcurrencyTokenBucketQueue.js";
import { canReleaseConcurrency } from "../statuses.js";
import { getLatestExecutionSnapshot } from "./executionSnapshotSystem.js";
import { SystemResources } from "./systems.js";
const ReleaseConcurrencyMetadata = z.object({
releaseConcurrency: z.boolean().optional(),
@@ -16,11 +16,17 @@ type ReleaseConcurrencyMetadata = z.infer<typeof ReleaseConcurrencyMetadata>;
export type ReleaseConcurrencySystemOptions = {
resources: SystemResources;
queueOptions?: ReleaseConcurrencyQueueOptions<{
orgId: string;
projectId: string;
envId: string;
}>;
maxTokensRatio?: number;
releasingsMaxAge?: number;
releasingsPollInterval?: number;
queueOptions?: Omit<
ReleaseConcurrencyQueueOptions<{
orgId: string;
projectId: string;
envId: string;
}>,
"executor" | "validateReleaserId" | "keys" | "maxTokens"
>;
};
export class ReleaseConcurrencySystem {
@@ -35,10 +41,79 @@ export class ReleaseConcurrencySystem {
this.$ = options.resources;
if (options.queueOptions) {
this.releaseConcurrencyQueue = new ReleaseConcurrencyTokenBucketQueue(options.queueOptions);
this.releaseConcurrencyQueue = new ReleaseConcurrencyTokenBucketQueue({
...options.queueOptions,
releasingsMaxAge: this.options.releasingsMaxAge,
releasingsPollInterval: this.options.releasingsPollInterval,
executor: async (descriptor, snapshotId) => {
return await this.executeReleaseConcurrencyForSnapshot(snapshotId);
},
keys: {
fromDescriptor: (descriptor) =>
`org:${descriptor.orgId}:proj:${descriptor.projectId}:env:${descriptor.envId}`,
toDescriptor: (name) => ({
orgId: name.split(":")[1],
projectId: name.split(":")[3],
envId: name.split(":")[5],
}),
},
maxTokens: async (descriptor) => {
const environment = await this.$.prisma.runtimeEnvironment.findFirstOrThrow({
where: { id: descriptor.envId },
select: {
maximumConcurrencyLimit: true,
},
});
return environment.maximumConcurrencyLimit * (this.options.maxTokensRatio ?? 1.0);
},
validateReleaserId: async (releaserId) => {
return this.validateSnapshotShouldRefillToken(releaserId);
},
});
}
}
async validateSnapshotShouldRefillToken(releaserId: string) {
const snapshot = await this.$.prisma.taskRunExecutionSnapshot.findFirst({
where: { id: releaserId },
select: {
id: true,
run: {
select: {
id: true,
status: true,
},
},
organizationId: true,
projectId: true,
environmentId: true,
executionStatus: true,
},
});
if (!snapshot) {
return;
}
const latestSnapshot = await getLatestExecutionSnapshot(this.$.prisma, snapshot.run.id);
this.$.logger.debug("Checking if snapshot should refill", {
snapshot,
latestSnapshot,
});
return {
releaseQueue: {
orgId: snapshot.organizationId,
projectId: snapshot.projectId,
envId: snapshot.environmentId,
},
releaserId: snapshot.id,
shouldRefill: latestSnapshot.id !== snapshot.id,
};
}
public async consumeToken(
descriptor: { orgId: string; projectId: string; envId: string },
releaserId: string
@@ -50,6 +125,9 @@ export class ReleaseConcurrencySystem {
await this.releaseConcurrencyQueue.consumeToken(descriptor, releaserId);
}
/**
* This is used in tests only
*/
public async returnToken(
descriptor: { orgId: string; projectId: string; envId: string },
releaserId: string
@@ -105,7 +183,7 @@ export class ReleaseConcurrencySystem {
return;
}
await this.releaseConcurrencyQueue.refillTokenIfNotInQueue(
await this.releaseConcurrencyQueue.refillTokenIfInReleasings(
{
orgId: snapshot.organizationId,
projectId: snapshot.projectId,
@@ -115,21 +193,6 @@ export class ReleaseConcurrencySystem {
);
}
public async checkpointCreatedOnEnvironment(environment: RuntimeEnvironment) {
if (!this.releaseConcurrencyQueue) {
return;
}
await this.releaseConcurrencyQueue.refillTokens(
{
orgId: environment.organizationId,
projectId: environment.projectId,
envId: environment.id,
},
1
);
}
public async releaseConcurrencyForSnapshot(snapshot: TaskRunExecutionSnapshot) {
if (!this.releaseConcurrencyQueue) {
this.$.logger.debug("Release concurrency queue not enabled, skipping release", {
@@ -33,6 +33,7 @@ import {
import { SystemResources } from "./systems.js";
import { WaitpointSystem } from "./waitpointSystem.js";
import { DelayedRunSystem } from "./delayedRunSystem.js";
import { ReleaseConcurrencySystem } from "./releaseConcurrencySystem.js";
export type RunAttemptSystemOptions = {
resources: SystemResources;
@@ -40,6 +41,7 @@ export type RunAttemptSystemOptions = {
batchSystem: BatchSystem;
waitpointSystem: WaitpointSystem;
delayedRunSystem: DelayedRunSystem;
releaseConcurrencySystem: ReleaseConcurrencySystem;
retryWarmStartThresholdMs?: number;
machines: RunEngineOptions["machines"];
};
@@ -50,6 +52,7 @@ export class RunAttemptSystem {
private readonly batchSystem: BatchSystem;
private readonly waitpointSystem: WaitpointSystem;
private readonly delayedRunSystem: DelayedRunSystem;
private readonly releaseConcurrencySystem: ReleaseConcurrencySystem;
constructor(private readonly options: RunAttemptSystemOptions) {
this.$ = options.resources;
@@ -57,6 +60,7 @@ export class RunAttemptSystem {
this.batchSystem = options.batchSystem;
this.waitpointSystem = options.waitpointSystem;
this.delayedRunSystem = options.delayedRunSystem;
this.releaseConcurrencySystem = options.releaseConcurrencySystem;
}
public async startRunAttempt({
@@ -1037,6 +1041,8 @@ export class RunAttemptSystem {
//remove it from the queue and release concurrency
await this.$.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId);
await this.releaseConcurrencySystem.refillTokensForSnapshot(latestSnapshot);
//if executing, we need to message the worker to cancel the run and put it into `PENDING_CANCEL` status
if (isExecuting(latestSnapshot.executionStatus)) {
const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, {
@@ -490,7 +490,11 @@ export class WaitpointSystem {
});
}
public async continueRunIfUnblocked({ runId }: { runId: string }) {
public async continueRunIfUnblocked({
runId,
}: {
runId: string;
}): Promise<"blocked" | "unblocked" | "skipped"> {
this.$.logger.debug(`continueRunIfUnblocked: start`, {
runId,
});
@@ -516,7 +520,7 @@ export class WaitpointSystem {
runId,
blockingWaitpoints,
});
return;
return "blocked";
}
// 3. Get the run with environment
@@ -553,7 +557,7 @@ export class WaitpointSystem {
runId,
snapshot,
});
return;
return "skipped";
}
//run is still executing, send a message to the worker
@@ -677,6 +681,8 @@ export class WaitpointSystem {
runId,
});
}
return "unblocked";
}
public async createRunAssociatedWaitpoint(
@@ -479,6 +479,149 @@ describe("RunEngine heartbeats", () => {
}
});
containerTest("Suspended", async ({ prisma, redisOptions }) => {
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const heartbeatTimeout = 1000;
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0001,
},
heartbeatTimeoutsMs: {
SUSPENDED: heartbeatTimeout,
},
tracer: trace.getTracer("test", "0.0.0"),
});
try {
const taskIdentifier = "test-task";
//create background worker
const backgroundWorker = await setupBackgroundWorker(
engine,
authenticatedEnvironment,
taskIdentifier
);
//trigger the run
const run = await engine.trigger(
{
number: 1,
friendlyId: "run_1234",
environment: authenticatedEnvironment,
taskIdentifier,
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: "t12345",
spanId: "s12345",
masterQueue: "main",
queue: "task/test-task",
isTest: false,
tags: [],
},
prisma
);
//dequeue the run
const dequeued = await engine.dequeueFromMasterQueue({
consumerId: "test_12345",
masterQueue: run.masterQueue,
maxRunCount: 10,
});
//create an attempt
await engine.startRunAttempt({
runId: dequeued[0].run.id,
snapshotId: dequeued[0].snapshot.id,
});
//cancel run
//create a manual waitpoint
const waitpointResult = await engine.createManualWaitpoint({
environmentId: authenticatedEnvironment.id,
projectId: authenticatedEnvironment.projectId,
});
expect(waitpointResult.waitpoint.status).toBe("PENDING");
//block the run
const blockedResult = await engine.blockRunWithWaitpoint({
runId: run.id,
waitpoints: waitpointResult.waitpoint.id,
projectId: authenticatedEnvironment.projectId,
organizationId: authenticatedEnvironment.organizationId,
});
const blockedExecutionData = await engine.getRunExecutionData({ runId: run.id });
expect(blockedExecutionData?.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS");
// Create a checkpoint
const checkpointResult = await engine.createCheckpoint({
runId: run.id,
snapshotId: blockedResult.id,
checkpoint: {
type: "DOCKER",
reason: "TEST_CHECKPOINT",
location: "test-location",
imageRef: "test-image-ref",
},
});
expect(checkpointResult.ok).toBe(true);
const snapshot = checkpointResult.ok ? checkpointResult.snapshot : null;
assertNonNullable(snapshot);
// Verify checkpoint creation
expect(snapshot.executionStatus).toBe("SUSPENDED");
// Now wait for the heartbeat to timeout, but it should retry later
await setTimeout(heartbeatTimeout * 1.5);
// Simulate a suspended run without any blocking waitpoints by deleting any blocking task run waitpoints
await prisma.taskRunWaitpoint.deleteMany({
where: {
taskRunId: run.id,
},
});
// Now wait for the heartbeat to timeout again
await setTimeout(heartbeatTimeout * 2);
// Expect the run to be queued
const executionData2 = await engine.getRunExecutionData({ runId: run.id });
assertNonNullable(executionData2);
expect(executionData2.snapshot.executionStatus).toBe("QUEUED");
} finally {
await engine.quit();
}
});
containerTest("Heartbeat keeps run alive", async ({ prisma, redisOptions }) => {
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
@@ -1504,4 +1504,185 @@ describe("RunEngine Releasing Concurrency", () => {
expect(queueMetricsAfterSecondFinished?.currentTokens).toBe(10);
}
);
containerTest(
"refills token bucket after the run has a new snapshot created by the release concurrency sweeper system",
async ({ prisma, redisOptions }) => {
//create environment
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0001,
},
releaseConcurrency: {
maxTokensRatio: 1,
maxRetries: 3,
consumersCount: 1,
pollInterval: 500,
releasingsPollInterval: 500,
batchSize: 1,
releasingsMaxAge: 2_000,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const taskIdentifier = "test-task";
await setupBackgroundWorker(
engine,
authenticatedEnvironment,
taskIdentifier,
undefined,
undefined,
{
concurrencyLimit: 1,
}
);
const run = await engine.trigger(
{
number: 1,
friendlyId: "run_p1234",
environment: authenticatedEnvironment,
taskIdentifier,
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: "t12345",
spanId: "s12345",
masterQueue: "main",
queue: `task/${taskIdentifier}`,
isTest: false,
tags: [],
},
prisma
);
const dequeued = await engine.dequeueFromMasterQueue({
consumerId: "test_12345",
masterQueue: run.masterQueue,
maxRunCount: 10,
});
const queueConcurrency = await engine.runQueue.currentConcurrencyOfQueue(
authenticatedEnvironment,
`task/${taskIdentifier}`
);
expect(queueConcurrency).toBe(1);
const envConcurrency = await engine.runQueue.currentConcurrencyOfEnvironment(
authenticatedEnvironment
);
expect(envConcurrency).toBe(1);
// create an attempt
const attemptResult = await engine.startRunAttempt({
runId: dequeued[0].run.id,
snapshotId: dequeued[0].snapshot.id,
});
expect(attemptResult.snapshot.executionStatus).toBe("EXECUTING");
// create a manual waitpoint
const result = await engine.createManualWaitpoint({
environmentId: authenticatedEnvironment.id,
projectId: authenticatedEnvironment.projectId,
});
// Block the run, specifying the release concurrency option as true
const executingWithWaitpointSnapshot = await engine.blockRunWithWaitpoint({
runId: run.id,
waitpoints: result.waitpoint.id,
projectId: authenticatedEnvironment.projectId,
organizationId: authenticatedEnvironment.organizationId,
releaseConcurrency: true,
});
expect(executingWithWaitpointSnapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS");
// Now confirm the environment concurrency has been released
const envConcurrencyAfter = await engine.runQueue.currentConcurrencyOfEnvironment(
authenticatedEnvironment
);
expect(envConcurrencyAfter).toBe(0);
const queueConcurrencyAfter = await engine.runQueue.currentConcurrencyOfQueue(
authenticatedEnvironment,
`task/${taskIdentifier}`
);
expect(queueConcurrencyAfter).toBe(0);
// And confirm the release concurrency system has consumed the token
const queueMetrics =
await engine.releaseConcurrencySystem.releaseConcurrencyQueue?.getReleaseQueueMetrics({
orgId: authenticatedEnvironment.organizationId,
projectId: authenticatedEnvironment.projectId,
envId: authenticatedEnvironment.id,
});
expect(queueMetrics?.currentTokens).toBe(9);
await setTimeout(3_000);
const queueMetricsAfter =
await engine.releaseConcurrencySystem.releaseConcurrencyQueue?.getReleaseQueueMetrics({
orgId: authenticatedEnvironment.organizationId,
projectId: authenticatedEnvironment.projectId,
envId: authenticatedEnvironment.id,
});
expect(queueMetricsAfter?.currentTokens).toBe(9);
// Now we create a new snapshot for the run, which will cause the sweeper system to refill the token bucket
await engine.executionSnapshotSystem.createExecutionSnapshot(prisma, {
run,
snapshot: {
executionStatus: "PENDING_CANCEL",
description: "Pending cancel",
},
environmentId: authenticatedEnvironment.id,
environmentType: "PRODUCTION",
projectId: authenticatedEnvironment.projectId,
organizationId: authenticatedEnvironment.organizationId,
});
await setTimeout(3_000);
const queueMetricsAfterRefill =
await engine.releaseConcurrencySystem.releaseConcurrencyQueue?.getReleaseQueueMetrics({
orgId: authenticatedEnvironment.organizationId,
projectId: authenticatedEnvironment.projectId,
envId: authenticatedEnvironment.id,
});
expect(queueMetricsAfterRefill?.currentTokens).toBe(10);
}
);
});
@@ -388,7 +388,7 @@ describe("ReleaseConcurrencyQueue", () => {
maxRetries: 2, // Set max retries to 2 (will attempt 3 times total: initial + 2 retries)
backoff: {
minDelay: 100,
maxDelay: 1000,
maxDelay: 200,
factor: 1,
},
},
@@ -715,26 +715,22 @@ describe("ReleaseConcurrencyQueue", () => {
);
redisTest(
"refillTokenIfNotInQueue should refill token when releaserId is not in queue",
"refillTokenIfInReleasings should refill token when releaserId is in the releasings set",
async ({ redisContainer }) => {
const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 2);
try {
// Use up all tokens
await queue.attemptToRelease({ name: "test-queue" }, "run1");
await queue.attemptToRelease({ name: "test-queue" }, "run2");
// Verify tokens were used
expect(executedRuns).toHaveLength(2);
// Try to refill token for a releaserId that's not in queue
const wasRefilled = await queue.refillTokenIfNotInQueue({ name: "test-queue" }, "run3");
const wasRefilled = await queue.refillTokenIfInReleasings({ name: "test-queue" }, "run1");
expect(wasRefilled).toBe(true);
// Verify we can now execute a new run
await queue.attemptToRelease({ name: "test-queue" }, "run3");
await queue.attemptToRelease({ name: "test-queue" }, "run2");
await setTimeout(100);
expect(executedRuns).toHaveLength(3);
expect(executedRuns).toHaveLength(2);
} finally {
await queue.quit();
}
@@ -742,7 +738,7 @@ describe("ReleaseConcurrencyQueue", () => {
);
redisTest(
"refillTokenIfNotInQueue should not refill token when releaserId is in queue",
"refillTokenIfInReleasings should not refill token when releaserId is not in the releasings set",
async ({ redisContainer }) => {
const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 1);
@@ -756,11 +752,11 @@ describe("ReleaseConcurrencyQueue", () => {
expect(executedRuns).toHaveLength(1); // run2 is queued
// Try to refill token for run2 which is in queue
const wasRefilled = await queue.refillTokenIfNotInQueue({ name: "test-queue" }, "run2");
const wasRefilled = await queue.refillTokenIfInReleasings({ name: "test-queue" }, "run2");
expect(wasRefilled).toBe(false);
// Verify run2 is still queued by refilling a token normally
await queue.refillTokens({ name: "test-queue" }, 1);
await queue.refillTokenIfInReleasings({ name: "test-queue" }, "run1");
await setTimeout(100);
expect(executedRuns).toHaveLength(2);
expect(executedRuns[1]).toEqual({ releaseQueue: "test-queue", runId: "run2" });
@@ -771,7 +767,7 @@ describe("ReleaseConcurrencyQueue", () => {
);
redisTest(
"refillTokenIfNotInQueue should handle multiple queues independently",
"refillTokenIfInReleasings should handle multiple queues independently",
async ({ redisContainer }) => {
const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 1);
@@ -787,10 +783,10 @@ describe("ReleaseConcurrencyQueue", () => {
expect(executedRuns).toHaveLength(2); // run3 and run4 are queued
// Try to refill tokens for different releaserIds
const wasRefilled1 = await queue.refillTokenIfNotInQueue({ name: "queue1" }, "run5");
const wasRefilled2 = await queue.refillTokenIfNotInQueue({ name: "queue2" }, "run4");
const wasRefilled1 = await queue.refillTokenIfInReleasings({ name: "queue1" }, "run1");
const wasRefilled2 = await queue.refillTokenIfInReleasings({ name: "queue2" }, "run4");
expect(wasRefilled1).toBe(true); // run5 not in queue1
expect(wasRefilled1).toBe(true); // run1 not in queue1
expect(wasRefilled2).toBe(false); // run4 is in queue2
// Verify queue1 can execute a new run with the refilled token
@@ -804,17 +800,20 @@ describe("ReleaseConcurrencyQueue", () => {
}
);
redisTest("refillTokenIfNotInQueue should not exceed maxTokens", async ({ redisContainer }) => {
redisTest("refillTokenIfInReleasings should not exceed maxTokens", async ({ redisContainer }) => {
const { queue } = createReleaseConcurrencyQueue(redisContainer, 1);
try {
// First consume a token
await queue.attemptToRelease({ name: "test-queue" }, "run1");
// First refill should work
const firstRefill = await queue.refillTokenIfNotInQueue({ name: "test-queue" }, "run1");
const firstRefill = await queue.refillTokenIfInReleasings({ name: "test-queue" }, "run1");
expect(firstRefill).toBe(true);
// Second refill should work but not exceed maxTokens
const secondRefill = await queue.refillTokenIfNotInQueue({ name: "test-queue" }, "run2");
expect(secondRefill).toBe(true);
const secondRefill = await queue.refillTokenIfInReleasings({ name: "test-queue" }, "run2");
expect(secondRefill).toBe(false);
// Get metrics to verify token count
const metrics = await queue.getReleaseQueueMetrics({ name: "test-queue" });
@@ -47,6 +47,8 @@ export type RunEngineOptions = {
releaseConcurrency?: {
disabled?: boolean;
maxTokensRatio?: number;
releasingsMaxAge?: number;
releasingsPollInterval?: number;
redis?: Partial<RedisOptions>;
maxRetries?: number;
consumersCount?: number;
@@ -66,6 +68,7 @@ export type HeartbeatTimeouts = {
PENDING_CANCEL: number;
EXECUTING: number;
EXECUTING_WITH_WAITPOINTS: number;
SUSPENDED: number;
};
export type TriggerParams = {