fix: release concurrency system only consumes tokens when releasings are executed successfully (#1883)

* fix: release concurrency system only consumes tokens when releasings are executed successfully

* Add release concurrency metrics sending to otel
This commit is contained in:
Eric Allam
2025-04-04 17:20:33 +01:00
committed by GitHub
parent 195c5d758b
commit e7bca6fa1b
6 changed files with 308 additions and 26 deletions
+1 -1
View File
@@ -138,7 +138,7 @@
"type": "node-terminal",
"request": "launch",
"name": "Debug RunEngine tests",
"command": "pnpm run test ./src/engine/tests/releaseConcurrencyQueue.test.ts -t 'Should manage token bucket and queue correctly'",
"command": "pnpm run test ./src/engine/tests/releaseConcurrencyTokenBucketQueue.test.ts -t 'Should retrieve metrics for all queues via getQueueMetrics'",
"cwd": "${workspaceFolder}/internal-packages/run-engine",
"sourceMaps": true
},
+1 -1
View File
@@ -8,7 +8,7 @@ const defaultOptions: Partial<RedisOptions> = {
const delay = Math.min(times * 50, 1000);
return delay;
},
maxRetriesPerRequest: process.env.GITHUB_ACTIONS ? 50 : process.env.VITEST ? 1 : 20,
maxRetriesPerRequest: process.env.GITHUB_ACTIONS ? 50 : process.env.VITEST ? 5 : 20,
};
const logger = new Logger("Redis", "debug");
@@ -219,7 +219,7 @@ export class RunEngine {
pollInterval: options.releaseConcurrency?.pollInterval ?? 1000,
batchSize: options.releaseConcurrency?.batchSize ?? 10,
executor: async (descriptor, snapshotId) => {
await this.releaseConcurrencySystem.executeReleaseConcurrencyForSnapshot(
return await this.releaseConcurrencySystem.executeReleaseConcurrencyForSnapshot(
snapshotId
);
},
@@ -1,8 +1,9 @@
import { Callback, createRedisClient, Redis, Result, type RedisOptions } from "@internal/redis";
import { Tracer } from "@internal/tracing";
import { startSpan, Tracer } from "@internal/tracing";
import { Logger } from "@trigger.dev/core/logger";
import { setInterval } from "node:timers/promises";
import { z } from "zod";
import { setInterval } from "node:timers/promises";
import { flattenAttributes } from "@trigger.dev/core/v3";
export type ReleaseConcurrencyQueueRetryOptions = {
maxRetries?: number;
@@ -15,7 +16,10 @@ export type ReleaseConcurrencyQueueRetryOptions = {
export type ReleaseConcurrencyQueueOptions<T> = {
redis: RedisOptions;
executor: (releaseQueue: T, releaserId: string) => Promise<void>;
/**
* @returns true if the run was successful, false if the token should be returned to the bucket
*/
executor: (releaseQueue: T, releaserId: string) => Promise<boolean>;
keys: {
fromDescriptor: (releaseQueue: T) => string;
toDescriptor: (releaseQueue: string) => T;
@@ -78,6 +82,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
if (!options.disableConsumers) {
this.#startConsumers();
this.#startMetricsProducer();
}
}
@@ -119,7 +124,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
String(Date.now())
);
this.logger.debug("Consumed token in attemptToRelease", {
this.logger.info("Consumed token in attemptToRelease", {
releaseQueueDescriptor,
releaserId,
maxTokens,
@@ -270,7 +275,7 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
return false;
}
await Promise.all(
await Promise.allSettled(
result.map(([queue, releaserId, metadata]) => {
const itemMetadata = QueueItemMetadata.parse(JSON.parse(metadata));
const releaseQueueDescriptor = this.keys.toDescriptor(queue);
@@ -283,9 +288,29 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
async #callExecutor(releaseQueueDescriptor: T, releaserId: string, metadata: QueueItemMetadata) {
try {
this.logger.info("Executing run:", { releaseQueueDescriptor, releaserId });
this.logger.info("Calling executor for release", { releaseQueueDescriptor, releaserId });
await this.options.executor(releaseQueueDescriptor, releaserId);
const released = await this.options.executor(releaseQueueDescriptor, releaserId);
if (released) {
this.logger.info("Executor released concurrency", { releaseQueueDescriptor, releaserId });
} else {
this.logger.info("Executor did not release concurrency", {
releaseQueueDescriptor,
releaserId,
});
// Return the token but don't requeue
const releaseQueue = this.keys.fromDescriptor(releaseQueueDescriptor);
await this.redis.returnTokenOnly(
this.masterQueuesKey,
this.#bucketKey(releaseQueue),
this.#queueKey(releaseQueue),
this.#metadataKey(releaseQueue),
releaseQueue,
releaserId
);
}
} catch (error) {
this.logger.error("Error executing run:", { error });
@@ -374,6 +399,30 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
}
}
async #startMetricsProducer() {
try {
// Produce metrics every 60 seconds, using a tracer span
for await (const _ of setInterval(60_000)) {
const metrics = await this.getQueueMetrics();
this.logger.info("Queue metrics:", { metrics });
await startSpan(
this.options.tracer,
"ReleaseConcurrencyTokenBucketQueue.metrics",
async (span) => {},
{
attributes: {
...flattenAttributes(metrics, "queues"),
forceRecording: true,
},
}
);
}
} catch (error) {
this.logger.error("Error starting metrics producer:", { error });
}
}
#calculateBackoffScore(item: QueueItemMetadata): string {
const delay = Math.min(
this.backoff.maxDelay,
@@ -382,6 +431,137 @@ export class ReleaseConcurrencyTokenBucketQueue<T> {
return String(Date.now() + delay);
}
async getQueueMetrics(): Promise<
Array<{ releaseQueue: string; currentTokens: number; queueLength: number }>
> {
const streamRedis = this.redis.duplicate();
const queuePattern = `${this.keyPrefix}*:queue`;
const stream = streamRedis.scanStream({
match: queuePattern,
type: "zset",
count: 100,
});
let resolvePromise: (
value: Array<{ releaseQueue: string; currentTokens: number; queueLength: number }>
) => void;
let rejectPromise: (reason?: any) => void;
const promise = new Promise<
Array<{ releaseQueue: string; currentTokens: number; queueLength: number }>
>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
const metrics: Map<
string,
{ releaseQueue: string; currentTokens: number; queueLength: number }
> = new Map();
async function getMetricsForKeys(queueKeys: string[]) {
if (queueKeys.length === 0) {
return [];
}
const pipeline = streamRedis.pipeline();
queueKeys.forEach((queueKey) => {
const releaseQueue = queueKey
.replace(":queue", "")
.replace(streamRedis.options.keyPrefix ?? "", "");
const bucketKey = `${releaseQueue}:bucket`;
pipeline.get(bucketKey);
pipeline.zcard(`${releaseQueue}:queue`);
});
const result = await pipeline.exec();
if (!result) {
return [];
}
const results = result.map(([resultError, queueLengthOrCurrentTokens]) => {
if (resultError) {
return null;
}
return queueLengthOrCurrentTokens ? Number(queueLengthOrCurrentTokens) : 0;
});
// Now zip the results with the queue keys
const zippedResults = queueKeys.map((queueKey, index) => {
const releaseQueue = queueKey
.replace(":queue", "")
.replace(streamRedis.options.keyPrefix ?? "", "");
// Current tokens are at indexes 0, 2, 4, 6, etc.
// Queue length are at indexes 1, 3, 5, 7, etc.
const currentTokens = results[index * 2];
const queueLength = results[index * 2 + 1];
if (typeof currentTokens !== "number" || typeof queueLength !== "number") {
return null;
}
return {
releaseQueue,
currentTokens: currentTokens,
queueLength: queueLength,
};
});
return zippedResults.filter((result) => result !== null);
}
stream.on("end", () => {
streamRedis.quit();
resolvePromise(Array.from(metrics.values()));
});
stream.on("error", (error) => {
this.logger.error("Error getting queue metrics:", { error });
stream.pause();
streamRedis.quit();
rejectPromise(error);
});
stream.on("data", async (keys) => {
stream.pause();
const uniqueKeys = Array.from(new Set<string>(keys));
if (uniqueKeys.length === 0) {
stream.resume();
return;
}
const unresolvedKeys = uniqueKeys.filter((key) => !metrics.has(key));
if (unresolvedKeys.length === 0) {
stream.resume();
return;
}
this.logger.debug("Fetching queue metrics for keys", { keys: uniqueKeys });
await getMetricsForKeys(unresolvedKeys).then((results) => {
results.forEach((result) => {
if (result) {
metrics.set(result.releaseQueue, result);
}
});
stream.resume();
});
});
return promise;
}
#registerCommands() {
this.redis.defineCommand("consumeToken", {
numberOfKeys: 4,
@@ -401,7 +581,9 @@ local currentTokens = tonumber(redis.call("GET", bucketKey) or maxTokens)
-- If we have enough tokens, then consume them
if currentTokens >= 1 then
redis.call("SET", bucketKey, currentTokens - 1)
local newCurrentTokens = currentTokens - 1
redis.call("SET", bucketKey, newCurrentTokens)
redis.call("ZREM", queueKey, releaserId)
-- Clean up metadata when successfully consuming
@@ -411,8 +593,8 @@ if currentTokens >= 1 then
local queueLength = redis.call("ZCARD", queueKey)
-- If we still have tokens and items in queue, update available queues
if currentTokens > 0 and queueLength > 0 then
redis.call("ZADD", masterQueuesKey, currentTokens, releaseQueue)
if newCurrentTokens > 0 and queueLength > 0 then
redis.call("ZADD", masterQueuesKey, newCurrentTokens, releaseQueue)
else
redis.call("ZREM", masterQueuesKey, releaseQueue)
end
@@ -104,9 +104,9 @@ export class ReleaseConcurrencySystem {
);
}
public async executeReleaseConcurrencyForSnapshot(snapshotId: string) {
public async executeReleaseConcurrencyForSnapshot(snapshotId: string): Promise<boolean> {
if (!this.releaseConcurrencyQueue) {
return;
return false;
}
this.$.logger.debug("Executing released concurrency", {
@@ -136,14 +136,14 @@ export class ReleaseConcurrencySystem {
snapshotId,
});
return;
return false;
}
// - Runlock the run
// - Get latest snapshot
// - If the run is non suspended or going to be, then bail
// - If the run is suspended or going to be, then release the concurrency
await this.$.runLock.lock([snapshot.runId], 5_000, async () => {
return await this.$.runLock.lock([snapshot.runId], 5_000, async () => {
const latestSnapshot = await getLatestExecutionSnapshot(this.$.prisma, snapshot.runId);
const isValidSnapshot =
@@ -159,7 +159,7 @@ export class ReleaseConcurrencySystem {
snapshot,
});
return;
return false;
}
if (!canReleaseConcurrency(latestSnapshot.executionStatus)) {
@@ -168,20 +168,21 @@ export class ReleaseConcurrencySystem {
snapshot: latestSnapshot,
});
return;
return false;
}
const metadata = this.#parseMetadata(snapshot.metadata);
if (typeof metadata.releaseConcurrency === "boolean") {
if (metadata.releaseConcurrency) {
return await this.$.runQueue.releaseAllConcurrency(
snapshot.organizationId,
snapshot.runId
);
await this.$.runQueue.releaseAllConcurrency(snapshot.organizationId, snapshot.runId);
return true;
}
return await this.$.runQueue.releaseEnvConcurrency(snapshot.organizationId, snapshot.runId);
await this.$.runQueue.releaseEnvConcurrency(snapshot.organizationId, snapshot.runId);
return true;
}
// Get the locked queue
@@ -198,10 +199,14 @@ export class ReleaseConcurrencySystem {
(typeof taskQueue.concurrencyLimit === "undefined" ||
taskQueue.releaseConcurrencyOnWaitpoint)
) {
return await this.$.runQueue.releaseAllConcurrency(snapshot.organizationId, snapshot.runId);
await this.$.runQueue.releaseAllConcurrency(snapshot.organizationId, snapshot.runId);
return true;
}
return await this.$.runQueue.releaseEnvConcurrency(snapshot.organizationId, snapshot.runId);
await this.$.runQueue.releaseEnvConcurrency(snapshot.organizationId, snapshot.runId);
return true;
});
}
@@ -20,6 +20,7 @@ function createReleaseConcurrencyQueue(
},
executor: async (releaseQueue, runId) => {
executedRuns.push({ releaseQueue: releaseQueue.name, runId });
return true;
},
maxTokens: async (_) => maxTokens,
keys: {
@@ -221,6 +222,7 @@ describe("ReleaseConcurrencyQueue", () => {
throw new Error("Executor failed");
}
executedRuns.push({ releaseQueue, runId });
return true;
},
maxTokens: async (_) => 2,
keys: {
@@ -299,6 +301,7 @@ describe("ReleaseConcurrencyQueue", () => {
// Add small delay to simulate work
await setTimeout(10);
executedRuns.push({ releaseQueue, runId });
return true;
},
keys: {
fromDescriptor: (descriptor) => descriptor,
@@ -419,6 +422,7 @@ describe("ReleaseConcurrencyQueue", () => {
},
executor: async (releaseQueue, runId) => {
secondRunAttempted = true;
return true;
},
keys: {
fromDescriptor: (descriptor) => descriptor,
@@ -516,6 +520,63 @@ describe("ReleaseConcurrencyQueue", () => {
}
});
redisTest(
"Should return token but not requeue when executor returns false",
async ({ redisContainer }) => {
const executedRuns: { releaseQueue: string; runId: string }[] = [];
const runResults: Record<string, boolean> = {
run1: true, // This will succeed
run2: false, // This will return false, returning the token without requeuing
run3: true, // This should execute immediately when run2's token is returned
};
const queue = new ReleaseConcurrencyTokenBucketQueue<string>({
redis: {
keyPrefix: "release-queue:test:",
host: redisContainer.getHost(),
port: redisContainer.getPort(),
},
executor: async (releaseQueue, runId) => {
const success = runResults[runId];
executedRuns.push({ releaseQueue, runId });
return success;
},
keys: {
fromDescriptor: (descriptor) => descriptor,
toDescriptor: (name) => name,
},
maxTokens: async (_) => 2, // Only 2 tokens available at a time
pollInterval: 100,
});
try {
// First run should execute and succeed
await queue.attemptToRelease("test-queue", "run1");
expect(executedRuns).toHaveLength(1);
expect(executedRuns[0]).toEqual({ releaseQueue: "test-queue", runId: "run1" });
// Second run should execute but return false, returning the token
await queue.attemptToRelease("test-queue", "run2");
expect(executedRuns).toHaveLength(2);
expect(executedRuns[1]).toEqual({ releaseQueue: "test-queue", runId: "run2" });
// Third run should be able to execute immediately since run2 returned its token
await queue.attemptToRelease("test-queue", "run3");
expect(executedRuns).toHaveLength(3);
expect(executedRuns[2]).toEqual({ releaseQueue: "test-queue", runId: "run3" });
// Verify that run2 was not retried (it should have been skipped)
const run2Attempts = executedRuns.filter((r) => r.runId === "run2");
expect(run2Attempts).toHaveLength(1); // Only executed once, not retried
} finally {
await queue.quit();
}
}
);
redisTest("Should implement exponential backoff between retries", async ({ redisContainer }) => {
const executionTimes: number[] = [];
let startTime: number;
@@ -618,4 +679,38 @@ describe("ReleaseConcurrencyQueue", () => {
await queue.quit();
}
});
redisTest(
"Should retrieve metrics for all queues via getQueueMetrics",
async ({ redisContainer }) => {
const { queue } = createReleaseConcurrencyQueue(redisContainer, 1);
// Set up multiple queues with different states
await queue.attemptToRelease({ name: "metrics-queue1" }, "run1"); // Consume 1 token from queue1
// Add more items to queue1 that will be queued due to no tokens
await queue.attemptToRelease({ name: "metrics-queue1" }, "run2"); // This will be queued
await queue.attemptToRelease({ name: "metrics-queue1" }, "run3"); // This will be queued
await queue.attemptToRelease({ name: "metrics-queue1" }, "run4"); // This will be queued
const metrics = await queue.getQueueMetrics();
expect(metrics).toHaveLength(1);
expect(metrics[0].releaseQueue).toBe("metrics-queue1");
expect(metrics[0].currentTokens).toBe(0);
expect(metrics[0].queueLength).toBe(3);
// Now add 10 items to 100 different queues
for (let i = 0; i < 100; i++) {
for (let j = 0; j < 10; j++) {
await queue.attemptToRelease({ name: `metrics-queue2-${i}` }, `run${i}-${j}`);
}
}
const metrics2 = await queue.getQueueMetrics();
expect(metrics2.length).toBeGreaterThan(90);
await queue.quit();
}
);
});