From 8b3551c9e2c870a513e9c69910a20ce051f4af1e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Mar 2025 21:53:37 +0000 Subject: [PATCH 01/38] wip reserve concurrency system --- internal-packages/redis-worker/src/worker.ts | 2 +- .../run-engine/src/engine/index.ts | 14 + .../run-engine/src/run-queue/index.ts | 434 ++++++++++++-- .../run-engine/src/run-queue/keyProducer.ts | 46 +- .../run-queue/tests/enqueueMessage.test.ts | 553 ++++++++++++++++++ .../run-engine/src/run-queue/types.ts | 4 + 6 files changed, 1001 insertions(+), 52 deletions(-) create mode 100644 internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts diff --git a/internal-packages/redis-worker/src/worker.ts b/internal-packages/redis-worker/src/worker.ts index d4fca68c6d..c4a5a2edc0 100644 --- a/internal-packages/redis-worker/src/worker.ts +++ b/internal-packages/redis-worker/src/worker.ts @@ -365,7 +365,7 @@ class Worker { if (err) { this.logger.error(`Failed to subscribe to ${channel}`, { error: err }); } else { - this.logger.log(`Subscribed to ${channel}`); + this.logger.debug(`Subscribed to ${channel}`); } }); diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 46556b9588..8c849ec6a6 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -2114,6 +2114,13 @@ export class RunEngine { }); } + // Add releaseConcurrencyIfSuspendedOrGoingToBeSuspended + // - Called from blockRunWithWaitpoint when releaseConcurrency exists + // - 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 + /** This completes a waitpoint and updates all entries so the run isn't blocked, * if they're no longer blocked. This doesn't suffer from race conditions. */ async completeWaitpoint({ @@ -2212,6 +2219,10 @@ export class RunEngine { return result.waitpoint; } + /** + * This gets called AFTER the checkpoint has been created + * The CPU/Memory checkpoint at this point exists in our snapshot storage + */ async createCheckpoint({ runId, snapshotId, @@ -2343,6 +2354,9 @@ export class RunEngine { }); } + /** + * This is called when a run has been restored from a checkpoint and is ready to start executing again + */ async continueRunExecution({ runId, snapshotId, diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index dcedf121ec..643f388863 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -52,6 +52,11 @@ export type RunQueueOptions = { retryOptions?: RetryOptions; }; +export type RunQueueReserveConcurrencyOptions = { + messageId: string; + recursiveQueue: boolean; +}; + type DequeuedMessage = { messageId: string; messageScore: string; @@ -258,6 +263,14 @@ export class RunQueue { return this.redis.scard(this.keys.envCurrentConcurrencyKey(env)); } + public async reserveConcurrencyOfEnvironment(env: MinimalAuthenticatedEnvironment) { + return this.redis.scard(this.keys.envReserveConcurrencyKey(env)); + } + + public async reserveConcurrencyOfQueue(env: MinimalAuthenticatedEnvironment, queue: string) { + return this.redis.scard(this.keys.reserveConcurrencyKey(env, queue)); + } + public async currentConcurrencyOfProject(env: MinimalAuthenticatedEnvironment) { return this.redis.scard(this.keys.projectCurrentConcurrencyKey(env)); } @@ -273,10 +286,12 @@ export class RunQueue { env, message, masterQueues, + reserveConcurrency, }: { env: MinimalAuthenticatedEnvironment; message: InputPayload; masterQueues: string | string[]; + reserveConcurrency?: RunQueueReserveConcurrencyOptions; }) { return await this.#trace( "enqueueMessage", @@ -304,7 +319,7 @@ export class RunQueue { attempt: 0, }; - await this.#callEnqueueMessage(messagePayload, parentQueues); + return await this.#callEnqueueMessage(messagePayload, parentQueues, reserveConcurrency); }, { kind: SpanKind.PRODUCER, @@ -829,38 +844,162 @@ export class RunQueue { ); } - async #callEnqueueMessage(message: OutputPayload, masterQueues: string[]) { - const concurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); - const envConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); - const taskConcurrencyKey = this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( + async #callEnqueueMessage( + message: OutputPayload, + masterQueues: string[], + reserveConcurrency?: RunQueueReserveConcurrencyOptions + ) { + const queueKey = message.queue; + const messageKey = this.keys.messageKey(message.orgId, message.runId); + const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); + const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(message.queue); + const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); + const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(message.queue); + const taskCurrentConcurrencyKey = this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( message.queue, message.taskIdentifier ); - const projectConcurrencyKey = this.keys.projectCurrentConcurrencyKeyFromQueue(message.queue); + const projectCurrentConcurrencyKey = this.keys.projectCurrentConcurrencyKeyFromQueue( + message.queue + ); + const envQueueKey = this.keys.envQueueKeyFromQueue(message.queue); + + const queueName = message.queue; + const messageId = message.runId; + const messageData = JSON.stringify(message); + const messageScore = String(message.timestamp); + const $masterQueues = JSON.stringify(masterQueues); + const keyPrefix = this.options.redis.keyPrefix ?? ""; + + if (!reserveConcurrency) { + this.logger.debug("Calling enqueueMessage", { + queueKey, + messageKey, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + taskCurrentConcurrencyKey, + projectCurrentConcurrencyKey, + envQueueKey, + queueName, + messageId, + messageData, + messageScore, + masterQueues: $masterQueues, + service: this.name, + }); - this.logger.debug("Calling enqueueMessage", { - messagePayload: message, - concurrencyKey, - envConcurrencyKey, - masterQueues, - service: this.name, - }); + await this.redis.enqueueMessage( + queueKey, + messageKey, + queueCurrentConcurrencyKey, + queueReserveConcurrencyKey, + envCurrentConcurrencyKey, + envReserveConcurrencyKey, + taskCurrentConcurrencyKey, + projectCurrentConcurrencyKey, + envQueueKey, + queueName, + messageId, + messageData, + messageScore, + $masterQueues, + keyPrefix + ); - return this.redis.enqueueMessage( - message.queue, - this.keys.messageKey(message.orgId, message.runId), - concurrencyKey, - envConcurrencyKey, - taskConcurrencyKey, - projectConcurrencyKey, - this.keys.envQueueKeyFromQueue(message.queue), - message.queue, - message.runId, - JSON.stringify(message), - String(message.timestamp), - JSON.stringify(masterQueues), - this.options.redis.keyPrefix ?? "" - ); + return true; + } + + const envConcurrencyLimitKey = this.keys.envConcurrencyLimitKeyFromQueue(message.queue); + const reserveMessageId = reserveConcurrency.messageId; + const defaultEnvConcurrencyLimit = String(this.options.defaultEnvConcurrency); + + if (!reserveConcurrency.recursiveQueue) { + this.logger.debug("Calling enqueueMessageWithReservingConcurrency", { + service: this.name, + queueKey, + messageKey, + queueCurrentConcurrencyKey, + queueReserveConcurrencyKey, + envCurrentConcurrencyKey, + envReserveConcurrencyKey, + envConcurrencyLimitKey, + envQueueKey, + queueName, + messageId, + messageData, + messageScore, + reserveMessageId, + defaultEnvConcurrencyLimit, + }); + + await this.redis.enqueueMessageWithReservingConcurrency( + queueKey, + messageKey, + queueCurrentConcurrencyKey, + queueReserveConcurrencyKey, + envCurrentConcurrencyKey, + envReserveConcurrencyKey, + envConcurrencyLimitKey, + taskCurrentConcurrencyKey, + projectCurrentConcurrencyKey, + envQueueKey, + queueName, + messageId, + messageData, + messageScore, + $masterQueues, + keyPrefix, + reserveMessageId, + defaultEnvConcurrencyLimit + ); + + return true; + } else { + const queueConcurrencyLimitKey = this.keys.concurrencyLimitKeyFromQueue(message.queue); + + this.logger.debug("Calling enqueueMessageWithReservingConcurrencyOnRecursiveQueue", { + service: this.name, + queueKey, + messageKey, + queueCurrentConcurrencyKey, + queueReserveConcurrencyKey, + queueConcurrencyLimitKey, + envCurrentConcurrencyKey, + envReserveConcurrencyKey, + envConcurrencyLimitKey, + envQueueKey, + queueName, + messageId, + messageData, + messageScore, + reserveMessageId, + defaultEnvConcurrencyLimit, + }); + + const result = await this.redis.enqueueMessageWithReservingConcurrencyOnRecursiveQueue( + queueKey, + messageKey, + queueCurrentConcurrencyKey, + queueReserveConcurrencyKey, + queueConcurrencyLimitKey, + envCurrentConcurrencyKey, + envReserveConcurrencyKey, + envConcurrencyLimitKey, + taskCurrentConcurrencyKey, + projectCurrentConcurrencyKey, + envQueueKey, + queueName, + messageId, + messageData, + messageScore, + $masterQueues, + keyPrefix, + reserveMessageId, + defaultEnvConcurrencyLimit + ); + + return !!result; + } } async #callDequeueMessage({ @@ -1006,15 +1145,17 @@ export class RunQueue { #registerCommands() { this.redis.defineCommand("enqueueMessage", { - numberOfKeys: 7, + numberOfKeys: 9, lua: ` -local queue = KEYS[1] +local queueKey = KEYS[1] local messageKey = KEYS[2] -local concurrencyKey = KEYS[3] -local envConcurrencyKey = KEYS[4] -local taskConcurrencyKey = KEYS[5] -local projectConcurrencyKey = KEYS[6] -local envQueueKey = KEYS[7] +local queueCurrentConcurrencyKey = KEYS[3] +local queueReserveConcurrencyKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local envReserveConcurrencyKey = KEYS[6] +local taskCurrentConcurrencyKey = KEYS[7] +local projectCurrentConcurrencyKey = KEYS[8] +local envQueueKey = KEYS[9] local queueName = ARGV[1] local messageId = ARGV[2] @@ -1027,13 +1168,13 @@ local keyPrefix = ARGV[6] redis.call('SET', messageKey, messageData) -- Add the message to the queue -redis.call('ZADD', queue, messageScore, messageId) +redis.call('ZADD', queueKey, messageScore, messageId) -- Add the message to the env queue redis.call('ZADD', envQueueKey, messageScore, messageId) -- Rebalance the parent queues -local earliestMessage = redis.call('ZRANGE', queue, 0, 0, 'WITHSCORES') +local earliestMessage = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') for _, parentQueue in ipairs(parentQueues) do local prefixedParentQueue = keyPrefix .. parentQueue @@ -1045,10 +1186,162 @@ for _, parentQueue in ipairs(parentQueues) do end -- Update the concurrency keys -redis.call('SREM', concurrencyKey, messageId) -redis.call('SREM', envConcurrencyKey, messageId) -redis.call('SREM', taskConcurrencyKey, messageId) -redis.call('SREM', projectConcurrencyKey, messageId) +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', taskCurrentConcurrencyKey, messageId) +redis.call('SREM', projectCurrentConcurrencyKey, messageId) +redis.call('SREM', envReserveConcurrencyKey, messageId) +redis.call('SREM', queueReserveConcurrencyKey, messageId) + +return true + `, + }); + + this.redis.defineCommand("enqueueMessageWithReservingConcurrency", { + numberOfKeys: 10, + lua: ` +local queueKey = KEYS[1] +local messageKey = KEYS[2] +local queueCurrentConcurrencyKey = KEYS[3] +local queueReserveConcurrencyKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local envReserveConcurrencyKey = KEYS[6] +local envConcurrencyLimitKey = KEYS[7] +local taskCurrentConcurrencyKey = KEYS[8] +local projectCurrentConcurrencyKey = KEYS[9] +local envQueueKey = KEYS[10] + +local queueName = ARGV[1] +local messageId = ARGV[2] +local messageData = ARGV[3] +local messageScore = ARGV[4] +local parentQueues = cjson.decode(ARGV[5]) +local keyPrefix = ARGV[6] +local reserveMessageId = ARGV[7] +local defaultEnvConcurrencyLimit = ARGV[8] + +-- Write the message to the message key +redis.call('SET', messageKey, messageData) + +-- Add the message to the queue +redis.call('ZADD', queueKey, messageScore, messageId) + +-- Add the message to the env queue +redis.call('ZADD', envQueueKey, messageScore, messageId) + +-- Rebalance the parent queues +local earliestMessage = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') + +for _, parentQueue in ipairs(parentQueues) do + local prefixedParentQueue = keyPrefix .. parentQueue + if #earliestMessage == 0 then + redis.call('ZREM', prefixedParentQueue, queueName) + else + redis.call('ZADD', prefixedParentQueue, earliestMessage[2], queueName) + end +end + +-- Update the concurrency keys +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', taskCurrentConcurrencyKey, messageId) +redis.call('SREM', projectCurrentConcurrencyKey, messageId) +redis.call('SREM', envReserveConcurrencyKey, messageId) +redis.call('SREM', queueReserveConcurrencyKey, messageId) + +-- Reserve the concurrency for the message +local envReserveConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) +-- Count the number of messages in the reserve concurrency set +local envReserveConcurrency = tonumber(redis.call('SCARD', envReserveConcurrencyKey) or '0') + +-- If there is space, add the messageId to the env reserve concurrency set +if envReserveConcurrency < envReserveConcurrencyLimit then + redis.call('SADD', envReserveConcurrencyKey, reserveMessageId) +end + +return true + `, + }); + + this.redis.defineCommand("enqueueMessageWithReservingConcurrencyOnRecursiveQueue", { + numberOfKeys: 11, + lua: ` +local queueKey = KEYS[1] +local messageKey = KEYS[2] +local queueCurrentConcurrencyKey = KEYS[3] +local queueReserveConcurrencyKey = KEYS[4] +local queueConcurrencyLimitKey = KEYS[5] +local envCurrentConcurrencyKey = KEYS[6] +local envReserveConcurrencyKey = KEYS[7] +local envConcurrencyLimitKey = KEYS[8] +local taskCurrentConcurrencyKey = KEYS[9] +local projectCurrentConcurrencyKey = KEYS[10] +local envQueueKey = KEYS[11] + +local queueName = ARGV[1] +local messageId = ARGV[2] +local messageData = ARGV[3] +local messageScore = ARGV[4] +local parentQueues = cjson.decode(ARGV[5]) +local keyPrefix = ARGV[6] +local reserveMessageId = ARGV[7] +local defaultEnvConcurrencyLimit = ARGV[8] + +-- Get the env reserve concurrency limit because we need it to calculate the max reserve concurrency +-- for the specific queue +local envReserveConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) + +-- Count the number of messages in the queue reserve concurrency set +local queueReserveConcurrency = tonumber(redis.call('SCARD', queueReserveConcurrencyKey) or '0') +local queueConcurrencyLimit = tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000') + +local queueReserveConcurrencyLimit = math.min(queueConcurrencyLimit, envReserveConcurrencyLimit) + +-- If we cannot add the reserve concurrency, then we have to return false +if queueReserveConcurrency >= queueReserveConcurrencyLimit then + return false +end + +-- Write the message to the message key +redis.call('SET', messageKey, messageData) + +-- Add the message to the queue +redis.call('ZADD', queueKey, messageScore, messageId) + +-- Add the message to the env queue +redis.call('ZADD', envQueueKey, messageScore, messageId) + +-- Rebalance the parent queues +local earliestMessage = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') + +for _, parentQueue in ipairs(parentQueues) do + local prefixedParentQueue = keyPrefix .. parentQueue + if #earliestMessage == 0 then + redis.call('ZREM', prefixedParentQueue, queueName) + else + redis.call('ZADD', prefixedParentQueue, earliestMessage[2], queueName) + end +end + +-- Update the concurrency keys +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', taskCurrentConcurrencyKey, messageId) +redis.call('SREM', projectCurrentConcurrencyKey, messageId) +redis.call('SREM', envReserveConcurrencyKey, messageId) +redis.call('SREM', queueReserveConcurrencyKey, messageId) + +-- Count the number of messages in the env reserve concurrency set +local envReserveConcurrency = tonumber(redis.call('SCARD', envReserveConcurrencyKey) or '0') + +-- If there is space, add the messaageId to the env reserve concurrency set +if envReserveConcurrency < envReserveConcurrencyLimit then + redis.call('SADD', envReserveConcurrencyKey, reserveMessageId) +end + +redis.call('SADD', queueReserveConcurrencyKey, reserveMessageId) + +return true `, }); @@ -1333,10 +1626,12 @@ declare module "@internal/redis" { //keys queue: string, messageKey: string, - concurrencyKey: string, - envConcurrencyKey: string, - taskConcurrencyKey: string, - projectConcurrencyKey: string, + queueCurrentConcurrencyKey: string, + queueReserveConcurrencyKey: string, + envCurrentConcurrencyKey: string, + envReserveConcurrencyKey: string, + taskCurrentConcurrencyKey: string, + projectCurrentConcurrencyKey: string, envQueueKey: string, //args queueName: string, @@ -1348,6 +1643,55 @@ declare module "@internal/redis" { callback?: Callback ): Result; + enqueueMessageWithReservingConcurrency( + //keys + queue: string, + messageKey: string, + queueCurrentConcurrencyKey: string, + queueReserveConcurrencyKey: string, + envCurrentConcurrencyKey: string, + envReserveConcurrencyKey: string, + envConcurrencyLimitKey: string, + taskCurrentConcurrencyKey: string, + projectCurrentConcurrencyKey: string, + envQueueKey: string, + //args + queueName: string, + messageId: string, + messageData: string, + messageScore: string, + parentQueues: string, + keyPrefix: string, + reserveMessageId: string, + defaultEnvConcurrencyLimit: string, + callback?: Callback + ): Result; + + enqueueMessageWithReservingConcurrencyOnRecursiveQueue( + //keys + queue: string, + messageKey: string, + queueCurrentConcurrencyKey: string, + queueReserveConcurrencyKey: string, + queueConcurrencyLimitKey: string, + envCurrentConcurrencyKey: string, + envReserveConcurrencyKey: string, + envConcurrencyLimitKey: string, + taskCurrentConcurrencyKey: string, + projectCurrentConcurrencyKey: string, + envQueueKey: string, + //args + queueName: string, + messageId: string, + messageData: string, + messageScore: string, + parentQueues: string, + keyPrefix: string, + reserveMessageId: string, + defaultEnvConcurrencyLimit: string, + callback?: Callback + ): Result; + dequeueMessage( //keys childQueue: string, diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.ts b/internal-packages/run-engine/src/run-queue/keyProducer.ts index cebdacea5c..9da46930cb 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.ts @@ -1,5 +1,5 @@ import { MinimalAuthenticatedEnvironment } from "../shared/index.js"; -import { EnvDescriptor, RunQueueKeyProducer } from "./types.js"; +import { EnvDescriptor, QueueDescriptor, RunQueueKeyProducer } from "./types.js"; const constants = { CURRENT_CONCURRENCY_PART: "currentConcurrency", @@ -104,14 +104,23 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { ); } + reserveConcurrencyKey(env: MinimalAuthenticatedEnvironment, queue: string) { + return [this.queueKey(env, queue), constants.RESERVE_CONCURRENCY_PART].join(":"); + } + disabledConcurrencyLimitKeyFromQueue(queue: string) { const { orgId } = this.descriptorFromQueue(queue); return `{${constants.ORG_PART}:${orgId}}:${constants.DISABLED_CONCURRENCY_LIMIT_PART}`; } envConcurrencyLimitKeyFromQueue(queue: string) { - const { orgId, envId } = this.descriptorFromQueue(queue); - return `{${constants.ORG_PART}:${orgId}}:${constants.ENV_PART}:${envId}:${constants.CONCURRENCY_LIMIT_PART}`; + const { orgId, projectId, envId } = this.descriptorFromQueue(queue); + + return this.envConcurrencyLimitKey({ + orgId, + projectId, + envId, + }); } envCurrentConcurrencyKeyFromQueue(queue: string) { @@ -169,9 +178,14 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { } taskIdentifierCurrentConcurrencyKeyPrefixFromQueue(queue: string) { - const { orgId, projectId } = this.descriptorFromQueue(queue); + const { orgId, envId, projectId } = this.descriptorFromQueue(queue); - return `${[this.orgKeySection(orgId), this.projKeySection(projectId), constants.TASK_PART] + return `${[ + this.orgKeySection(orgId), + this.envKeySection(envId), + this.projKeySection(projectId), + constants.TASK_PART, + ] .filter(Boolean) .join(":")}:`; } @@ -187,6 +201,7 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { return [ this.orgKeySection(env.organization.id), this.projKeySection(env.project.id), + this.envKeySection(env.id), constants.TASK_PART, taskIdentifier, ].join(":"); @@ -230,7 +245,26 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { return this.descriptorFromQueue(queue).projectId; } - descriptorFromQueue(queue: string) { + reserveConcurrencyKeyFromQueue(queue: string) { + const descriptor = this.descriptorFromQueue(queue); + + return this.queueReserveConcurrencyKeyFromDescriptor(descriptor); + } + + envReserveConcurrencyKeyFromQueue(queue: string) { + const descriptor = this.descriptorFromQueue(queue); + + return this.envReserveConcurrencyKey(descriptor); + } + + private queueReserveConcurrencyKeyFromDescriptor(descriptor: QueueDescriptor) { + return [ + this.queueKey(descriptor.orgId, descriptor.projectId, descriptor.envId, descriptor.queue), + constants.RESERVE_CONCURRENCY_PART, + ].join(":"); + } + + descriptorFromQueue(queue: string): QueueDescriptor { const parts = queue.split(":"); return { orgId: parts[1].replace("{", "").replace("}", ""), diff --git a/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts b/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts new file mode 100644 index 0000000000..dee0bdaaf1 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts @@ -0,0 +1,553 @@ +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { describe } from "node:test"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import { InputPayload } from "../types.js"; + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + logger: new Logger("RunQueue", "warn"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys: new RunQueueFullKeyProducer(), +}; + +const authenticatedEnvDev = { + id: "e1234", + type: "DEVELOPMENT" as const, + maximumConcurrencyLimit: 10, + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +const messageDev: InputPayload = { + runId: "r4321", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e4321", + environmentType: "DEVELOPMENT", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, +}; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("RunQueue.enqueueMessage", () => { + redisTest("enqueueMessage with no reserved concurrency", async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + //initial queue length + const result = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(result).toBe(0); + const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength).toBe(0); + + //initial oldest message + const oldestScore = await queue.oldestMessageInQueue(authenticatedEnvDev, messageDev.queue); + expect(oldestScore).toBe(undefined); + + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + //enqueue message + const enqueueResult = await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + }); + + expect(enqueueResult).toBe(true); + + //queue length + const result2 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(result2).toBe(1); + + const envQueueLength2 = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength2).toBe(1); + + //oldest message + const oldestScore2 = await queue.oldestMessageInQueue(authenticatedEnvDev, messageDev.queue); + expect(oldestScore2).toBe(messageDev.timestamp); + + //concurrencies + const queueConcurrency = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrency).toBe(0); + + const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency).toBe(0); + + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(0); + + const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); + expect(projectConcurrency).toBe(0); + + const taskConcurrency = await queue.currentConcurrencyOfTask( + authenticatedEnvDev, + messageDev.taskIdentifier + ); + expect(taskConcurrency).toBe(0); + } finally { + await queue.quit(); + } + }); + + redisTest( + "enqueueMessage with non-recursive reserved concurrency adds to the environment's reserved concurrency", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + //initial queue length + const result = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(result).toBe(0); + const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength).toBe(0); + + //initial oldest message + const oldestScore = await queue.oldestMessageInQueue(authenticatedEnvDev, messageDev.queue); + expect(oldestScore).toBe(undefined); + + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + //enqueue message + const enqueueResult = await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: "r1234", + recursiveQueue: false, + }, + }); + + expect(enqueueResult).toBe(true); + + //queue length + const result2 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(result2).toBe(1); + + const envQueueLength2 = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength2).toBe(1); + + //oldest message + const oldestScore2 = await queue.oldestMessageInQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(oldestScore2).toBe(messageDev.timestamp); + + //concurrencies + const queueConcurrency = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrency).toBe(0); + + const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency).toBe(0); + + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(1); + + const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); + expect(projectConcurrency).toBe(0); + + const taskConcurrency = await queue.currentConcurrencyOfTask( + authenticatedEnvDev, + messageDev.taskIdentifier + ); + expect(taskConcurrency).toBe(0); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "enqueueMessage with recursive reserved concurrency adds to the environment and queue reserved concurrency", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + //initial queue length + const result = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(result).toBe(0); + const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength).toBe(0); + + //initial oldest message + const oldestScore = await queue.oldestMessageInQueue(authenticatedEnvDev, messageDev.queue); + expect(oldestScore).toBe(undefined); + + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + //enqueue message + const enqueueResult = await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: "r1234", + recursiveQueue: true, + }, + }); + + expect(enqueueResult).toBe(true); + + //queue length + const result2 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(result2).toBe(1); + + const envQueueLength2 = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength2).toBe(1); + + //oldest message + const oldestScore2 = await queue.oldestMessageInQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(oldestScore2).toBe(messageDev.timestamp); + + //concurrencies + const queueConcurrency = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrency).toBe(0); + + const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueReserveConcurrency).toBe(1); + + const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency).toBe(0); + + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(1); + + const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); + expect(projectConcurrency).toBe(0); + + const taskConcurrency = await queue.currentConcurrencyOfTask( + authenticatedEnvDev, + messageDev.taskIdentifier + ); + expect(taskConcurrency).toBe(0); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "enqueueMessage of a reserved message should clear the reserved concurrency", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + //initial queue length + const result = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(result).toBe(0); + const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength).toBe(0); + + //initial oldest message + const oldestScore = await queue.oldestMessageInQueue(authenticatedEnvDev, messageDev.queue); + expect(oldestScore).toBe(undefined); + + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + //enqueue message + const enqueueResult = await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: "r1234", + recursiveQueue: false, + }, + }); + + expect(enqueueResult).toBe(true); + + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(1); + + // enqueue reserve message + const enqueueResult2 = await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: { + ...messageDev, + runId: "r1234", + }, + masterQueues: ["main", envMasterQueue], + }); + + expect(enqueueResult2).toBe(true); + + const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrencyAfter).toBe(0); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "enqueueMessage with non-recursive reserved concurrency cannot exceed the environment's maximum concurrency limit", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + await queue.updateEnvConcurrencyLimits({ + ...authenticatedEnvDev, + maximumConcurrencyLimit: 1, + }); + + const envConcurrencyLimit = await queue.getEnvConcurrencyLimit(authenticatedEnvDev); + expect(envConcurrencyLimit).toBe(1); + + //enqueue message + const enqueueResult = await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: "r1234", + recursiveQueue: false, + }, + }); + + expect(enqueueResult).toBe(true); + + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(1); + + // enqueue another message with a non-recursive reserved concurrency + const enqueueResult2 = await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: { + ...messageDev, + runId: "rabc123", + }, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: "r12345678", + recursiveQueue: false, + }, + }); + + expect(enqueueResult2).toBe(true); + + const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrencyAfter).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "enqueueMessage with recursive reserved concurrency should fail if queue reserve concurrency will exceed the queue concurrency limit", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, messageDev.queue, 1); + + const envConcurrencyLimit = await queue.getQueueConcurrencyLimit( + authenticatedEnvDev, + messageDev.queue + ); + expect(envConcurrencyLimit).toBe(1); + + //enqueue message + const enqueueResult = await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: "r1234", + recursiveQueue: true, + }, + }); + + expect(enqueueResult).toBe(true); + + const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueReserveConcurrency).toBe(1); + + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(1); + + // enqueue another message with a non-recursive reserved concurrency + const enqueueResult2 = await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: { + ...messageDev, + runId: "rabc123", + }, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: "r12345678", + recursiveQueue: true, + }, + }); + + expect(enqueueResult2).toBe(false); + + const queueReserveConcurrencyAfter = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + + expect(queueReserveConcurrencyAfter).toBe(1); + + const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrencyAfter).toBe(1); + + const lengthOfQueue = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(lengthOfQueue).toBe(1); + + const lengthOfEnvQueue = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(lengthOfEnvQueue).toBe(1); + } finally { + await queue.quit(); + } + } + ); +}); diff --git a/internal-packages/run-engine/src/run-queue/types.ts b/internal-packages/run-engine/src/run-queue/types.ts index 0eaa048f78..2f005b28b7 100644 --- a/internal-packages/run-engine/src/run-queue/types.ts +++ b/internal-packages/run-engine/src/run-queue/types.ts @@ -57,6 +57,7 @@ export interface RunQueueKeyProducer { queue: string, concurrencyKey?: string ): string; + reserveConcurrencyKey(env: MinimalAuthenticatedEnvironment, queue: string): string; disabledConcurrencyLimitKeyFromQueue(queue: string): string; //env oncurrency envCurrentConcurrencyKey(env: EnvDescriptor): string; @@ -88,6 +89,9 @@ export interface RunQueueKeyProducer { envIdFromQueue(queue: string): string; projectIdFromQueue(queue: string): string; descriptorFromQueue(queue: string): QueueDescriptor; + + reserveConcurrencyKeyFromQueue(queue: string): string; + envReserveConcurrencyKeyFromQueue(queue: string): string; } export type EnvQueues = { From 16546f144fa5e243c75f2c31390139f4eeeb20d7 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 11 Mar 2025 10:50:15 +0000 Subject: [PATCH 02/38] dequeue message --- .vscode/launch.json | 2 +- .../run-engine/src/run-queue/index.ts | 126 +++-- .../run-engine/src/run-queue/keyProducer.ts | 2 +- .../src/run-queue/tests/ack.test.ts | 293 +++++++++++ .../dequeueMessageFromMasterQueue.test.ts | 465 ++++++++++++++++++ .../run-engine/tsconfig.test.json | 2 +- 6 files changed, 833 insertions(+), 57 deletions(-) create mode 100644 internal-packages/run-engine/src/run-queue/tests/ack.test.ts create mode 100644 internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index ab091cb534..2bd25ee36f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -146,7 +146,7 @@ "type": "node-terminal", "request": "launch", "name": "Debug RunQueue tests", - "command": "pnpm run test ./src/engine/tests/waitpoints.test.ts", + "command": "pnpm run test ./src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts", "cwd": "${workspaceFolder}/internal-packages/run-engine", "sourceMaps": true } diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 643f388863..81db047ff8 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -388,15 +388,6 @@ export class RunQueue { // Attempt to dequeue from this queue const message = await this.#callDequeueMessage({ messageQueue: queue, - concurrencyLimitKey: this.keys.concurrencyLimitKeyFromQueue(queue), - currentConcurrencyKey: this.keys.currentConcurrencyKeyFromQueue(queue), - envConcurrencyLimitKey: this.keys.envConcurrencyLimitKeyFromQueue(queue), - envCurrentConcurrencyKey: this.keys.envCurrentConcurrencyKeyFromQueue(queue), - projectCurrentConcurrencyKey: this.keys.projectCurrentConcurrencyKeyFromQueue(queue), - messageKeyPrefix: this.keys.messageKeyPrefixFromQueue(queue), - envQueueKey: this.keys.envQueueKeyFromQueue(queue), - taskCurrentConcurrentKeyPrefix: - this.keys.taskIdentifierCurrentConcurrencyKeyPrefixFromQueue(queue), }); if (message) { @@ -1004,32 +995,45 @@ export class RunQueue { async #callDequeueMessage({ messageQueue, - concurrencyLimitKey, - envConcurrencyLimitKey, - currentConcurrencyKey, - envCurrentConcurrencyKey, - projectCurrentConcurrencyKey, - messageKeyPrefix, - envQueueKey, - taskCurrentConcurrentKeyPrefix, }: { messageQueue: string; - concurrencyLimitKey: string; - envConcurrencyLimitKey: string; - currentConcurrencyKey: string; - envCurrentConcurrencyKey: string; - projectCurrentConcurrencyKey: string; - messageKeyPrefix: string; - envQueueKey: string; - taskCurrentConcurrentKeyPrefix: string; }): Promise { + const queueConcurrencyLimitKey = this.keys.concurrencyLimitKeyFromQueue(messageQueue); + const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(messageQueue); + const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(messageQueue); + const envConcurrencyLimitKey = this.keys.envConcurrencyLimitKeyFromQueue(messageQueue); + const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(messageQueue); + const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(messageQueue); + const projectCurrentConcurrencyKey = + this.keys.projectCurrentConcurrencyKeyFromQueue(messageQueue); + const messageKeyPrefix = this.keys.messageKeyPrefixFromQueue(messageQueue); + const envQueueKey = this.keys.envQueueKeyFromQueue(messageQueue); + const taskCurrentConcurrentKeyPrefix = + this.keys.taskIdentifierCurrentConcurrencyKeyPrefixFromQueue(messageQueue); + + this.logger.debug("#callDequeueMessage", { + messageQueue, + queueConcurrencyLimitKey, + envConcurrencyLimitKey, + queueCurrentConcurrencyKey, + queueReserveConcurrencyKey, + envCurrentConcurrencyKey, + envReserveConcurrencyKey, + projectCurrentConcurrencyKey, + messageKeyPrefix, + envQueueKey, + taskCurrentConcurrentKeyPrefix, + }); + const result = await this.redis.dequeueMessage( //keys messageQueue, - concurrencyLimitKey, + queueConcurrencyLimitKey, envConcurrencyLimitKey, - currentConcurrencyKey, + queueCurrentConcurrencyKey, + queueReserveConcurrencyKey, envCurrentConcurrencyKey, + envReserveConcurrencyKey, projectCurrentConcurrencyKey, messageKeyPrefix, envQueueKey, @@ -1346,19 +1350,21 @@ return true }); this.redis.defineCommand("dequeueMessage", { - numberOfKeys: 9, + numberOfKeys: 11, lua: ` -local childQueue = KEYS[1] -local concurrencyLimitKey = KEYS[2] +local queueKey = KEYS[1] +local queueConcurrencyLimitKey = KEYS[2] local envConcurrencyLimitKey = KEYS[3] -local currentConcurrencyKey = KEYS[4] -local envCurrentConcurrencyKey = KEYS[5] -local projectConcurrencyKey = KEYS[6] -local messageKeyPrefix = KEYS[7] -local envQueueKey = KEYS[8] -local taskCurrentConcurrentKeyPrefix = KEYS[9] +local queueCurrentConcurrencyKey = KEYS[4] +local queueReserveConcurrencyKey = KEYS[5] +local envCurrentConcurrencyKey = KEYS[6] +local envReserveConcurrencyKey = KEYS[7] +local projectCurrentConcurrencyKey = KEYS[8] +local messageKeyPrefix = KEYS[9] +local envQueueKey = KEYS[10] +local taskCurrentConcurrentKeyPrefix = KEYS[11] -local childQueueName = ARGV[1] +local queueName = ARGV[1] local currentTime = tonumber(ARGV[2]) local defaultEnvConcurrencyLimit = ARGV[3] local keyPrefix = ARGV[4] @@ -1366,22 +1372,26 @@ local keyPrefix = ARGV[4] -- Check current env concurrency against the limit local envCurrentConcurrency = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) +local envReserveConcurrency = tonumber(redis.call('SCARD', envReserveConcurrencyKey) or '0') +local totalEnvConcurrencyLimit = envConcurrencyLimit + envReserveConcurrency -if envCurrentConcurrency >= envConcurrencyLimit then +if envCurrentConcurrency >= totalEnvConcurrencyLimit then return nil end -- Check current queue concurrency against the limit -local currentConcurrency = tonumber(redis.call('SCARD', currentConcurrencyKey) or '0') -local concurrencyLimit = tonumber(redis.call('GET', concurrencyLimitKey) or '1000000') +local queueCurrentConcurrency = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') +local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) +local queueReserveConcurrency = tonumber(redis.call('SCARD', queueReserveConcurrencyKey) or '0') +local totalQueueConcurrencyLimit = queueConcurrencyLimit + queueReserveConcurrency -- Check condition only if concurrencyLimit exists -if currentConcurrency >= concurrencyLimit then +if queueCurrentConcurrency >= totalQueueConcurrencyLimit then return nil end -- Attempt to dequeue the next message -local messages = redis.call('ZRANGEBYSCORE', childQueue, '-inf', currentTime, 'WITHSCORES', 'LIMIT', 0, 1) +local messages = redis.call('ZRANGEBYSCORE', queueKey, '-inf', currentTime, 'WITHSCORES', 'LIMIT', 0, 1) if #messages == 0 then return nil @@ -1399,24 +1409,30 @@ local decodedPayload = cjson.decode(messagePayload); local taskIdentifier = decodedPayload.taskIdentifier -- Perform SADD with taskIdentifier and messageId -local taskConcurrencyKey = taskCurrentConcurrentKeyPrefix .. taskIdentifier +local taskCurrentConcurrencyKey = taskCurrentConcurrentKeyPrefix .. taskIdentifier -- Update concurrency -redis.call('ZREM', childQueue, messageId) +redis.call('ZREM', queueKey, messageId) redis.call('ZREM', envQueueKey, messageId) -redis.call('SADD', currentConcurrencyKey, messageId) +redis.call('SADD', queueCurrentConcurrencyKey, messageId) redis.call('SADD', envCurrentConcurrencyKey, messageId) -redis.call('SADD', projectConcurrencyKey, messageId) -redis.call('SADD', taskConcurrencyKey, messageId) +redis.call('SADD', projectCurrentConcurrencyKey, messageId) +redis.call('SADD', taskCurrentConcurrencyKey, messageId) + +-- Remove the message from the queue reserve concurrency set +redis.call('SREM', queueReserveConcurrencyKey, messageId) + +-- Remove the message from the env reserve concurrency set +redis.call('SREM', envReserveConcurrencyKey, messageId) -- Rebalance the parent queues -local earliestMessage = redis.call('ZRANGE', childQueue, 0, 0, 'WITHSCORES') +local earliestMessage = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') for _, parentQueue in ipairs(decodedPayload.masterQueues) do local prefixedParentQueue = keyPrefix .. parentQueue if #earliestMessage == 0 then - redis.call('ZREM', prefixedParentQueue, childQueueName) + redis.call('ZREM', prefixedParentQueue, queueName) else - redis.call('ZADD', prefixedParentQueue, earliestMessage[2], childQueueName) + redis.call('ZADD', prefixedParentQueue, earliestMessage[2], queueName) end end @@ -1695,11 +1711,13 @@ declare module "@internal/redis" { dequeueMessage( //keys childQueue: string, - concurrencyLimitKey: string, + queueConcurrencyLimitKey: string, envConcurrencyLimitKey: string, - currentConcurrencyKey: string, - envConcurrencyKey: string, - projectConcurrencyKey: string, + queueCurrentConcurrencyKey: string, + queueReserveConcurrencyKey: string, + envCurrentConcurrencyKey: string, + envReserveConcurrencyKey: string, + projectCurrentConcurrencyKey: string, messageKeyPrefix: string, envQueueKey: string, taskCurrentConcurrentKeyPrefix: string, diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.ts b/internal-packages/run-engine/src/run-queue/keyProducer.ts index 9da46930cb..1feac4a7c2 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.ts @@ -182,8 +182,8 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { return `${[ this.orgKeySection(orgId), - this.envKeySection(envId), this.projKeySection(projectId), + this.envKeySection(envId), constants.TASK_PART, ] .filter(Boolean) diff --git a/internal-packages/run-engine/src/run-queue/tests/ack.test.ts b/internal-packages/run-engine/src/run-queue/tests/ack.test.ts new file mode 100644 index 0000000000..56eb9e1204 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/ack.test.ts @@ -0,0 +1,293 @@ +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { describe } from "node:test"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import { InputPayload } from "../types.js"; + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + logger: new Logger("RunQueue", "warn"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys: new RunQueueFullKeyProducer(), +}; + +const authenticatedEnvDev = { + id: "e1234", + type: "DEVELOPMENT" as const, + maximumConcurrencyLimit: 10, + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +const messageDev: InputPayload = { + runId: "r4321", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e4321", + environmentType: "DEVELOPMENT", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, +}; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("RunQueue.acknowledgeMessage", () => { + redisTest("acknowledging a message clears all concurrency", async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + // Enqueue and dequeue a message to get it into processing + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + }); + + const dequeued = await queue.dequeueMessageFromMasterQueue("test_12345", envMasterQueue, 10); + expect(dequeued.length).toBe(1); + + // Verify concurrency is set + const queueConcurrency = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrency).toBe(1); + + const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency).toBe(1); + + const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); + expect(projectConcurrency).toBe(1); + + const taskConcurrency = await queue.currentConcurrencyOfTask( + authenticatedEnvDev, + messageDev.taskIdentifier + ); + expect(taskConcurrency).toBe(1); + + // Acknowledge the message + await queue.acknowledgeMessage(messageDev.orgId, messageDev.runId); + + // Verify all concurrency is cleared + const queueConcurrencyAfter = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrencyAfter).toBe(0); + + const envConcurrencyAfter = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrencyAfter).toBe(0); + + const projectConcurrencyAfter = await queue.currentConcurrencyOfProject(authenticatedEnvDev); + expect(projectConcurrencyAfter).toBe(0); + + const taskConcurrencyAfter = await queue.currentConcurrencyOfTask( + authenticatedEnvDev, + messageDev.taskIdentifier + ); + expect(taskConcurrencyAfter).toBe(0); + } finally { + await queue.quit(); + } + }); + + redisTest( + "acknowledging a message with reserve concurrency clears both current and reserve concurrency", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + // Enqueue message with reserve concurrency + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: messageDev.runId, + recursiveQueue: true, + }, + }); + + // Verify reserve concurrency is set + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(1); + + const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueReserveConcurrency).toBe(1); + + // Dequeue the message + const dequeued = await queue.dequeueMessageFromMasterQueue( + "test_12345", + envMasterQueue, + 10 + ); + expect(dequeued.length).toBe(1); + + // Verify current concurrency is set and reserve is cleared + const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency).toBe(1); + + const queueConcurrency = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrency).toBe(1); + + const envReserveConcurrencyAfterDequeue = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrencyAfterDequeue).toBe(0); + + const queueReserveConcurrencyAfterDequeue = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueReserveConcurrencyAfterDequeue).toBe(0); + + // Acknowledge the message + await queue.acknowledgeMessage(messageDev.orgId, messageDev.runId); + + // Verify all concurrency is cleared + const queueConcurrencyAfter = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrencyAfter).toBe(0); + + const envConcurrencyAfter = await queue.currentConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envConcurrencyAfter).toBe(0); + + const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrencyAfter).toBe(0); + + const queueReserveConcurrencyAfter = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueReserveConcurrencyAfter).toBe(0); + } finally { + await queue.quit(); + } + } + ); + + redisTest("acknowledging a message removes it from the queue", async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + // Enqueue message + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + }); + + // Verify queue lengths + const queueLength = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(queueLength).toBe(1); + + const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength).toBe(1); + + // Dequeue the message + const dequeued = await queue.dequeueMessageFromMasterQueue("test_12345", envMasterQueue, 10); + expect(dequeued.length).toBe(1); + + // Verify queue is empty after dequeue + const queueLengthAfterDequeue = await queue.lengthOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueLengthAfterDequeue).toBe(0); + + const envQueueLengthAfterDequeue = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLengthAfterDequeue).toBe(0); + + // Acknowledge the message + await queue.acknowledgeMessage(messageDev.orgId, messageDev.runId); + + // Verify queue remains empty + const queueLengthAfterAck = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(queueLengthAfterAck).toBe(0); + + const envQueueLengthAfterAck = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLengthAfterAck).toBe(0); + } finally { + await queue.quit(); + } + }); +}); diff --git a/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts b/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts new file mode 100644 index 0000000000..b29af519a0 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts @@ -0,0 +1,465 @@ +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { describe } from "node:test"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import { InputPayload } from "../types.js"; + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + logger: new Logger("RunQueue", "warn"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys: new RunQueueFullKeyProducer(), +}; + +const authenticatedEnvDev = { + id: "e1234", + type: "DEVELOPMENT" as const, + maximumConcurrencyLimit: 10, + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +const messageDev: InputPayload = { + runId: "r4321", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e4321", + environmentType: "DEVELOPMENT", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, +}; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("RunQueue.dequeueMessageFromMasterQueue", () => { + redisTest("dequeuing a message from a master queue", async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + //initial queue length + const result = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(result).toBe(0); + const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength).toBe(0); + + //initial oldest message + const oldestScore = await queue.oldestMessageInQueue(authenticatedEnvDev, messageDev.queue); + expect(oldestScore).toBe(undefined); + + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + //enqueue message + const enqueueResult = await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + }); + + expect(enqueueResult).toBe(true); + + //queue length + const result2 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(result2).toBe(1); + + const envQueueLength2 = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength2).toBe(1); + + //oldest message + const oldestScore2 = await queue.oldestMessageInQueue(authenticatedEnvDev, messageDev.queue); + expect(oldestScore2).toBe(messageDev.timestamp); + + //concurrencies + const queueConcurrency = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrency).toBe(0); + + const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency).toBe(0); + + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(0); + + const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); + expect(projectConcurrency).toBe(0); + + const taskConcurrency = await queue.currentConcurrencyOfTask( + authenticatedEnvDev, + messageDev.taskIdentifier + ); + expect(taskConcurrency).toBe(0); + + const dequeued = await queue.dequeueMessageFromMasterQueue("test_12345", envMasterQueue, 10); + expect(dequeued.length).toBe(1); + expect(dequeued[0].messageId).toEqual(messageDev.runId); + expect(dequeued[0].message.orgId).toEqual(messageDev.orgId); + expect(dequeued[0].message.version).toEqual("1"); + expect(dequeued[0].message.masterQueues).toEqual(["main", envMasterQueue]); + + //concurrencies + const queueConcurrencyAfter = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrencyAfter).toBe(1); + + const envConcurrencyAfter = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrencyAfter).toBe(1); + + const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrencyAfter).toBe(0); + + const projectConcurrencyAfter = await queue.currentConcurrencyOfProject(authenticatedEnvDev); + expect(projectConcurrencyAfter).toBe(1); + + const taskConcurrencyAfter = await queue.currentConcurrencyOfTask( + authenticatedEnvDev, + messageDev.taskIdentifier + ); + expect(taskConcurrencyAfter).toBe(1); + + //queue length + const result3 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(result3).toBe(0); + const envQueueLength3 = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength3).toBe(0); + } finally { + await queue.quit(); + } + }); + + redisTest( + "should not dequeue when env current concurrency equals env concurrency limit", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + // Set env concurrency limit to 1 + await queue.updateEnvConcurrencyLimits({ + ...authenticatedEnvDev, + maximumConcurrencyLimit: 1, + }); + + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + // Enqueue first message + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + }); + + // Dequeue first message to occupy the concurrency + const dequeued1 = await queue.dequeueMessageFromMasterQueue( + "test_12345", + envMasterQueue, + 10 + ); + expect(dequeued1.length).toBe(1); + + // Enqueue second message + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: { ...messageDev, runId: "r4322" }, + masterQueues: ["main", envMasterQueue], + }); + + // Try to dequeue second message + const dequeued2 = await queue.dequeueMessageFromMasterQueue( + "test_12345", + envMasterQueue, + 10 + ); + expect(dequeued2.length).toBe(0); + + const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "should respect queue concurrency limits when dequeuing", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + // Set queue concurrency limit to 1 + await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, messageDev.queue, 1); + + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + // Enqueue two messages + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + }); + + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: { ...messageDev, runId: "r4322" }, + masterQueues: ["main", envMasterQueue], + }); + + // Dequeue first message + const dequeued1 = await queue.dequeueMessageFromMasterQueue( + "test_12345", + envMasterQueue, + 10 + ); + expect(dequeued1.length).toBe(1); + + // Try to dequeue second message + const dequeued2 = await queue.dequeueMessageFromMasterQueue( + "test_12345", + envMasterQueue, + 10 + ); + expect(dequeued2.length).toBe(0); + + const queueConcurrency = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrency).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "should consider reserve concurrency when checking limits", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + // Set env concurrency limit to 1 + await queue.updateEnvConcurrencyLimits({ + ...authenticatedEnvDev, + maximumConcurrencyLimit: 1, + }); + + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + // First enqueue and dequeue a message to occupy the concurrency + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + }); + + const dequeued1 = await queue.dequeueMessageFromMasterQueue( + "test_12345", + envMasterQueue, + 10 + ); + expect(dequeued1.length).toBe(1); + + // Verify current concurrency is at limit + const envConcurrency1 = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency1).toBe(1); + + // Now enqueue a message with reserve concurrency + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: { ...messageDev, runId: "r4322" }, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: "r1234", + recursiveQueue: false, + }, + }); + + // Verify reserve concurrency is set + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(1); + + // Try to dequeue another message - should fail because current concurrency is at limit + const dequeued2 = await queue.dequeueMessageFromMasterQueue( + "test_12345", + envMasterQueue, + 10 + ); + expect(dequeued2.length).toBe(0); + + // Verify concurrency counts + const envConcurrency2 = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency2).toBe(1); + + // Reserve concurrency should still be set + const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrencyAfter).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "should clear reserve concurrency when dequeuing reserved message", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + // Enqueue message with reserve concurrency + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: messageDev.runId, + recursiveQueue: true, + }, + }); + + // Verify reserve concurrency is set + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(1); + + const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueReserveConcurrency).toBe(1); + + // Dequeue the reserved message + const dequeued = await queue.dequeueMessageFromMasterQueue( + "test_12345", + envMasterQueue, + 10 + ); + expect(dequeued.length).toBe(1); + expect(dequeued[0].messageId).toBe(messageDev.runId); + + // Verify reserve concurrency is cleared and current concurrency is set + const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrencyAfter).toBe(0); + + const queueReserveConcurrencyAfter = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueReserveConcurrencyAfter).toBe(0); + + const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency).toBe(1); + + const queueConcurrency = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrency).toBe(1); + } finally { + await queue.quit(); + } + } + ); +}); diff --git a/internal-packages/run-engine/tsconfig.test.json b/internal-packages/run-engine/tsconfig.test.json index b68d234bd7..d8c7d1c638 100644 --- a/internal-packages/run-engine/tsconfig.test.json +++ b/internal-packages/run-engine/tsconfig.test.json @@ -1,5 +1,5 @@ { - "include": ["src/**/*.test.ts"], + "include": ["src/**/*.test.ts", "src/run-queue/tests/dequeueMessageFromMasterQueue.ts"], "references": [{ "path": "./tsconfig.src.json" }], "compilerOptions": { "composite": true, From a5b2b39201d82df23a01399faa8fc97c6ef3d935 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 11 Mar 2025 11:01:40 +0000 Subject: [PATCH 03/38] ack --- .../run-engine/src/run-queue/index.ts | 15 +++- .../src/run-queue/tests/ack.test.ts | 90 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 81db047ff8..64f466b852 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -1119,6 +1119,9 @@ export class RunQueue { service: this.name, }); + const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(messageQueue); + const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(messageQueue); + return this.redis.acknowledgeMessage( messageKey, messageQueue, @@ -1127,6 +1130,8 @@ export class RunQueue { projectConcurrencyKey, envQueueKey, taskConcurrencyKey, + queueReserveConcurrencyKey, + envReserveConcurrencyKey, messageId, messageQueue, JSON.stringify(masterQueues), @@ -1441,7 +1446,7 @@ return {messageId, messageScore, messagePayload} -- Return message details }); this.redis.defineCommand("acknowledgeMessage", { - numberOfKeys: 7, + numberOfKeys: 9, lua: ` -- Keys: local messageKey = KEYS[1] @@ -1451,6 +1456,8 @@ local envCurrentConcurrencyKey = KEYS[4] local projectCurrentConcurrencyKey = KEYS[5] local envQueueKey = KEYS[6] local taskCurrentConcurrencyKey = KEYS[7] +local queueReserveConcurrencyKey = KEYS[8] +local envReserveConcurrencyKey = KEYS[9] -- Args: local messageId = ARGV[1] @@ -1481,6 +1488,10 @@ redis.call('SREM', concurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) redis.call('SREM', projectCurrentConcurrencyKey, messageId) redis.call('SREM', taskCurrentConcurrencyKey, messageId) + +-- Clear reserve concurrency +redis.call('SREM', queueReserveConcurrencyKey, messageId) +redis.call('SREM', envReserveConcurrencyKey, messageId) `, }); @@ -1737,6 +1748,8 @@ declare module "@internal/redis" { projectConcurrencyKey: string, envQueueKey: string, taskConcurrencyKey: string, + queueReserveConcurrencyKey: string, + envReserveConcurrencyKey: string, messageId: string, messageQueueName: string, masterQueues: string, diff --git a/internal-packages/run-engine/src/run-queue/tests/ack.test.ts b/internal-packages/run-engine/src/run-queue/tests/ack.test.ts index 56eb9e1204..b9b1be1776 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ack.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ack.test.ts @@ -290,4 +290,94 @@ describe("RunQueue.acknowledgeMessage", () => { await queue.quit(); } }); + + redisTest( + "acknowledging a message clears reserve concurrency sets even when not dequeued", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + // Enqueue message with reserve concurrency + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: messageDev.runId, + recursiveQueue: true, + }, + }); + + // Verify reserve concurrency is set + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(1); + + const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueReserveConcurrency).toBe(1); + + // Verify message is in queue before acknowledging + const queueLengthBefore = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(queueLengthBefore).toBe(1); + + const envQueueLengthBefore = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLengthBefore).toBe(1); + + // Acknowledge the message before dequeuing + await queue.acknowledgeMessage(messageDev.orgId, messageDev.runId); + + // Verify reserve concurrency is cleared + const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrencyAfter).toBe(0); + + const queueReserveConcurrencyAfter = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueReserveConcurrencyAfter).toBe(0); + + // Verify message is removed from queue + const queueLength = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); + expect(queueLength).toBe(0); + + const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength).toBe(0); + + // Verify no current concurrency was set + const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); + expect(envConcurrency).toBe(0); + + const queueConcurrency = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueConcurrency).toBe(0); + } finally { + await queue.quit(); + } + } + ); }); From 880538ff92cfaf15b9897e783a42b48ee3d423f1 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 11 Mar 2025 14:07:27 +0000 Subject: [PATCH 04/38] improve the dead letter queue stuff --- .vscode/launch.json | 2 +- .../run-engine/src/run-queue/index.test.ts | 40 +- .../run-engine/src/run-queue/index.ts | 375 ++++++++++-------- .../run-engine/src/run-queue/keyProducer.ts | 25 ++ .../src/run-queue/tests/nack.test.ts | 244 ++++++++++++ .../run-engine/src/run-queue/types.ts | 4 + internal-packages/run-engine/vitest.config.ts | 1 + .../src/run-queue/tests/nack.test.ts | 11 + 8 files changed, 512 insertions(+), 190 deletions(-) create mode 100644 internal-packages/run-engine/src/run-queue/tests/nack.test.ts create mode 100644 internal-packages/run-queue/src/run-queue/tests/nack.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 2bd25ee36f..8242758d34 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -146,7 +146,7 @@ "type": "node-terminal", "request": "launch", "name": "Debug RunQueue tests", - "command": "pnpm run test ./src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts", + "command": "pnpm run test ./src/run-queue/index.test.ts", "cwd": "${workspaceFolder}/internal-packages/run-engine", "sourceMaps": true } diff --git a/internal-packages/run-engine/src/run-queue/index.test.ts b/internal-packages/run-engine/src/run-queue/index.test.ts index 7fcae56eeb..952df3b28c 100644 --- a/internal-packages/run-engine/src/run-queue/index.test.ts +++ b/internal-packages/run-engine/src/run-queue/index.test.ts @@ -826,7 +826,7 @@ describe("RunQueue", () => { } ); - redisTest("Dead Letter Queue", { timeout: 8_000 }, async ({ redisContainer, redisOptions }) => { + redisTest("Dead Letter Queue", async ({ redisContainer, redisOptions }) => { const queue = new RunQueue({ ...testOptions, retryOptions: { @@ -891,36 +891,30 @@ describe("RunQueue", () => { expect(taskConcurrency2).toBe(0); //check the message is still there - const exists2 = await redis.exists(key); - expect(exists2).toBe(1); - - //check it's in the dlq - const dlqKey = "dlq"; - const dlqExists = await redis.exists(dlqKey); - expect(dlqExists).toBe(1); - const dlqMembers = await redis.zrange(dlqKey, 0, -1); - expect(dlqMembers).toContain(messageProd.runId); + const message = await queue.readMessage(messages[0].message.orgId, messages[0].messageId); + expect(message).toBeDefined(); - //redrive - const redisClient = createRedisClient({ - host: redisContainer.getHost(), - port: redisContainer.getPort(), - password: redisContainer.getPassword(), - }); + const deadLetterQueueLengthBefore = await queue.lengthOfDeadLetterQueue(authenticatedEnvProd); + expect(deadLetterQueueLengthBefore).toBe(1); - // Publish redrive message - await redisClient.publish( - "rq:redrive", - JSON.stringify({ runId: messageProd.runId, orgId: messageProd.orgId }) + const existsInDlq = await queue.messageInDeadLetterQueue( + authenticatedEnvProd, + messageProd.runId ); + expect(existsInDlq).toBe(true); + + //redrive + await queue.redriveMessage(authenticatedEnvProd, messageProd.runId); // Wait for the item to be redrived and processed await setTimeout(5_000); - await redisClient.quit(); //shouldn't be in the dlq now - const dlqMembersAfter = await redis.zrange(dlqKey, 0, -1); - expect(dlqMembersAfter).not.toContain(messageProd.runId); + const existsInDlqAfter = await queue.messageInDeadLetterQueue( + authenticatedEnvProd, + messageProd.runId + ); + expect(existsInDlqAfter).toBe(false); //dequeue const messages3 = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 10); diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 64f466b852..4d17347532 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -161,6 +161,28 @@ export class RunQueue { return this.redis.zcard(this.keys.envQueueKey(env)); } + public async lengthOfDeadLetterQueue(env: MinimalAuthenticatedEnvironment) { + return this.redis.zcard(this.keys.deadLetterQueueKey(env)); + } + + public async messageInDeadLetterQueue(env: MinimalAuthenticatedEnvironment, messageId: string) { + const result = await this.redis.zscore(this.keys.deadLetterQueueKey(env), messageId); + return !!result; + } + + public async redriveMessage(env: MinimalAuthenticatedEnvironment, messageId: string) { + // Publish redrive message + await this.redis.publish( + "rq:redrive", + JSON.stringify({ + runId: messageId, + orgId: env.organization.id, + envId: env.id, + projectId: env.project.id, + }) + ); + } + public async oldestMessageInQueue( env: MinimalAuthenticatedEnvironment, queue: string, @@ -275,6 +297,10 @@ export class RunQueue { return this.redis.scard(this.keys.projectCurrentConcurrencyKey(env)); } + public async messageExists(orgId: string, messageId: string) { + return this.redis.exists(this.keys.messageKey(orgId, messageId)); + } + public async currentConcurrencyOfTask( env: MinimalAuthenticatedEnvironment, taskIdentifier: string @@ -282,6 +308,41 @@ export class RunQueue { return this.redis.scard(this.keys.taskIdentifierCurrentConcurrencyKey(env, taskIdentifier)); } + public async readMessage(orgId: string, messageId: string) { + return this.#trace( + "readMessage", + async (span) => { + const rawMessage = await this.redis.get(this.keys.messageKey(orgId, messageId)); + + if (!rawMessage) { + return; + } + + const message = OutputPayload.safeParse(JSON.parse(rawMessage)); + + if (!message.success) { + this.logger.error(`[${this.name}] Failed to parse message`, { + messageId, + error: message.error, + service: this.name, + }); + + return; + } + + return message.data; + }, + { + attributes: { + [SEMATTRS_MESSAGING_OPERATION]: "receive", + [SEMATTRS_MESSAGE_ID]: messageId, + [SEMATTRS_MESSAGING_SYSTEM]: "marqs", + [SemanticAttributes.RUN_ID]: messageId, + }, + } + ); + } + public async enqueueMessage({ env, message, @@ -434,7 +495,7 @@ export class RunQueue { return this.#trace( "acknowledgeMessage", async (span) => { - const message = await this.#readMessage(orgId, messageId); + const message = await this.readMessage(orgId, messageId); if (!message) { this.logger.log(`[${this.name}].acknowledgeMessage() message not found`, { @@ -452,18 +513,7 @@ export class RunQueue { }); await this.#callAcknowledgeMessage({ - messageId, - messageQueue: message.queue, - masterQueues: message.masterQueues, - messageKey: this.keys.messageKey(orgId, messageId), - concurrencyKey: this.keys.currentConcurrencyKeyFromQueue(message.queue), - envConcurrencyKey: this.keys.envCurrentConcurrencyKeyFromQueue(message.queue), - taskConcurrencyKey: this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( - message.queue, - message.taskIdentifier - ), - envQueueKey: this.keys.envQueueKeyFromQueue(message.queue), - projectConcurrencyKey: this.keys.projectCurrentConcurrencyKeyFromQueue(message.queue), + message, }); }, { @@ -497,7 +547,7 @@ export class RunQueue { async (span) => { const maxAttempts = this.retryOptions.maxAttempts ?? defaultRetrySettings.maxAttempts; - const message = await this.#readMessage(orgId, messageId); + const message = await this.readMessage(orgId, messageId); if (!message) { this.logger.log(`[${this.name}].nackMessage() message not found`, { orgId, @@ -516,75 +566,16 @@ export class RunQueue { [SemanticAttributes.MASTER_QUEUES]: message.masterQueues.join(","), }); - const messageKey = this.keys.messageKey(orgId, messageId); - const messageQueue = message.queue; - const concurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); - const envConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); - const taskConcurrencyKey = this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( - message.queue, - message.taskIdentifier - ); - const projectConcurrencyKey = this.keys.projectCurrentConcurrencyKeyFromQueue( - message.queue - ); - const envQueueKey = this.keys.envQueueKeyFromQueue(message.queue); - if (incrementAttemptCount) { message.attempt = message.attempt + 1; if (message.attempt >= maxAttempts) { - await this.redis.moveToDeadLetterQueue( - messageKey, - messageQueue, - concurrencyKey, - envConcurrencyKey, - projectConcurrencyKey, - envQueueKey, - taskConcurrencyKey, - "dlq", - messageId, - messageQueue, - JSON.stringify(message.masterQueues), - this.options.redis.keyPrefix ?? "" - ); + await this.#callMoveToDeadLetterQueue({ message }); return false; } } - const nextRetryDelay = calculateNextRetryDelay(this.retryOptions, message.attempt); - const messageScore = retryAt ?? (nextRetryDelay ? Date.now() + nextRetryDelay : Date.now()); - - this.logger.debug("Calling nackMessage", { - messageKey, - messageQueue, - masterQueues: message.masterQueues, - concurrencyKey, - envConcurrencyKey, - projectConcurrencyKey, - envQueueKey, - taskConcurrencyKey, - messageId, - messageScore, - attempt: message.attempt, - service: this.name, - }); + await this.#callNackMessage({ message }); - await this.redis.nackMessage( - //keys - messageKey, - messageQueue, - concurrencyKey, - envConcurrencyKey, - projectConcurrencyKey, - envQueueKey, - taskConcurrencyKey, - //args - messageId, - messageQueue, - JSON.stringify(message), - String(messageScore), - JSON.stringify(message.masterQueues), - this.options.redis.keyPrefix ?? "" - ); return true; }, { @@ -606,7 +597,7 @@ export class RunQueue { return this.#trace( "releaseConcurrency", async (span) => { - const message = await this.#readMessage(orgId, messageId); + const message = await this.readMessage(orgId, messageId); if (!message) { this.logger.log(`[${this.name}].acknowledgeMessage() message not found`, { @@ -652,7 +643,7 @@ export class RunQueue { return this.#trace( "reacquireConcurrency", async (span) => { - const message = await this.#readMessage(orgId, messageId); + const message = await this.readMessage(orgId, messageId); if (!message) { this.logger.log(`[${this.name}].acknowledgeMessage() message not found`, { @@ -702,16 +693,21 @@ export class RunQueue { private async handleRedriveMessage(channel: string, message: string) { try { - const { runId, orgId } = JSON.parse(message) as any; - if (typeof orgId !== "string" || typeof runId !== "string") { + const { runId, envId, projectId, orgId } = JSON.parse(message) as any; + if ( + typeof orgId !== "string" || + typeof runId !== "string" || + typeof envId !== "string" || + typeof projectId !== "string" + ) { this.logger.error( - "handleRedriveMessage: invalid message format: runId and orgId must be strings", + "handleRedriveMessage: invalid message format: runId, envId, projectId and orgId must be strings", { message, channel } ); return; } - const data = await this.#readMessage(orgId, runId); + const data = await this.readMessage(orgId, runId); if (!data) { this.logger.error(`handleRedriveMessage: couldn't read message`, { orgId, runId, channel }); @@ -739,7 +735,10 @@ export class RunQueue { }); //remove from the dlq - const result = await this.redis.zrem("dlq", runId); + const result = await this.redis.zrem( + this.keys.deadLetterQueueKey({ envId, orgId, projectId }), + runId + ); if (result === 0) { this.logger.error(`handleRedriveMessage: couldn't remove message from dlq`, { @@ -800,41 +799,6 @@ export class RunQueue { this.subscriber.on("message", this.handleRedriveMessage.bind(this)); } - async #readMessage(orgId: string, messageId: string) { - return this.#trace( - "readMessage", - async (span) => { - const rawMessage = await this.redis.get(this.keys.messageKey(orgId, messageId)); - - if (!rawMessage) { - return; - } - - const message = OutputPayload.safeParse(JSON.parse(rawMessage)); - - if (!message.success) { - this.logger.error(`[${this.name}] Failed to parse message`, { - messageId, - error: message.error, - service: this.name, - }); - - return; - } - - return message.data; - }, - { - attributes: { - [SEMATTRS_MESSAGING_OPERATION]: "receive", - [SEMATTRS_MESSAGE_ID]: messageId, - [SEMATTRS_MESSAGING_SYSTEM]: "marqs", - [SemanticAttributes.RUN_ID]: messageId, - }, - } - ); - } - async #callEnqueueMessage( message: OutputPayload, masterQueues: string[], @@ -1085,35 +1049,30 @@ export class RunQueue { }; } - async #callAcknowledgeMessage({ - messageId, - masterQueues, - messageKey, - messageQueue, - concurrencyKey, - envConcurrencyKey, - taskConcurrencyKey, - envQueueKey, - projectConcurrencyKey, - }: { - masterQueues: string[]; - messageKey: string; - messageQueue: string; - concurrencyKey: string; - envConcurrencyKey: string; - taskConcurrencyKey: string; - envQueueKey: string; - projectConcurrencyKey: string; - messageId: string; - }) { + async #callAcknowledgeMessage({ message }: { message: OutputPayload }) { + const messageId = message.runId; + const messageKey = this.keys.messageKey(message.orgId, messageId); + const messageQueue = message.queue; + const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); + const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); + const projectCurrentConcurrencyKey = this.keys.projectCurrentConcurrencyKeyFromQueue( + message.queue + ); + const envQueueKey = this.keys.envQueueKeyFromQueue(message.queue); + const taskCurrentConcurrencyKey = this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( + message.queue, + message.taskIdentifier + ); + const masterQueues = message.masterQueues; + this.logger.debug("Calling acknowledgeMessage", { messageKey, messageQueue, - concurrencyKey, - envConcurrencyKey, - projectConcurrencyKey, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + projectCurrentConcurrencyKey, envQueueKey, - taskConcurrencyKey, + taskCurrentConcurrencyKey, messageId, masterQueues, service: this.name, @@ -1125,11 +1084,11 @@ export class RunQueue { return this.redis.acknowledgeMessage( messageKey, messageQueue, - concurrencyKey, - envConcurrencyKey, - projectConcurrencyKey, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + projectCurrentConcurrencyKey, envQueueKey, - taskConcurrencyKey, + taskCurrentConcurrencyKey, queueReserveConcurrencyKey, envReserveConcurrencyKey, messageId, @@ -1139,6 +1098,90 @@ export class RunQueue { ); } + async #callNackMessage({ message, retryAt }: { message: OutputPayload; retryAt?: number }) { + const messageId = message.runId; + const messageKey = this.keys.messageKey(message.orgId, message.runId); + const messageQueue = message.queue; + const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); + const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); + const projectCurrentConcurrencyKey = this.keys.projectCurrentConcurrencyKeyFromQueue( + message.queue + ); + const envQueueKey = this.keys.envQueueKeyFromQueue(message.queue); + const taskCurrentConcurrencyKey = this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( + message.queue, + message.taskIdentifier + ); + + const nextRetryDelay = calculateNextRetryDelay(this.retryOptions, message.attempt); + const messageScore = retryAt ?? (nextRetryDelay ? Date.now() + nextRetryDelay : Date.now()); + + this.logger.debug("Calling nackMessage", { + messageKey, + messageQueue, + masterQueues: message.masterQueues, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + projectCurrentConcurrencyKey, + envQueueKey, + taskCurrentConcurrencyKey, + messageId, + messageScore, + attempt: message.attempt, + service: this.name, + }); + + await this.redis.nackMessage( + //keys + messageKey, + messageQueue, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + projectCurrentConcurrencyKey, + envQueueKey, + taskCurrentConcurrencyKey, + //args + messageId, + messageQueue, + JSON.stringify(message), + String(messageScore), + JSON.stringify(message.masterQueues), + this.options.redis.keyPrefix ?? "" + ); + } + + async #callMoveToDeadLetterQueue({ message }: { message: OutputPayload }) { + const messageId = message.runId; + const messageKey = this.keys.messageKey(message.orgId, message.runId); + const messageQueue = message.queue; + const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); + const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); + const projectCurrentConcurrencyKey = this.keys.projectCurrentConcurrencyKeyFromQueue( + message.queue + ); + const envQueueKey = this.keys.envQueueKeyFromQueue(message.queue); + const taskCurrentConcurrencyKey = this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( + message.queue, + message.taskIdentifier + ); + const deadLetterQueueKey = this.keys.deadLetterQueueKeyFromQueue(message.queue); + + await this.redis.moveToDeadLetterQueue( + messageKey, + messageQueue, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + projectCurrentConcurrencyKey, + envQueueKey, + taskCurrentConcurrencyKey, + deadLetterQueueKey, + messageId, + messageQueue, + JSON.stringify(message.masterQueues), + this.options.redis.keyPrefix ?? "" + ); + } + #callUpdateGlobalConcurrencyLimits({ envConcurrencyLimitKey, envConcurrencyLimit, @@ -1501,11 +1544,11 @@ redis.call('SREM', envReserveConcurrencyKey, messageId) -- Keys: local messageKey = KEYS[1] local messageQueueKey = KEYS[2] -local concurrencyKey = KEYS[3] -local envConcurrencyKey = KEYS[4] -local projectConcurrencyKey = KEYS[5] +local queueCurrentConcurrencyKey = KEYS[3] +local envCurrentConcurrencyKey = KEYS[4] +local projectCurrentConcurrencyKey = KEYS[5] local envQueueKey = KEYS[6] -local taskConcurrencyKey = KEYS[7] +local taskCurrentConcurrencyKey = KEYS[7] -- Args: local messageId = ARGV[1] @@ -1519,10 +1562,10 @@ local keyPrefix = ARGV[6] redis.call('SET', messageKey, messageData) -- Update the concurrency keys -redis.call('SREM', concurrencyKey, messageId) -redis.call('SREM', envConcurrencyKey, messageId) -redis.call('SREM', projectConcurrencyKey, messageId) -redis.call('SREM', taskConcurrencyKey, messageId) +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', projectCurrentConcurrencyKey, messageId) +redis.call('SREM', taskCurrentConcurrencyKey, messageId) -- Enqueue the message into the queue redis.call('ZADD', messageQueueKey, messageScore, messageId) @@ -1547,11 +1590,11 @@ end -- Keys: local messageKey = KEYS[1] local messageQueue = KEYS[2] -local concurrencyKey = KEYS[3] +local queueCurrentConcurrencyKey = KEYS[3] local envCurrentConcurrencyKey = KEYS[4] local projectCurrentConcurrencyKey = KEYS[5] local envQueueKey = KEYS[6] -local taskCurrentConcurrencyKey = KEYS[7] +local taskCurrentConcurrencyKeyPrefix = KEYS[7] local deadLetterQueueKey = KEYS[8] -- Args: @@ -1579,10 +1622,10 @@ end redis.call('ZADD', deadLetterQueueKey, tonumber(redis.call('TIME')[1]), messageId) -- Update the concurrency keys -redis.call('SREM', concurrencyKey, messageId) +redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) redis.call('SREM', projectCurrentConcurrencyKey, messageId) -redis.call('SREM', taskCurrentConcurrencyKey, messageId) +redis.call('SREM', taskCurrentConcurrencyKeyPrefix, messageId) `, }); @@ -1760,11 +1803,11 @@ declare module "@internal/redis" { nackMessage( messageKey: string, messageQueue: string, - concurrencyKey: string, - envConcurrencyKey: string, - projectConcurrencyKey: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + projectCurrentConcurrencyKey: string, envQueueKey: string, - taskConcurrencyKey: string, + taskCurrentConcurrencyKey: string, messageId: string, messageQueueName: string, messageData: string, @@ -1777,11 +1820,11 @@ declare module "@internal/redis" { moveToDeadLetterQueue( messageKey: string, messageQueue: string, - concurrencyKey: string, - envConcurrencyKey: string, - projectConcurrencyKey: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + projectCurrentConcurrencyKey: string, envQueueKey: string, - taskConcurrencyKey: string, + taskCurrentConcurrencyKey: string, deadLetterQueueKey: string, messageId: string, messageQueueName: string, diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.ts b/internal-packages/run-engine/src/run-queue/keyProducer.ts index 1feac4a7c2..a24bf8dbdd 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.ts @@ -13,6 +13,7 @@ const constants = { TASK_PART: "task", MESSAGE_PART: "message", RESERVE_CONCURRENCY_PART: "reserveConcurrency", + DEAD_LETTER_QUEUE_PART: "deadLetter", } as const; export class RunQueueFullKeyProducer implements RunQueueKeyProducer { @@ -256,6 +257,30 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { return this.envReserveConcurrencyKey(descriptor); } + deadLetterQueueKey(env: MinimalAuthenticatedEnvironment): string; + deadLetterQueueKey(env: EnvDescriptor): string; + deadLetterQueueKey(envOrDescriptor: EnvDescriptor | MinimalAuthenticatedEnvironment): string { + if ("id" in envOrDescriptor) { + return [ + this.orgKeySection(envOrDescriptor.organization.id), + this.projKeySection(envOrDescriptor.project.id), + this.envKeySection(envOrDescriptor.id), + constants.DEAD_LETTER_QUEUE_PART, + ].join(":"); + } else { + return [ + this.orgKeySection(envOrDescriptor.orgId), + this.projKeySection(envOrDescriptor.projectId), + this.envKeySection(envOrDescriptor.envId), + constants.DEAD_LETTER_QUEUE_PART, + ].join(":"); + } + } + deadLetterQueueKeyFromQueue(queue: string): string { + const descriptor = this.descriptorFromQueue(queue); + + return this.deadLetterQueueKey(descriptor); + } private queueReserveConcurrencyKeyFromDescriptor(descriptor: QueueDescriptor) { return [ diff --git a/internal-packages/run-engine/src/run-queue/tests/nack.test.ts b/internal-packages/run-engine/src/run-queue/tests/nack.test.ts new file mode 100644 index 0000000000..67ec26b589 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/nack.test.ts @@ -0,0 +1,244 @@ +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { describe } from "node:test"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import { InputPayload } from "../types.js"; +import { setTimeout } from "node:timers/promises"; + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + logger: new Logger("RunQueue", "warn"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys: new RunQueueFullKeyProducer(), +}; + +const authenticatedEnvDev = { + id: "e1234", + type: "DEVELOPMENT" as const, + maximumConcurrencyLimit: 10, + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +const messageDev: InputPayload = { + runId: "r4321", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e4321", + environmentType: "DEVELOPMENT", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, +}; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("RunQueue.nackMessage", () => { + redisTest("nacking a message clears all concurrency", async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + // Enqueue message with reserve concurrency + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + reserveConcurrency: { + messageId: messageDev.runId, + recursiveQueue: true, + }, + }); + + // Verify reserve concurrency is set + const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueReserveConcurrency).toBe(1); + + const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envReserveConcurrency).toBe(1); + + // Dequeue message + const dequeued = await queue.dequeueMessageFromMasterQueue("test_12345", envMasterQueue, 10); + expect(dequeued.length).toBe(1); + + // Verify current concurrency is set and reserve is cleared + const queueCurrentConcurrency = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueCurrentConcurrency).toBe(1); + + const envCurrentConcurrency = await queue.currentConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envCurrentConcurrency).toBe(1); + + // Nack the message + await queue.nackMessage({ + orgId: messageDev.orgId, + messageId: messageDev.runId, + }); + + // Verify all concurrency is cleared + const queueCurrentConcurrencyAfterNack = await queue.currentConcurrencyOfQueue( + authenticatedEnvDev, + messageDev.queue + ); + expect(queueCurrentConcurrencyAfterNack).toBe(0); + + const envCurrentConcurrencyAfterNack = await queue.currentConcurrencyOfEnvironment( + authenticatedEnvDev + ); + expect(envCurrentConcurrencyAfterNack).toBe(0); + + const projectCurrentConcurrencyAfterNack = await queue.currentConcurrencyOfProject( + authenticatedEnvDev + ); + expect(projectCurrentConcurrencyAfterNack).toBe(0); + + const taskCurrentConcurrencyAfterNack = await queue.currentConcurrencyOfTask( + authenticatedEnvDev, + messageDev.taskIdentifier + ); + expect(taskCurrentConcurrencyAfterNack).toBe(0); + + const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength).toBe(1); + + const message = await queue.readMessage(messageDev.orgId, messageDev.runId); + expect(message?.attempt).toBe(1); + + //we need to wait because the default wait is 1 second + await setTimeout(300); + + // Now we should be able to dequeue it again + const dequeued2 = await queue.dequeueMessageFromMasterQueue("test_12345", envMasterQueue, 10); + expect(dequeued2.length).toBe(1); + } finally { + await queue.quit(); + } + }); + + redisTest( + "nacking a message with maxAttempts reached should be moved to dead letter queue", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + retryOptions: { + ...testOptions.retryOptions, + maxAttempts: 2, // Set lower for testing + }, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + const envMasterQueue = `env:${authenticatedEnvDev.id}`; + + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: messageDev, + masterQueues: ["main", envMasterQueue], + }); + + const dequeued = await queue.dequeueMessageFromMasterQueue( + "test_12345", + envMasterQueue, + 10 + ); + expect(dequeued.length).toBe(1); + + await queue.nackMessage({ + orgId: messageDev.orgId, + messageId: messageDev.runId, + }); + + // Wait for any requeue delay + await setTimeout(300); + + // Message should not be requeued as max attempts reached + const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLength).toBe(1); + + const message = await queue.readMessage(messageDev.orgId, messageDev.runId); + expect(message?.attempt).toBe(1); + + // Now we dequeue and nack again, and it should be moved to dead letter queue + const dequeued3 = await queue.dequeueMessageFromMasterQueue( + "test_12345", + envMasterQueue, + 10 + ); + expect(dequeued3.length).toBe(1); + + const envQueueLengthDequeue = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLengthDequeue).toBe(0); + + const deadLetterQueueLengthBefore = await queue.lengthOfDeadLetterQueue( + authenticatedEnvDev + ); + expect(deadLetterQueueLengthBefore).toBe(0); + + await queue.nackMessage({ + orgId: messageDev.orgId, + messageId: messageDev.runId, + }); + + const envQueueLengthAfterNack = await queue.lengthOfEnvQueue(authenticatedEnvDev); + expect(envQueueLengthAfterNack).toBe(0); + + const deadLetterQueueLengthAfterNack = await queue.lengthOfDeadLetterQueue( + authenticatedEnvDev + ); + expect(deadLetterQueueLengthAfterNack).toBe(1); + } finally { + await queue.quit(); + } + } + ); +}); diff --git a/internal-packages/run-engine/src/run-queue/types.ts b/internal-packages/run-engine/src/run-queue/types.ts index 2f005b28b7..563cececa8 100644 --- a/internal-packages/run-engine/src/run-queue/types.ts +++ b/internal-packages/run-engine/src/run-queue/types.ts @@ -92,6 +92,10 @@ export interface RunQueueKeyProducer { reserveConcurrencyKeyFromQueue(queue: string): string; envReserveConcurrencyKeyFromQueue(queue: string): string; + + deadLetterQueueKey(env: MinimalAuthenticatedEnvironment): string; + deadLetterQueueKey(env: EnvDescriptor): string; + deadLetterQueueKeyFromQueue(queue: string): string; } export type EnvQueues = { diff --git a/internal-packages/run-engine/vitest.config.ts b/internal-packages/run-engine/vitest.config.ts index e10e77f70e..044760d6b6 100644 --- a/internal-packages/run-engine/vitest.config.ts +++ b/internal-packages/run-engine/vitest.config.ts @@ -12,5 +12,6 @@ export default defineConfig({ singleThread: true, }, }, + testTimeout: 60_000, }, }); diff --git a/internal-packages/run-queue/src/run-queue/tests/nack.test.ts b/internal-packages/run-queue/src/run-queue/tests/nack.test.ts new file mode 100644 index 0000000000..c553fce412 --- /dev/null +++ b/internal-packages/run-queue/src/run-queue/tests/nack.test.ts @@ -0,0 +1,11 @@ +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { describe } from "node:test"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import { InputPayload } from "../types.js"; +import { createRedisClient } from "@internal/redis"; + +// ... existing code ... From 555d9912ec05606da26d7f8e05a94df02efe3e36 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 11 Mar 2025 15:01:59 +0000 Subject: [PATCH 05/38] fixed some key producer tests --- .../run-engine/src/run-queue/keyProducer.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.test.ts b/internal-packages/run-engine/src/run-queue/keyProducer.test.ts index 0f6b14e17d..b7cafb56f0 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.test.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.test.ts @@ -176,7 +176,7 @@ describe("KeyProducer", () => { "task/task-name" ); const key = keyProducer.taskIdentifierCurrentConcurrencyKeyPrefixFromQueue(queueKey); - expect(key).toBe("{org:o1234}:proj:p1234:task:"); + expect(key).toBe("{org:o1234}:proj:p1234:env:e1234:task:"); }); it("taskIdentifierCurrentConcurrencyKeyFromQueue", () => { @@ -192,7 +192,7 @@ describe("KeyProducer", () => { "task/task-name" ); const key = keyProducer.taskIdentifierCurrentConcurrencyKeyFromQueue(queueKey, "task-name"); - expect(key).toBe("{org:o1234}:proj:p1234:task:task-name"); + expect(key).toBe("{org:o1234}:proj:p1234:env:e1234:task:task-name"); }); it("taskIdentifierCurrentConcurrencyKey", () => { @@ -207,7 +207,7 @@ describe("KeyProducer", () => { }, "task-name" ); - expect(key).toBe("{org:o1234}:proj:p1234:task:task-name"); + expect(key).toBe("{org:o1234}:proj:p1234:env:e1234:task:task-name"); }); it("projectCurrentConcurrencyKey", () => { @@ -259,7 +259,7 @@ describe("KeyProducer", () => { "task/task-name" ); const key = keyProducer.envConcurrencyLimitKeyFromQueue(queueKey); - expect(key).toBe("{org:o1234}:env:e1234:concurrency"); + expect(key).toBe("{org:o1234}:proj:p1234:env:e1234:concurrency"); }); it("envCurrentConcurrencyKeyFromQueue", () => { From 1a233efd4274f6f13499e8fe20a2d282dec6eb9f Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 12 Mar 2025 11:44:16 +0000 Subject: [PATCH 06/38] the run engine now works with the new reserve concurrency system --- .vscode/launch.json | 4 +- ...dmin.api.v1.environments.$environmentId.ts | 14 +- .../app/v3/services/triggerTaskV2.server.ts | 21 +- .../run-engine/src/engine/index.ts | 66 +- .../src/engine/tests/attemptFailures.test.ts | 1110 ++++++++--------- .../engine/tests/reserveConcurrency.test.ts | 585 +++++++++ .../test-reserve-concurrency-system.ts | 8 +- 7 files changed, 1211 insertions(+), 597 deletions(-) create mode 100644 internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 8242758d34..da6e7674a5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -138,8 +138,8 @@ "type": "node-terminal", "request": "launch", "name": "Debug RunEngine tests", - "command": "pnpm run test --filter @internal/run-engine", - "cwd": "${workspaceFolder}", + "command": "pnpm run test ./src/engine/tests/attemptFailures.test.ts -t 'OOM fails after retrying on larger machine'", + "cwd": "${workspaceFolder}/internal-packages/run-engine", "sourceMaps": true }, { diff --git a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts index 3bb2ecf664..49483a9678 100644 --- a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts +++ b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts @@ -2,7 +2,7 @@ import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server- import { z } from "zod"; import { prisma } from "~/db.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; -import { marqs } from "~/v3/marqs/index.server"; +import { engine } from "~/v3/runEngine.server"; import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; const ParamsSchema = z.object({ @@ -113,20 +113,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) { Object.fromEntries(requestUrl.searchParams.entries()) ); - const concurrencyLimit = await marqs.getEnvConcurrencyLimit(environment); - const currentConcurrency = await marqs.currentConcurrencyOfEnvironment(environment); - const reserveConcurrency = await marqs.reserveConcurrencyOfEnvironment(environment); + const concurrencyLimit = await engine.runQueue.getEnvConcurrencyLimit(environment); + const currentConcurrency = await engine.runQueue.currentConcurrencyOfEnvironment(environment); + const reserveConcurrency = await engine.runQueue.reserveConcurrencyOfEnvironment(environment); if (searchParams.queue) { - const queueConcurrencyLimit = await marqs.getQueueConcurrencyLimit( + const queueConcurrencyLimit = await engine.runQueue.getQueueConcurrencyLimit( environment, searchParams.queue ); - const queueCurrentConcurrency = await marqs.currentConcurrencyOfQueue( + const queueCurrentConcurrency = await engine.runQueue.currentConcurrencyOfQueue( environment, searchParams.queue ); - const queueReserveConcurrency = await marqs.reserveConcurrencyOfQueue( + const queueReserveConcurrency = await engine.runQueue.reserveConcurrencyOfQueue( environment, searchParams.queue ); diff --git a/apps/webapp/app/v3/services/triggerTaskV2.server.ts b/apps/webapp/app/v3/services/triggerTaskV2.server.ts index 1d1f6cb558..a1d431b650 100644 --- a/apps/webapp/app/v3/services/triggerTaskV2.server.ts +++ b/apps/webapp/app/v3/services/triggerTaskV2.server.ts @@ -4,6 +4,9 @@ import { packetRequiresOffloading, QueueOptions, SemanticInternalAttributes, + TaskRunError, + taskRunErrorEnhancer, + taskRunErrorToString, TriggerTaskRequestBody, } from "@trigger.dev/core/v3"; import { @@ -271,7 +274,7 @@ export class TriggerTaskServiceV2 extends WithRunEngine { immediate: true, }, async (event, traceContext, traceparent) => { - const run = await autoIncrementCounter.incrementInTransaction( + const result = await autoIncrementCounter.incrementInTransaction( `v3-run:${environment.id}:${taskId}`, async (num, tx) => { const lockedToBackgroundWorker = body.options?.lockToVersion @@ -374,7 +377,13 @@ export class TriggerTaskServiceV2 extends WithRunEngine { this._prisma ); - return { run: taskRun, isCached: false }; + const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined; + + if (error) { + event.failWithError(error); + } + + return { run: taskRun, error, isCached: false }; }, async (_, tx) => { const counter = await tx.taskRunNumberCounter.findFirst({ @@ -390,7 +399,13 @@ export class TriggerTaskServiceV2 extends WithRunEngine { this._prisma ); - return run; + if (result?.error) { + throw new ServiceValidationError( + taskRunErrorToString(taskRunErrorEnhancer(result.error)) + ); + } + + return result; } ); } catch (error) { diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 8c849ec6a6..95d78af404 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -13,11 +13,9 @@ import { parsePacket, RetryOptions, RunExecutionData, - sanitizeError, - shouldRetryError, StartRunAttemptResult, TaskRunError, - taskRunErrorEnhancer, + TaskRunErrorCodes, TaskRunExecution, TaskRunExecutionResult, TaskRunFailedExecutionResult, @@ -52,8 +50,9 @@ import { assertNever } from "assert-never"; import { nanoid } from "nanoid"; import { EventEmitter } from "node:events"; import { z } from "zod"; -import { RunQueue } from "../run-queue/index.js"; import { FairQueueSelectionStrategy } from "../run-queue/fairQueueSelectionStrategy.js"; +import { RunQueue, RunQueueReserveConcurrencyOptions } from "../run-queue/index.js"; +import { RunQueueFullKeyProducer } from "../run-queue/keyProducer.js"; import { MinimalAuthenticatedEnvironment } from "../shared/index.js"; import { MAX_TASK_RUN_ATTEMPTS } from "./consts.js"; import { getRunWithBackgroundWorkerTasks } from "./db/worker.js"; @@ -62,6 +61,7 @@ import { EventBusEvents } from "./eventBus.js"; import { executionResultFromSnapshot, getLatestExecutionSnapshot } from "./executionSnapshots.js"; import { RunLocker } from "./locking.js"; import { getMachinePreset } from "./machinePresets.js"; +import { retryOutcomeFromCompletion } from "./retrying.js"; import { isCheckpointable, isDequeueableExecutionStatus, @@ -70,8 +70,6 @@ import { isPendingExecuting, } from "./statuses.js"; import { HeartbeatTimeouts, RunEngineOptions, TriggerParams } from "./types.js"; -import { RunQueueFullKeyProducer } from "../run-queue/keyProducer.js"; -import { retryOutcomeFromCompletion } from "./retrying.js"; const workerCatalog = { finishWaitpoint: { @@ -423,6 +421,8 @@ export class RunEngine { completedByTaskRunId: taskRun.id, }); + let reserveConcurrencyOptions: RunQueueReserveConcurrencyOptions | undefined; + //triggerAndWait or batchTriggerAndWait if (resumeParentOnCompletion && parentTaskRunId) { //this will block the parent run from continuing until this waitpoint is completed (and removed) @@ -438,9 +438,7 @@ export class RunEngine { tx: prisma, }); - //release the concurrency - //if the queue is the same then it's recursive and we need to release that too otherwise we could have a deadlock - const parentRun = await prisma.taskRun.findUnique({ + const parentRun = await prisma.taskRun.findFirst({ select: { queue: true, }, @@ -448,12 +446,13 @@ export class RunEngine { id: parentTaskRunId, }, }); - const releaseRunConcurrency = parentRun?.queue === taskRun.queue; - await this.runQueue.releaseConcurrency( - environment.organization.id, - parentTaskRunId, - releaseRunConcurrency - ); + + if (parentRun) { + reserveConcurrencyOptions = { + messageId: parentTaskRunId, + recursiveQueue: parentRun?.queue === taskRun.queue, + }; + } } //Make sure lock extension succeeded @@ -551,14 +550,27 @@ export class RunEngine { //enqueue the run if it's not delayed if (!taskRun.delayUntil) { - await this.#enqueueRun({ + const { wasEnqueued, error } = await this.#enqueueRun({ run: taskRun, env: environment, timestamp: Date.now() - taskRun.priorityMs, workerId, runnerId, tx: prisma, + reserveConcurrency: reserveConcurrencyOptions, }); + + if (error) { + // Fail the run immediately + taskRun = await prisma.taskRun.update({ + where: { id: taskRun.id }, + data: { + status: "SYSTEM_FAILURE", + completedAt: new Date(), + error, + }, + }); + } } }); @@ -3212,6 +3224,7 @@ export class RunEngine { completedWaitpoints, workerId, runnerId, + reserveConcurrency, }: { run: TaskRun; env: MinimalAuthenticatedEnvironment; @@ -3228,10 +3241,11 @@ export class RunEngine { }[]; workerId?: string; runnerId?: string; - }) { + reserveConcurrency?: RunQueueReserveConcurrencyOptions; + }): Promise<{ wasEnqueued: boolean; error?: TaskRunError }> { const prisma = tx ?? this.prisma; - await this.runLock.lock([run.id], 5000, async (signal) => { + return await this.runLock.lock([run.id], 5000, async (signal) => { const newSnapshot = await this.#createExecutionSnapshot(prisma, { run: run, snapshot: { @@ -3252,7 +3266,7 @@ export class RunEngine { masterQueues.push(run.secondaryMasterQueue); } - await this.runQueue.enqueueMessage({ + const wasEnqueued = await this.runQueue.enqueueMessage({ env, masterQueues, message: { @@ -3267,7 +3281,21 @@ export class RunEngine { timestamp, attempt: 0, }, + reserveConcurrency, }); + + if (!wasEnqueued) { + return { + wasEnqueued: false, + error: { + type: "INTERNAL_ERROR", + code: TaskRunErrorCodes.RECURSIVE_WAIT_DEADLOCK, + message: `This run will never execute because it was triggered recursively and the task has no remaining concurrency available`, + } satisfies TaskRunError, + }; + } + + return { wasEnqueued }; }); } diff --git a/internal-packages/run-engine/src/engine/tests/attemptFailures.test.ts b/internal-packages/run-engine/src/engine/tests/attemptFailures.test.ts index e46f2ae2fb..6ff3f4d7e8 100644 --- a/internal-packages/run-engine/src/engine/tests/attemptFailures.test.ts +++ b/internal-packages/run-engine/src/engine/tests/attemptFailures.test.ts @@ -10,164 +10,160 @@ import { expect } from "vitest"; import { RunEngine } from "../index.js"; describe("RunEngine attempt failures", () => { - containerTest( - "Retry user error and succeed", - { timeout: 15_000 }, - 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, - }, + containerTest("Retry user error and succeed", 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: { - defaultMachine: "small-1x", - machines: { - "small-1x": { - name: "small-1x" as const, - cpu: 0.5, - memory: 0.5, - centsPerMs: 0.0001, - }, + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, }, - baseCostInCents: 0.0001, }, - tracer: trace.getTracer("test", "0.0.0"), + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + //create background worker + const backgroundWorker = await setupBackgroundWorker( + prisma, + 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", + queueName: "task/test-task", + isTest: false, + tags: [], + }, + prisma + ); + + //dequeue the run + const dequeued = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: run.masterQueue, + maxRunCount: 10, }); - try { - const taskIdentifier = "test-task"; - - //create background worker - const backgroundWorker = await setupBackgroundWorker( - prisma, - 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", - queueName: "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 - const attemptResult = await engine.startRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: dequeued[0].snapshot.id, - }); - - //fail the attempt - const error = { - type: "BUILT_IN_ERROR" as const, - name: "UserError", - message: "This is a user error", - stackTrace: "Error: This is a user error\n at :1:1", - }; - const result = await engine.completeRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: attemptResult.snapshot.id, - completion: { - ok: false, - id: dequeued[0].run.id, - error, - retry: { - timestamp: Date.now(), - delay: 0, - }, - }, - }); - expect(result.attemptStatus).toBe("RETRY_IMMEDIATELY"); - expect(result.snapshot.executionStatus).toBe("PENDING_EXECUTING"); - expect(result.run.status).toBe("RETRYING_AFTER_FAILURE"); - - //state should be pending - const executionData3 = await engine.getRunExecutionData({ runId: run.id }); - assertNonNullable(executionData3); - expect(executionData3.snapshot.executionStatus).toBe("PENDING_EXECUTING"); - //only when the new attempt is created, should the attempt be increased - expect(executionData3.run.attemptNumber).toBe(1); - expect(executionData3.run.status).toBe("RETRYING_AFTER_FAILURE"); - - //create a second attempt - const attemptResult2 = await engine.startRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: executionData3.snapshot.id, - }); - expect(attemptResult2.run.attemptNumber).toBe(2); - - //now complete it successfully - const result2 = await engine.completeRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: attemptResult2.snapshot.id, - completion: { - ok: true, - id: dequeued[0].run.id, - output: `{"foo":"bar"}`, - outputType: "application/json", - }, - }); - expect(result2.snapshot.executionStatus).toBe("FINISHED"); - expect(result2.run.attemptNumber).toBe(2); - expect(result2.run.status).toBe("COMPLETED_SUCCESSFULLY"); - - //waitpoint should have been completed, with the output - const runWaitpointAfter = await prisma.waitpoint.findMany({ - where: { - completedByTaskRunId: run.id, + //create an attempt + const attemptResult = await engine.startRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: dequeued[0].snapshot.id, + }); + + //fail the attempt + const error = { + type: "BUILT_IN_ERROR" as const, + name: "UserError", + message: "This is a user error", + stackTrace: "Error: This is a user error\n at :1:1", + }; + const result = await engine.completeRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: attemptResult.snapshot.id, + completion: { + ok: false, + id: dequeued[0].run.id, + error, + retry: { + timestamp: Date.now(), + delay: 0, }, - }); - expect(runWaitpointAfter.length).toBe(1); - expect(runWaitpointAfter[0].type).toBe("RUN"); - expect(runWaitpointAfter[0].output).toBe(`{"foo":"bar"}`); - expect(runWaitpointAfter[0].outputIsError).toBe(false); - - //state should be completed - const executionData4 = await engine.getRunExecutionData({ runId: run.id }); - assertNonNullable(executionData4); - expect(executionData4.snapshot.executionStatus).toBe("FINISHED"); - expect(executionData4.run.attemptNumber).toBe(2); - expect(executionData4.run.status).toBe("COMPLETED_SUCCESSFULLY"); - } finally { - engine.quit(); - } + }, + }); + expect(result.attemptStatus).toBe("RETRY_IMMEDIATELY"); + expect(result.snapshot.executionStatus).toBe("PENDING_EXECUTING"); + expect(result.run.status).toBe("RETRYING_AFTER_FAILURE"); + + //state should be pending + const executionData3 = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData3); + expect(executionData3.snapshot.executionStatus).toBe("PENDING_EXECUTING"); + //only when the new attempt is created, should the attempt be increased + expect(executionData3.run.attemptNumber).toBe(1); + expect(executionData3.run.status).toBe("RETRYING_AFTER_FAILURE"); + + //create a second attempt + const attemptResult2 = await engine.startRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: executionData3.snapshot.id, + }); + expect(attemptResult2.run.attemptNumber).toBe(2); + + //now complete it successfully + const result2 = await engine.completeRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: attemptResult2.snapshot.id, + completion: { + ok: true, + id: dequeued[0].run.id, + output: `{"foo":"bar"}`, + outputType: "application/json", + }, + }); + expect(result2.snapshot.executionStatus).toBe("FINISHED"); + expect(result2.run.attemptNumber).toBe(2); + expect(result2.run.status).toBe("COMPLETED_SUCCESSFULLY"); + + //waitpoint should have been completed, with the output + const runWaitpointAfter = await prisma.waitpoint.findMany({ + where: { + completedByTaskRunId: run.id, + }, + }); + expect(runWaitpointAfter.length).toBe(1); + expect(runWaitpointAfter[0].type).toBe("RUN"); + expect(runWaitpointAfter[0].output).toBe(`{"foo":"bar"}`); + expect(runWaitpointAfter[0].outputIsError).toBe(false); + + //state should be completed + const executionData4 = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData4); + expect(executionData4.snapshot.executionStatus).toBe("FINISHED"); + expect(executionData4.run.attemptNumber).toBe(2); + expect(executionData4.run.status).toBe("COMPLETED_SUCCESSFULLY"); + } finally { + engine.quit(); } - ); + }); - containerTest("Fail (no more retries)", { timeout: 15_000 }, async ({ prisma, redisOptions }) => { + containerTest("Fail (no more retries)", async ({ prisma, redisOptions }) => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); @@ -278,120 +274,116 @@ describe("RunEngine attempt failures", () => { } }); - containerTest( - "Fail (not a retriable error)", - { timeout: 15_000 }, - async ({ prisma, redisOptions }) => { - //create environment - const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + containerTest("Fail (not a retriable error)", 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, - }, + 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: { - defaultMachine: "small-1x", - machines: { - "small-1x": { - name: "small-1x" as const, - cpu: 0.5, - memory: 0.5, - centsPerMs: 0.0001, - }, + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, }, - baseCostInCents: 0.0001, }, - tracer: trace.getTracer("test", "0.0.0"), + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + //create background worker + await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier, undefined, { + maxAttempts: 1, }); - try { - const taskIdentifier = "test-task"; - - //create background worker - await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier, undefined, { - maxAttempts: 1, - }); - - //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", - queueName: "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 - const attemptResult = await engine.startRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: dequeued[0].snapshot.id, - }); - - //fail the attempt with an unretriable error - const error = { - type: "INTERNAL_ERROR" as const, - code: "DISK_SPACE_EXCEEDED" as const, - }; - const result = await engine.completeRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: attemptResult.snapshot.id, - completion: { - ok: false, - id: dequeued[0].run.id, - error, - retry: { - timestamp: Date.now(), - delay: 0, - }, + //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", + queueName: "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 + const attemptResult = await engine.startRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: dequeued[0].snapshot.id, + }); + + //fail the attempt with an unretriable error + const error = { + type: "INTERNAL_ERROR" as const, + code: "DISK_SPACE_EXCEEDED" as const, + }; + const result = await engine.completeRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: attemptResult.snapshot.id, + completion: { + ok: false, + id: dequeued[0].run.id, + error, + retry: { + timestamp: Date.now(), + delay: 0, }, - }); - expect(result.attemptStatus).toBe("RUN_FINISHED"); - expect(result.snapshot.executionStatus).toBe("FINISHED"); - expect(result.run.status).toBe("CRASHED"); - - //state should be pending - const executionData3 = await engine.getRunExecutionData({ runId: run.id }); - assertNonNullable(executionData3); - expect(executionData3.snapshot.executionStatus).toBe("FINISHED"); - //only when the new attempt is created, should the attempt be increased - expect(executionData3.run.attemptNumber).toBe(1); - expect(executionData3.run.status).toBe("CRASHED"); - } finally { - engine.quit(); - } + }, + }); + expect(result.attemptStatus).toBe("RUN_FINISHED"); + expect(result.snapshot.executionStatus).toBe("FINISHED"); + expect(result.run.status).toBe("CRASHED"); + + //state should be pending + const executionData3 = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData3); + expect(executionData3.snapshot.executionStatus).toBe("FINISHED"); + //only when the new attempt is created, should the attempt be increased + expect(executionData3.run.attemptNumber).toBe(1); + expect(executionData3.run.status).toBe("CRASHED"); + } finally { + engine.quit(); } - ); + }); - containerTest("OOM fail", { timeout: 15_000 }, async ({ prisma, redisOptions }) => { + containerTest("OOM fail", async ({ prisma, redisOptions }) => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); @@ -498,332 +490,324 @@ describe("RunEngine attempt failures", () => { } }); - containerTest( - "OOM retry on larger machine", - { timeout: 15_000 }, - async ({ prisma, redisOptions }) => { - //create environment - const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + containerTest("OOM retry on larger machine", 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, - }, + 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: { - defaultMachine: "small-1x", - machines: { - "small-1x": { - name: "small-1x" as const, - cpu: 0.5, - memory: 0.5, - centsPerMs: 0.0001, - }, - "small-2x": { - name: "small-2x" as const, - cpu: 1, - memory: 1, - centsPerMs: 0.0002, - }, + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, + "small-2x": { + name: "small-2x" as const, + cpu: 1, + memory: 1, + centsPerMs: 0.0002, }, - baseCostInCents: 0.0001, }, - tracer: trace.getTracer("test", "0.0.0"), + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + //create background worker + await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier, undefined, { + outOfMemory: { + machine: "small-2x", + }, }); - try { - const taskIdentifier = "test-task"; + //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", + queueName: "task/test-task", + isTest: false, + tags: [], + }, + prisma + ); - //create background worker - await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier, undefined, { - outOfMemory: { - machine: "small-2x", - }, - }); - - //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", - queueName: "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 - const attemptResult = await engine.startRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: dequeued[0].snapshot.id, - }); - - //fail the attempt with an OOM error - const error = { - type: "INTERNAL_ERROR" as const, - code: "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE" as const, - message: "Process exited with code -1 after signal SIGKILL.", - stackTrace: "JavaScript heap out of memory", - }; - - const result = await engine.completeRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: attemptResult.snapshot.id, - completion: { - ok: false, - id: dequeued[0].run.id, - error, - }, - }); - - // The run should be retried with a larger machine - expect(result.attemptStatus).toBe("RETRY_QUEUED"); - expect(result.snapshot.executionStatus).toBe("QUEUED"); - expect(result.run.status).toBe("RETRYING_AFTER_FAILURE"); - - //state should be pending - const executionData = await engine.getRunExecutionData({ runId: run.id }); - assertNonNullable(executionData); - expect(executionData.snapshot.executionStatus).toBe("QUEUED"); - expect(executionData.run.attemptNumber).toBe(1); - expect(executionData.run.status).toBe("RETRYING_AFTER_FAILURE"); - - //create a second attempt - const attemptResult2 = await engine.startRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: executionData.snapshot.id, - }); - expect(attemptResult2.run.attemptNumber).toBe(2); - - //now complete it successfully - const result2 = await engine.completeRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: attemptResult2.snapshot.id, - completion: { - ok: true, - id: dequeued[0].run.id, - output: `{"foo":"bar"}`, - outputType: "application/json", - }, - }); - expect(result2.snapshot.executionStatus).toBe("FINISHED"); - expect(result2.run.attemptNumber).toBe(2); - expect(result2.run.status).toBe("COMPLETED_SUCCESSFULLY"); - - //waitpoint should have been completed, with the output - const runWaitpointAfter = await prisma.waitpoint.findMany({ - where: { - completedByTaskRunId: run.id, - }, - }); - expect(runWaitpointAfter.length).toBe(1); - expect(runWaitpointAfter[0].type).toBe("RUN"); - expect(runWaitpointAfter[0].output).toBe(`{"foo":"bar"}`); - expect(runWaitpointAfter[0].outputIsError).toBe(false); - - //state should be completed - const executionData4 = await engine.getRunExecutionData({ runId: run.id }); - assertNonNullable(executionData4); - expect(executionData4.snapshot.executionStatus).toBe("FINISHED"); - expect(executionData4.run.attemptNumber).toBe(2); - expect(executionData4.run.status).toBe("COMPLETED_SUCCESSFULLY"); - } finally { - engine.quit(); - } - } - ); + //dequeue the run + const dequeued = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: run.masterQueue, + maxRunCount: 10, + }); - containerTest( - "OOM fails after retrying on larger machine", - { timeout: 15_000 }, - async ({ prisma, redisOptions }) => { - //create environment - const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + //create an attempt + const attemptResult = await engine.startRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: dequeued[0].snapshot.id, + }); - const engine = new RunEngine({ - prisma, - worker: { - redis: redisOptions, - workers: 1, - tasksPerWorker: 10, - pollIntervalMs: 100, + //fail the attempt with an OOM error + const error = { + type: "INTERNAL_ERROR" as const, + code: "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE" as const, + message: "Process exited with code -1 after signal SIGKILL.", + stackTrace: "JavaScript heap out of memory", + }; + + const result = await engine.completeRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: attemptResult.snapshot.id, + completion: { + ok: false, + id: dequeued[0].run.id, + error, }, - queue: { - redis: redisOptions, + }); + + // The run should be retried with a larger machine + expect(result.attemptStatus).toBe("RETRY_QUEUED"); + expect(result.snapshot.executionStatus).toBe("QUEUED"); + expect(result.run.status).toBe("RETRYING_AFTER_FAILURE"); + + //state should be pending + const executionData = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData); + expect(executionData.snapshot.executionStatus).toBe("QUEUED"); + expect(executionData.run.attemptNumber).toBe(1); + expect(executionData.run.status).toBe("RETRYING_AFTER_FAILURE"); + + //create a second attempt + const attemptResult2 = await engine.startRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: executionData.snapshot.id, + }); + expect(attemptResult2.run.attemptNumber).toBe(2); + + //now complete it successfully + const result2 = await engine.completeRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: attemptResult2.snapshot.id, + completion: { + ok: true, + id: dequeued[0].run.id, + output: `{"foo":"bar"}`, + outputType: "application/json", }, - runLock: { - redis: redisOptions, + }); + expect(result2.snapshot.executionStatus).toBe("FINISHED"); + expect(result2.run.attemptNumber).toBe(2); + expect(result2.run.status).toBe("COMPLETED_SUCCESSFULLY"); + + //waitpoint should have been completed, with the output + const runWaitpointAfter = await prisma.waitpoint.findMany({ + where: { + completedByTaskRunId: run.id, }, + }); + expect(runWaitpointAfter.length).toBe(1); + expect(runWaitpointAfter[0].type).toBe("RUN"); + expect(runWaitpointAfter[0].output).toBe(`{"foo":"bar"}`); + expect(runWaitpointAfter[0].outputIsError).toBe(false); + + //state should be completed + const executionData4 = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData4); + expect(executionData4.snapshot.executionStatus).toBe("FINISHED"); + expect(executionData4.run.attemptNumber).toBe(2); + expect(executionData4.run.status).toBe("COMPLETED_SUCCESSFULLY"); + } finally { + engine.quit(); + } + }); + + containerTest("OOM fails after retrying on larger machine", 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: { - defaultMachine: "small-1x", - machines: { - "small-1x": { - name: "small-1x" as const, - cpu: 0.5, - memory: 0.5, - centsPerMs: 0.0001, - }, - "small-2x": { - name: "small-2x" as const, - cpu: 1, - memory: 1, - centsPerMs: 0.0002, - }, + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, + "small-2x": { + name: "small-2x" as const, + cpu: 1, + memory: 1, + centsPerMs: 0.0002, }, - baseCostInCents: 0.0001, }, - tracer: trace.getTracer("test", "0.0.0"), + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + //create background worker + await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier, undefined, { + maxTimeoutInMs: 10, + maxAttempts: 10, + outOfMemory: { + machine: "small-2x", + }, }); - try { - const taskIdentifier = "test-task"; + //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", + queueName: "task/test-task", + isTest: false, + tags: [], + }, + prisma + ); - //create background worker - await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier, undefined, { - maxTimeoutInMs: 10, - maxAttempts: 10, - outOfMemory: { - machine: "small-2x", - }, - }); - - //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", - queueName: "task/test-task", - isTest: false, - tags: [], - }, - prisma - ); - - //dequeue the run - const dequeued = await engine.dequeueFromMasterQueue({ - consumerId: "test_12345", - masterQueue: run.masterQueue, - maxRunCount: 10, - }); - - //create first attempt - const attemptResult = await engine.startRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: dequeued[0].snapshot.id, - }); - - //fail the first attempt with an OOM error - const error = { - type: "INTERNAL_ERROR" as const, - code: "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE" as const, - message: "Process exited with code -1 after signal SIGKILL.", - stackTrace: "JavaScript heap out of memory", - }; - - const result = await engine.completeRunAttempt({ - runId: dequeued[0].run.id, - snapshotId: attemptResult.snapshot.id, - completion: { - ok: false, - id: dequeued[0].run.id, - error, - }, - }); - - // The run should be retried with a larger machine - expect(result.attemptStatus).toBe("RETRY_QUEUED"); - expect(result.snapshot.executionStatus).toBe("QUEUED"); - expect(result.run.status).toBe("RETRYING_AFTER_FAILURE"); - - //state should be queued - const executionData = await engine.getRunExecutionData({ runId: run.id }); - assertNonNullable(executionData); - expect(executionData.snapshot.executionStatus).toBe("QUEUED"); - expect(executionData.run.attemptNumber).toBe(1); - expect(executionData.run.status).toBe("RETRYING_AFTER_FAILURE"); - - //wait for 1s - await setTimeout(1_000); - - //dequeue again - const dequeued2 = await engine.dequeueFromMasterQueue({ - consumerId: "test_12345", - masterQueue: run.masterQueue, - maxRunCount: 10, - }); - expect(dequeued2.length).toBe(1); - - //create second attempt - const attemptResult2 = await engine.startRunAttempt({ - runId: dequeued2[0].run.id, - snapshotId: dequeued2[0].snapshot.id, - }); - expect(attemptResult2.run.attemptNumber).toBe(2); - - //fail the second attempt with the same OOM error - const result2 = await engine.completeRunAttempt({ - runId: dequeued2[0].run.id, - snapshotId: attemptResult2.snapshot.id, - completion: { - ok: false, - id: dequeued2[0].run.id, - error, - retry: { - timestamp: Date.now(), - delay: 0, - }, + //dequeue the run + const dequeued = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: run.masterQueue, + maxRunCount: 10, + }); + + //create first attempt + const attemptResult = await engine.startRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: dequeued[0].snapshot.id, + }); + + //fail the first attempt with an OOM error + const error = { + type: "INTERNAL_ERROR" as const, + code: "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE" as const, + message: "Process exited with code -1 after signal SIGKILL.", + stackTrace: "JavaScript heap out of memory", + }; + + const result = await engine.completeRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: attemptResult.snapshot.id, + completion: { + ok: false, + id: dequeued[0].run.id, + error, + }, + }); + + // The run should be retried with a larger machine + expect(result.attemptStatus).toBe("RETRY_QUEUED"); + expect(result.snapshot.executionStatus).toBe("QUEUED"); + expect(result.run.status).toBe("RETRYING_AFTER_FAILURE"); + + //state should be queued + const executionData = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData); + expect(executionData.snapshot.executionStatus).toBe("QUEUED"); + expect(executionData.run.attemptNumber).toBe(1); + expect(executionData.run.status).toBe("RETRYING_AFTER_FAILURE"); + + //wait for 1s + await setTimeout(5_000); + + //dequeue again + const dequeued2 = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: run.masterQueue, + maxRunCount: 10, + }); + expect(dequeued2.length).toBe(1); + + //create second attempt + const attemptResult2 = await engine.startRunAttempt({ + runId: dequeued2[0].run.id, + snapshotId: dequeued2[0].snapshot.id, + }); + expect(attemptResult2.run.attemptNumber).toBe(2); + + //fail the second attempt with the same OOM error + const result2 = await engine.completeRunAttempt({ + runId: dequeued2[0].run.id, + snapshotId: attemptResult2.snapshot.id, + completion: { + ok: false, + id: dequeued2[0].run.id, + error, + retry: { + timestamp: Date.now(), + delay: 0, }, - }); - - // The run should fail after the second OOM - expect(result2.attemptStatus).toBe("RUN_FINISHED"); - expect(result2.snapshot.executionStatus).toBe("FINISHED"); - expect(result2.run.status).toBe("CRASHED"); - - //final state should be crashed - const finalExecutionData = await engine.getRunExecutionData({ runId: run.id }); - assertNonNullable(finalExecutionData); - expect(finalExecutionData.snapshot.executionStatus).toBe("FINISHED"); - expect(finalExecutionData.run.attemptNumber).toBe(2); - expect(finalExecutionData.run.status).toBe("CRASHED"); - } finally { - engine.quit(); - } + }, + }); + + // The run should fail after the second OOM + expect(result2.attemptStatus).toBe("RUN_FINISHED"); + expect(result2.snapshot.executionStatus).toBe("FINISHED"); + expect(result2.run.status).toBe("CRASHED"); + + //final state should be crashed + const finalExecutionData = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(finalExecutionData); + expect(finalExecutionData.snapshot.executionStatus).toBe("FINISHED"); + expect(finalExecutionData.run.attemptNumber).toBe(2); + expect(finalExecutionData.run.status).toBe("CRASHED"); + } finally { + engine.quit(); } - ); + }); }); diff --git a/internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts b/internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts new file mode 100644 index 0000000000..d2ba5f1b34 --- /dev/null +++ b/internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts @@ -0,0 +1,585 @@ +import { + assertNonNullable, + containerTest, + setupAuthenticatedEnvironment, + setupBackgroundWorker, +} from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { expect } from "vitest"; +import { RunEngine } from "../index.js"; +import { setTimeout } from "node:timers/promises"; +import { TaskRunErrorCodes } from "@trigger.dev/core/v3/schemas"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("Reserve concurrency", () => { + containerTest( + "triggerAndWait reserves concurrency on the environment when triggering a child task on a different queue", + 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, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + await engine.runQueue.updateEnvConcurrencyLimits({ + ...authenticatedEnvironment, + maximumConcurrencyLimit: 1, + }); + + const parentTask = "parent-task"; + const childTask = "child-task"; + + //create background worker + await setupBackgroundWorker(prisma, authenticatedEnvironment, [parentTask, childTask]); + + //trigger the run + const parentRun = await engine.trigger( + { + number: 1, + friendlyId: "run_p1234", + environment: authenticatedEnvironment, + taskIdentifier: parentTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + masterQueue: "main", + queueName: `task/${parentTask}`, + isTest: false, + tags: [], + }, + prisma + ); + + //dequeue parent + const dequeued = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: parentRun.masterQueue, + maxRunCount: 10, + }); + + //create an attempt + const initialExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(initialExecutionData); + const attemptResult = await engine.startRunAttempt({ + runId: parentRun.id, + snapshotId: initialExecutionData.snapshot.id, + }); + + const childRun = await engine.trigger( + { + number: 1, + friendlyId: "run_c1234", + environment: authenticatedEnvironment, + taskIdentifier: childTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + masterQueue: "main", + queueName: `task/${childTask}`, + isTest: false, + tags: [], + resumeParentOnCompletion: true, + parentTaskRunId: parentRun.id, + }, + prisma + ); + + const childExecutionData = await engine.getRunExecutionData({ runId: childRun.id }); + assertNonNullable(childExecutionData); + expect(childExecutionData.snapshot.executionStatus).toBe("QUEUED"); + + const parentExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(parentExecutionData); + expect(parentExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + //check the waitpoint blocking the parent run + const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ + where: { + taskRunId: parentRun.id, + }, + include: { + waitpoint: true, + }, + }); + assertNonNullable(runWaitpoint); + expect(runWaitpoint.waitpoint.type).toBe("RUN"); + expect(runWaitpoint.waitpoint.completedByTaskRunId).toBe(childRun.id); + + //dequeue the child run + const dequeuedChild = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: childRun.masterQueue, + maxRunCount: 10, + }); + + expect(dequeuedChild.length).toBe(1); + + //start the child run + const childAttempt = await engine.startRunAttempt({ + runId: childRun.id, + snapshotId: dequeuedChild[0].snapshot.id, + }); + + // complete the child run + await engine.completeRunAttempt({ + runId: childRun.id, + snapshotId: childAttempt.snapshot.id, + completion: { + id: childRun.id, + ok: true, + output: '{"foo":"bar"}', + outputType: "application/json", + }, + }); + + //child snapshot + const childExecutionDataAfter = await engine.getRunExecutionData({ runId: childRun.id }); + assertNonNullable(childExecutionDataAfter); + expect(childExecutionDataAfter.snapshot.executionStatus).toBe("FINISHED"); + + const waitpointAfter = await prisma.waitpoint.findFirst({ + where: { + id: runWaitpoint.waitpointId, + }, + }); + expect(waitpointAfter?.completedAt).not.toBeNull(); + expect(waitpointAfter?.status).toBe("COMPLETED"); + expect(waitpointAfter?.output).toBe('{"foo":"bar"}'); + + await setTimeout(500); + + const runWaitpointAfter = await prisma.taskRunWaitpoint.findFirst({ + where: { + taskRunId: parentRun.id, + }, + include: { + waitpoint: true, + }, + }); + expect(runWaitpointAfter).toBeNull(); + + //parent snapshot + const parentExecutionDataAfter = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(parentExecutionDataAfter); + expect(parentExecutionDataAfter.snapshot.executionStatus).toBe("EXECUTING"); + expect(parentExecutionDataAfter.completedWaitpoints?.length).toBe(1); + expect(parentExecutionDataAfter.completedWaitpoints![0].id).toBe(runWaitpoint.waitpointId); + expect(parentExecutionDataAfter.completedWaitpoints![0].completedByTaskRun?.id).toBe( + childRun.id + ); + expect(parentExecutionDataAfter.completedWaitpoints![0].output).toBe('{"foo":"bar"}'); + } finally { + engine.quit(); + } + } + ); + + containerTest( + "triggerAndWait reserves concurrency on the environment and the queue when triggering a child task on the same queue", + 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, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + await engine.runQueue.updateEnvConcurrencyLimits({ + ...authenticatedEnvironment, + maximumConcurrencyLimit: 1, + }); + + const parentTask = "parent-task"; + const childTask = "child-task"; + + //create background worker + await setupBackgroundWorker(prisma, authenticatedEnvironment, [parentTask, childTask]); + + //trigger the run + const parentRun = await engine.trigger( + { + number: 1, + friendlyId: "run_p1234", + environment: authenticatedEnvironment, + taskIdentifier: parentTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + masterQueue: "main", + queueName: "shared-queue", + queue: { + concurrencyLimit: 1, + }, + isTest: false, + tags: [], + }, + prisma + ); + + //dequeue parent + const dequeued = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: parentRun.masterQueue, + maxRunCount: 10, + }); + + expect(dequeued.length).toBe(1); + + //create an attempt + const initialExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(initialExecutionData); + const attemptResult = await engine.startRunAttempt({ + runId: parentRun.id, + snapshotId: initialExecutionData.snapshot.id, + }); + + expect(attemptResult).toBeDefined(); + + const childRun = await engine.trigger( + { + number: 1, + friendlyId: "run_c1234", + environment: authenticatedEnvironment, + taskIdentifier: childTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + masterQueue: "main", + queueName: "shared-queue", + queue: { + concurrencyLimit: 1, + }, + isTest: false, + tags: [], + resumeParentOnCompletion: true, + parentTaskRunId: parentRun.id, + }, + prisma + ); + + const childExecutionData = await engine.getRunExecutionData({ runId: childRun.id }); + assertNonNullable(childExecutionData); + expect(childExecutionData.snapshot.executionStatus).toBe("QUEUED"); + + const parentExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(parentExecutionData); + expect(parentExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + //check the waitpoint blocking the parent run + const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ + where: { + taskRunId: parentRun.id, + }, + include: { + waitpoint: true, + }, + }); + assertNonNullable(runWaitpoint); + expect(runWaitpoint.waitpoint.type).toBe("RUN"); + expect(runWaitpoint.waitpoint.completedByTaskRunId).toBe(childRun.id); + + //dequeue the child run + const dequeuedChild = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: childRun.masterQueue, + maxRunCount: 10, + }); + + expect(dequeuedChild.length).toBe(1); + + //start the child run + const childAttempt = await engine.startRunAttempt({ + runId: childRun.id, + snapshotId: dequeuedChild[0].snapshot.id, + }); + + // complete the child run + await engine.completeRunAttempt({ + runId: childRun.id, + snapshotId: childAttempt.snapshot.id, + completion: { + id: childRun.id, + ok: true, + output: '{"foo":"bar"}', + outputType: "application/json", + }, + }); + + //child snapshot + const childExecutionDataAfter = await engine.getRunExecutionData({ runId: childRun.id }); + assertNonNullable(childExecutionDataAfter); + expect(childExecutionDataAfter.snapshot.executionStatus).toBe("FINISHED"); + + const waitpointAfter = await prisma.waitpoint.findFirst({ + where: { + id: runWaitpoint.waitpointId, + }, + }); + expect(waitpointAfter?.completedAt).not.toBeNull(); + expect(waitpointAfter?.status).toBe("COMPLETED"); + expect(waitpointAfter?.output).toBe('{"foo":"bar"}'); + + await setTimeout(500); + + const runWaitpointAfter = await prisma.taskRunWaitpoint.findFirst({ + where: { + taskRunId: parentRun.id, + }, + include: { + waitpoint: true, + }, + }); + expect(runWaitpointAfter).toBeNull(); + + //parent snapshot + const parentExecutionDataAfter = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(parentExecutionDataAfter); + expect(parentExecutionDataAfter.snapshot.executionStatus).toBe("EXECUTING"); + expect(parentExecutionDataAfter.completedWaitpoints?.length).toBe(1); + expect(parentExecutionDataAfter.completedWaitpoints![0].id).toBe(runWaitpoint.waitpointId); + expect(parentExecutionDataAfter.completedWaitpoints![0].completedByTaskRun?.id).toBe( + childRun.id + ); + expect(parentExecutionDataAfter.completedWaitpoints![0].output).toBe('{"foo":"bar"}'); + } finally { + engine.quit(); + } + } + ); + + containerTest( + "triggerAndWait fails with recursive deadlock error when there is no more reserve concurrency left when triggering a child task on the same queue", + 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, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + await engine.runQueue.updateEnvConcurrencyLimits({ + ...authenticatedEnvironment, + maximumConcurrencyLimit: 1, + }); + + const parentTask = "parent-task"; + const childTask = "child-task"; + + //create background worker + await setupBackgroundWorker(prisma, authenticatedEnvironment, [parentTask, childTask]); + + //trigger the run + const parentRun = await engine.trigger( + { + number: 1, + friendlyId: "run_p1234", + environment: authenticatedEnvironment, + taskIdentifier: parentTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + masterQueue: "main", + queueName: "shared-queue", + queue: { + concurrencyLimit: 1, + }, + isTest: false, + tags: [], + }, + prisma + ); + + //dequeue parent + const dequeued = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: parentRun.masterQueue, + maxRunCount: 10, + }); + + expect(dequeued.length).toBe(1); + + //create an attempt + const initialExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(initialExecutionData); + const attemptResult = await engine.startRunAttempt({ + runId: parentRun.id, + snapshotId: initialExecutionData.snapshot.id, + }); + + expect(attemptResult).toBeDefined(); + + const childRun = await engine.trigger( + { + number: 1, + friendlyId: "run_c1234", + environment: authenticatedEnvironment, + taskIdentifier: childTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + masterQueue: "main", + queueName: "shared-queue", + queue: { + concurrencyLimit: 1, + }, + isTest: false, + tags: [], + resumeParentOnCompletion: true, + parentTaskRunId: parentRun.id, + }, + prisma + ); + + const childExecutionData = await engine.getRunExecutionData({ runId: childRun.id }); + assertNonNullable(childExecutionData); + expect(childExecutionData.snapshot.executionStatus).toBe("QUEUED"); + + const parentExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(parentExecutionData); + expect(parentExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + //dequeue the child run + const dequeuedChild = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: childRun.masterQueue, + maxRunCount: 10, + }); + + expect(dequeuedChild.length).toBe(1); + + // Now try and trigger another child run on the same queue + const childRun2 = await engine.trigger( + { + number: 1, + friendlyId: "run_c12345", + environment: authenticatedEnvironment, + taskIdentifier: childTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345_2", + spanId: "s12345_2", + masterQueue: "main", + queueName: "shared-queue", + queue: { + concurrencyLimit: 1, + }, + isTest: false, + tags: [], + resumeParentOnCompletion: true, + parentTaskRunId: parentRun.id, + }, + prisma + ); + + expect(childRun2.status).toBe("SYSTEM_FAILURE"); + expect(childRun2.error).toEqual({ + type: "INTERNAL_ERROR", + code: TaskRunErrorCodes.RECURSIVE_WAIT_DEADLOCK, + message: expect.any(String), + }); + } finally { + engine.quit(); + } + } + ); +}); diff --git a/references/test-tasks/src/trigger/test-reserve-concurrency-system.ts b/references/test-tasks/src/trigger/test-reserve-concurrency-system.ts index 05b8eba9a4..4a0b044791 100644 --- a/references/test-tasks/src/trigger/test-reserve-concurrency-system.ts +++ b/references/test-tasks/src/trigger/test-reserve-concurrency-system.ts @@ -1,4 +1,4 @@ -import { logger, task } from "@trigger.dev/sdk/v3"; +import { batch, logger, task } from "@trigger.dev/sdk/v3"; import assert from "assert"; import { getEnvironmentStats, @@ -293,8 +293,10 @@ export const testEnvReserveConcurrency = task({ })) ); + const retrievedHoldBatch = await batch.retrieve(holdBatch.batchId); + // Wait for the hold tasks to be executing - await Promise.all(holdBatch.runs.map((run) => waitForRunStatus(run.id, ["EXECUTING"]))); + await Promise.all(retrievedHoldBatch.runs.map((run) => waitForRunStatus(run, ["EXECUTING"]))); // Now we will trigger a parent task that will trigger a child task const parentRun = await genericParentTask.trigger( @@ -341,7 +343,7 @@ export const testEnvReserveConcurrency = task({ ); // Wait for the hold tasks to be completed - await Promise.all(holdBatch.runs.map((run) => waitForRunStatus(run.id, ["COMPLETED"]))); + await Promise.all(retrievedHoldBatch.runs.map((run) => waitForRunStatus(run, ["COMPLETED"]))); await updateEnvironmentConcurrencyLimit(ctx.environment.id, 100); From e9fa4ce3a2ba443f0d1562fdf787d73a58c6af39 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 12 Mar 2025 15:44:55 +0000 Subject: [PATCH 07/38] Update delays to use a redis worker and work with the new reserve concurrency system --- .vscode/launch.json | 2 +- .../run-engine/src/engine/index.ts | 156 ++++++---- .../src/engine/tests/delays.test.ts | 287 +++++++++++++++++- .../engine/tests/reserveConcurrency.test.ts | 2 +- 4 files changed, 390 insertions(+), 57 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index da6e7674a5..bb8c931ec9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -138,7 +138,7 @@ "type": "node-terminal", "request": "launch", "name": "Debug RunEngine tests", - "command": "pnpm run test ./src/engine/tests/attemptFailures.test.ts -t 'OOM fails after retrying on larger machine'", + "command": "pnpm run test ./src/engine/tests/delays.test.ts -t 'Delayed run with a ttl'", "cwd": "${workspaceFolder}/internal-packages/run-engine", "sourceMaps": true }, diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 95d78af404..1c8a296c6a 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -118,6 +118,12 @@ const workerCatalog = { }), visibilityTimeoutMs: 10_000, }, + enqueueDelayedRun: { + schema: z.object({ + runId: z.string(), + }), + visibilityTimeoutMs: 10_000, + }, }; type EngineWorker = Worker; @@ -215,6 +221,9 @@ export class RunEngine { runId: payload.runId, }); }, + enqueueDelayedRun: async ({ payload }) => { + await this.#enqueueDelayedRun({ runId: payload.runId }); + }, }, }).start(); @@ -515,41 +524,18 @@ export class RunEngine { } } - if (taskRun.delayUntil) { - const delayWaitpointResult = await this.createDateTimeWaitpoint({ - projectId: environment.project.id, - environmentId: environment.id, - completedAfter: taskRun.delayUntil, - tx: prisma, - }); - - await prisma.taskRunWaitpoint.create({ - data: { - taskRunId: taskRun.id, - waitpointId: delayWaitpointResult.waitpoint.id, - projectId: delayWaitpointResult.waitpoint.projectId, - }, - }); - } - - if (!taskRun.delayUntil && taskRun.ttl) { - const expireAt = parseNaturalLanguageDuration(taskRun.ttl); - - if (expireAt) { - await this.worker.enqueue({ - id: `expireRun:${taskRun.id}`, - job: "expireRun", - payload: { runId: taskRun.id }, - availableAt: expireAt, - }); - } - } - //Make sure lock extension succeeded signal.throwIfAborted(); - //enqueue the run if it's not delayed - if (!taskRun.delayUntil) { + if (taskRun.delayUntil) { + // Schedule the run to be enqueued at the delayUntil time + await this.worker.enqueue({ + id: `enqueueDelayedRun:${taskRun.id}`, + job: "enqueueDelayedRun", + payload: { runId: taskRun.id }, + availableAt: taskRun.delayUntil, + }); + } else { const { wasEnqueued, error } = await this.#enqueueRun({ run: taskRun, env: environment, @@ -565,12 +551,25 @@ export class RunEngine { taskRun = await prisma.taskRun.update({ where: { id: taskRun.id }, data: { - status: "SYSTEM_FAILURE", + status: runStatusFromError(error), completedAt: new Date(), error, }, }); } + + if (wasEnqueued && taskRun.ttl) { + const expireAt = parseNaturalLanguageDuration(taskRun.ttl); + + if (expireAt) { + await this.worker.enqueue({ + id: `expireRun:${taskRun.id}`, + job: "expireRun", + payload: { runId: taskRun.id }, + availableAt: expireAt, + }); + } + } } }); @@ -1598,7 +1597,7 @@ export class RunEngine { /** * Reschedules a delayed run where the run hasn't been queued yet */ - async rescheduleRun({ + async rescheduleDelayedRun({ runId, delayUntil, tx, @@ -1634,26 +1633,9 @@ export class RunEngine { }, }, }, - include: { - blockedByWaitpoints: true, - }, }); - if (updatedRun.blockedByWaitpoints.length === 0) { - throw new ServiceValidationError( - "Cannot reschedule a run that is not blocked by a waitpoint" - ); - } - - const result = await this.#rescheduleDateTimeWaitpoint( - prisma, - updatedRun.blockedByWaitpoints[0].waitpointId, - delayUntil - ); - - if (!result.success) { - throw new ServiceValidationError("Failed to reschedule waitpoint, too late.", 400); - } + await this.worker.reschedule(`enqueueDelayedRun:${updatedRun.id}`, delayUntil); return updatedRun; }); @@ -3118,7 +3100,7 @@ export class RunEngine { runnerId, }: { runId: string; - snapshotId: string; + snapshotId?: string; failedAt: Date; error: TaskRunError; workerId?: string; @@ -3472,6 +3454,74 @@ export class RunEngine { }); } + async #enqueueDelayedRun({ runId }: { runId: string }) { + const run = await this.prisma.taskRun.findFirst({ + where: { id: runId }, + include: { + runtimeEnvironment: { + include: { + project: true, + organization: true, + }, + }, + }, + }); + + if (!run) { + throw new Error(`#enqueueDelayedRun: run not found: ${runId}`); + } + + let reserveConcurrency: RunQueueReserveConcurrencyOptions | undefined; + + if (run.parentTaskRunId) { + const parentRun = await this.prisma.taskRun.findFirst({ + where: { id: run.parentTaskRunId }, + }); + + if (parentRun) { + reserveConcurrency = { + messageId: parentRun.id, + recursiveQueue: parentRun.queue === run.queue, + }; + } + } + + // Now we need to enqueue the run into the RunQueue + const { wasEnqueued, error } = await this.#enqueueRun({ + run, + env: run.runtimeEnvironment, + timestamp: run.createdAt.getTime() - run.priorityMs, + reserveConcurrency, + }); + + if (error) { + await this.#permanentlyFailRun({ runId, error, failedAt: new Date() }); + } + + if (wasEnqueued) { + await this.prisma.taskRun.update({ + where: { id: runId }, + data: { + status: "PENDING", + queuedAt: new Date(), + }, + }); + + if (run.ttl) { + const expireAt = parseNaturalLanguageDuration(run.ttl); + + if (expireAt) { + await this.worker.enqueue({ + id: `expireRun:${runId}`, + job: "expireRun", + payload: { runId }, + availableAt: expireAt, + }); + } + } + } + } + async #queueRunsWaitingForWorker({ backgroundWorkerId }: { backgroundWorkerId: string }) { //It could be a lot of runs, so we will process them in a batch //if there are still more to process we will enqueue this function again diff --git a/internal-packages/run-engine/src/engine/tests/delays.test.ts b/internal-packages/run-engine/src/engine/tests/delays.test.ts index 0d87d27e47..8dd24bf7e7 100644 --- a/internal-packages/run-engine/src/engine/tests/delays.test.ts +++ b/internal-packages/run-engine/src/engine/tests/delays.test.ts @@ -8,6 +8,7 @@ import { trace } from "@internal/tracing"; import { expect } from "vitest"; import { RunEngine } from "../index.js"; import { setTimeout } from "timers/promises"; +import { TaskRunErrorCodes } from "@trigger.dev/core/v3"; vi.setConfig({ testTimeout: 60_000 }); @@ -154,7 +155,7 @@ describe("RunEngine delays", () => { queueName: "task/test-task", isTest: false, tags: [], - delayUntil: new Date(Date.now() + 200), + delayUntil: new Date(Date.now() + 400), }, prisma ); @@ -165,7 +166,10 @@ describe("RunEngine delays", () => { expect(executionData.snapshot.executionStatus).toBe("RUN_CREATED"); const rescheduleTo = new Date(Date.now() + 1_500); - const updatedRun = await engine.rescheduleRun({ runId: run.id, delayUntil: rescheduleTo }); + const updatedRun = await engine.rescheduleDelayedRun({ + runId: run.id, + delayUntil: rescheduleTo, + }); expect(updatedRun.delayUntil?.toISOString()).toBe(rescheduleTo.toISOString()); //wait so the initial delay passes @@ -187,4 +191,283 @@ describe("RunEngine delays", () => { engine.quit(); } }); + + containerTest("Delayed run with a ttl", 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, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + //create background worker + const backgroundWorker = await setupBackgroundWorker( + prisma, + 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", + queueName: "task/test-task", + isTest: false, + tags: [], + delayUntil: new Date(Date.now() + 1000), + ttl: "2s", + }, + prisma + ); + + //should be created but not queued yet + const executionData = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData); + expect(executionData.snapshot.executionStatus).toBe("RUN_CREATED"); + expect(run.status).toBe("DELAYED"); + + //wait for 1 seconds + await setTimeout(2_500); + + //should now be queued + const executionData2 = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData2); + expect(executionData2.snapshot.executionStatus).toBe("QUEUED"); + + const run2 = await prisma.taskRun.findFirstOrThrow({ + where: { id: run.id }, + }); + + expect(run2.status).toBe("PENDING"); + + //wait for 3 seconds + await setTimeout(3_000); + + //should now be expired + const executionData3 = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData3); + expect(executionData3.snapshot.executionStatus).toBe("FINISHED"); + + const run3 = await prisma.taskRun.findFirstOrThrow({ + where: { id: run.id }, + }); + + expect(run3.status).toBe("EXPIRED"); + } finally { + engine.quit(); + } + }); + + containerTest( + "Delayed run that fails to enqueue because of a recursive deadlock issue", + 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, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const parentTask = "parent-task"; + const childTask = "child-task"; + + //create background worker + await setupBackgroundWorker(prisma, authenticatedEnvironment, [parentTask, childTask]); + + //trigger the run + const parentRun = await engine.trigger( + { + number: 1, + friendlyId: "run_p1234", + environment: authenticatedEnvironment, + taskIdentifier: parentTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + masterQueue: "main", + queueName: "shared-queue", + queue: { + concurrencyLimit: 1, + }, + isTest: false, + tags: [], + }, + prisma + ); + + const dequeued = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: parentRun.masterQueue, + maxRunCount: 10, + }); + + expect(dequeued.length).toBe(1); + + const initialExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(initialExecutionData); + const attemptResult = await engine.startRunAttempt({ + runId: parentRun.id, + snapshotId: initialExecutionData.snapshot.id, + }); + + expect(attemptResult).toBeDefined(); + + const childRun = await engine.trigger( + { + number: 1, + friendlyId: "run_c1234", + environment: authenticatedEnvironment, + taskIdentifier: childTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + masterQueue: "main", + queueName: "shared-queue", + queue: { + concurrencyLimit: 1, + }, + isTest: false, + tags: [], + resumeParentOnCompletion: true, + parentTaskRunId: parentRun.id, + }, + prisma + ); + + const childExecutionData = await engine.getRunExecutionData({ runId: childRun.id }); + assertNonNullable(childExecutionData); + expect(childExecutionData.snapshot.executionStatus).toBe("QUEUED"); + + const parentExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); + assertNonNullable(parentExecutionData); + expect(parentExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + //dequeue the child run + const dequeuedChild = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: childRun.masterQueue, + maxRunCount: 10, + }); + + expect(dequeuedChild.length).toBe(1); + + // Now try and trigger another child run on the same queue + const childRun2 = await engine.trigger( + { + number: 1, + friendlyId: "run_c12345", + environment: authenticatedEnvironment, + taskIdentifier: childTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345_2", + spanId: "s12345_2", + masterQueue: "main", + queueName: "shared-queue", + queue: { + concurrencyLimit: 1, + }, + isTest: false, + tags: [], + resumeParentOnCompletion: true, + parentTaskRunId: parentRun.id, + delayUntil: new Date(Date.now() + 1000), + }, + prisma + ); + + const executionData = await engine.getRunExecutionData({ runId: childRun2.id }); + assertNonNullable(executionData); + expect(executionData.snapshot.executionStatus).toBe("RUN_CREATED"); + + await setTimeout(1_500); + + // Now the run should be failed + const run2 = await prisma.taskRun.findFirstOrThrow({ + where: { id: childRun2.id }, + }); + + expect(run2.status).toBe("COMPLETED_WITH_ERRORS"); + expect(run2.error).toEqual({ + type: "INTERNAL_ERROR", + code: TaskRunErrorCodes.RECURSIVE_WAIT_DEADLOCK, + message: expect.any(String), + }); + } finally { + engine.quit(); + } + } + ); }); diff --git a/internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts b/internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts index d2ba5f1b34..97b6319353 100644 --- a/internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts +++ b/internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts @@ -571,7 +571,7 @@ describe("Reserve concurrency", () => { prisma ); - expect(childRun2.status).toBe("SYSTEM_FAILURE"); + expect(childRun2.status).toBe("COMPLETED_WITH_ERRORS"); expect(childRun2.error).toEqual({ type: "INTERNAL_ERROR", code: TaskRunErrorCodes.RECURSIVE_WAIT_DEADLOCK, From 2b6ce169be38d106b148d6571359eb6ad1df39ec Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 12 Mar 2025 15:51:33 +0000 Subject: [PATCH 08/38] remove unused method --- .../run-engine/src/engine/index.ts | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 1c8a296c6a..9e4cb57dcb 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -3617,47 +3617,6 @@ export class RunEngine { }); } - async #rescheduleDateTimeWaitpoint( - tx: PrismaClientOrTransaction, - waitpointId: string, - completedAfter: Date - ): Promise<{ success: true } | { success: false; error: string }> { - try { - const updatedWaitpoint = await tx.waitpoint.update({ - where: { id: waitpointId, status: "PENDING" }, - data: { - completedAfter, - }, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { - return { - success: false, - error: "Waitpoint doesn't exist or is already completed", - }; - } - - this.logger.error("Error rescheduling waitpoint", { error }); - - return { - success: false, - error: "An unknown error occurred", - }; - } - - //reschedule completion - await this.worker.enqueue({ - id: `finishWaitpoint.${waitpointId}`, - job: "finishWaitpoint", - payload: { waitpointId: waitpointId }, - availableAt: completedAfter, - }); - - return { - success: true, - }; - } - async #clearBlockingWaitpoints({ runId, tx }: { runId: string; tx?: PrismaClientOrTransaction }) { const prisma = tx ?? this.prisma; const deleted = await prisma.taskRunWaitpoint.deleteMany({ From 1362d8296d838c46bfb1046824a3c9c849ead3fa Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 12 Mar 2025 15:53:32 +0000 Subject: [PATCH 09/38] Add batchId to the delayed enqueueRun call --- internal-packages/run-engine/src/engine/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 9e4cb57dcb..6aca681f07 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -3492,6 +3492,7 @@ export class RunEngine { env: run.runtimeEnvironment, timestamp: run.createdAt.getTime() - run.priorityMs, reserveConcurrency, + batchId: run.batchId ?? undefined, }); if (error) { From d11d4912152b559319bc28b94cdf8ac9d426bacb Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 13 Mar 2025 22:04:22 +0000 Subject: [PATCH 10/38] WIP release concurrency queue --- .cursor/mcp.json | 2 +- .vscode/launch.json | 2 +- ...ne.v1.runs.$runFriendlyId.wait.duration.ts | 4 +- ...points.tokens.$waitpointFriendlyId.wait.ts | 1 + .../run-engine/src/engine/index.ts | 114 +++- .../src/engine/releaseConcurrencyQueue.ts | 533 +++++++++++++++++ .../run-engine/src/engine/statuses.ts | 5 + .../tests/releaseConcurrencyQueue.test.ts | 544 ++++++++++++++++++ .../src/engine/tests/waitpoints.test.ts | 4 +- .../run-engine/src/engine/types.ts | 8 +- .../run-engine/src/run-queue/index.ts | 8 +- .../run-engine/src/run-queue/keyProducer.ts | 28 + .../run-engine/src/run-queue/types.ts | 5 + 13 files changed, 1228 insertions(+), 30 deletions(-) create mode 100644 internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts create mode 100644 internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 96a6f73e4e..9b3221784d 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -4,4 +4,4 @@ "url": "http://localhost:3333/sse" } } -} +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index bb8c931ec9..e044183922 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -138,7 +138,7 @@ "type": "node-terminal", "request": "launch", "name": "Debug RunEngine tests", - "command": "pnpm run test ./src/engine/tests/delays.test.ts -t 'Delayed run with a ttl'", + "command": "pnpm run test ./src/engine/tests/releaseConcurrencyQueue.test.ts -t 'Should manage token bucket and queue correctly'", "cwd": "${workspaceFolder}/internal-packages/run-engine", "sourceMaps": true }, diff --git a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts index 8e11d4d626..5ffdd138b7 100644 --- a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts +++ b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts @@ -51,9 +51,7 @@ const { action } = createActionApiRoute( environmentId: authentication.environment.id, projectId: authentication.environment.project.id, organizationId: authentication.environment.organization.id, - releaseConcurrency: { - releaseQueue: true, - }, + releaseConcurrency: true, }); return json({ diff --git a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts index b20d0fd22d..c0e19c1f48 100644 --- a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts +++ b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts @@ -40,6 +40,7 @@ const { action } = createActionApiRoute( environmentId: authentication.environment.id, projectId: authentication.environment.project.id, organizationId: authentication.environment.organization.id, + releaseConcurrency: true, }); return json( diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 6aca681f07..4bab97fa08 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -63,6 +63,7 @@ import { RunLocker } from "./locking.js"; import { getMachinePreset } from "./machinePresets.js"; import { retryOutcomeFromCompletion } from "./retrying.js"; import { + canReleaseConcurrency, isCheckpointable, isDequeueableExecutionStatus, isExecuting, @@ -70,6 +71,7 @@ import { isPendingExecuting, } from "./statuses.js"; import { HeartbeatTimeouts, RunEngineOptions, TriggerParams } from "./types.js"; +import { ReleaseConcurrencyQueue } from "./releaseConcurrencyQueue.js"; const workerCatalog = { finishWaitpoint: { @@ -137,6 +139,7 @@ export class RunEngine { private logger = new Logger("RunEngine", "debug"); private tracer: Tracer; private heartbeatTimeouts: HeartbeatTimeouts; + private releaseConcurrencyQueue: ReleaseConcurrencyQueue; eventBus = new EventEmitter(); constructor(private readonly options: RunEngineOptions) { @@ -239,6 +242,20 @@ export class RunEngine { ...defaultHeartbeatTimeouts, ...(options.heartbeatTimeoutsMs ?? {}), }; + + // Initialize the ReleaseConcurrencyQueue + this.releaseConcurrencyQueue = new ReleaseConcurrencyQueue({ + redis: { + ...options.queue.redis, // Use base queue redis options + ...options.releaseConcurrency?.redis, // Allow overrides + keyPrefix: `${options.queue.redis.keyPrefix}release-concurrency:`, + }, + maxTokens: options.releaseConcurrency?.maxTokens ?? 10, // Default to 10 tokens + executor: async (releaseQueue, runId) => { + await this.#executeReleasedConcurrencyFromQueue(releaseQueue, runId); + }, + tracer: this.tracer, + }); } //MARK: - Run functions @@ -1994,9 +2011,7 @@ export class RunEngine { environmentId: string; projectId: string; organizationId: string; - releaseConcurrency?: { - releaseQueue: boolean; - }; + releaseConcurrency?: boolean; timeout?: Date; spanIdToComplete?: string; batch?: { id: string; index?: number }; @@ -2096,11 +2111,7 @@ export class RunEngine { } else { if (releaseConcurrency) { //release concurrency - await this.runQueue.releaseConcurrency( - organizationId, - runId, - releaseConcurrency.releaseQueue === true - ); + await this.#attemptToReleaseConcurrency(organizationId, snapshot); } } @@ -2108,12 +2119,76 @@ export class RunEngine { }); } - // Add releaseConcurrencyIfSuspendedOrGoingToBeSuspended - // - Called from blockRunWithWaitpoint when releaseConcurrency exists - // - 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 + async #attemptToReleaseConcurrency(orgId: string, snapshot: TaskRunExecutionSnapshot) { + // Go ahead and release concurrency immediately if the run is in a development environment + if (snapshot.environmentType === "DEVELOPMENT") { + return await this.runQueue.releaseConcurrency(orgId, snapshot.runId); + } + + const run = await this.prisma.taskRun.findFirst({ + where: { + id: snapshot.runId, + }, + select: { + runtimeEnvironment: { + select: { + id: true, + projectId: true, + organizationId: true, + }, + }, + }, + }); + + if (!run) { + this.logger.error("Run not found for attemptToReleaseConcurrency", { + runId: snapshot.runId, + }); + + return; + } + + await this.releaseConcurrencyQueue.attemptToRelease( + this.runQueue.keys.releaseConcurrencyKey({ + orgId: run.runtimeEnvironment.organizationId, + projectId: run.runtimeEnvironment.projectId, + envId: run.runtimeEnvironment.id, + }), + snapshot.runId + ); + + return; + } + + async #executeReleasedConcurrencyFromQueue(releaseQueue: string, runId: string) { + const releaseQueueDescriptor = + this.runQueue.keys.releaseConcurrencyDescriptorFromQueue(releaseQueue); + + this.logger.debug("Executing released concurrency", { + releaseQueue, + runId, + releaseQueueDescriptor, + }); + + // - 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([runId], 5_000, async (signal) => { + const snapshot = await getLatestExecutionSnapshot(this.prisma, runId); + + if (!canReleaseConcurrency(snapshot.executionStatus)) { + this.logger.debug("Run is not in a state to release concurrency", { + runId, + snapshot, + }); + + return; + } + + return await this.runQueue.releaseConcurrency(releaseQueueDescriptor.orgId, snapshot.runId); + }); + } /** This completes a waitpoint and updates all entries so the run isn't blocked, * if they're no longer blocked. This doesn't suffer from race conditions. */ @@ -2300,6 +2375,7 @@ export class RunEngine { select: { id: true, projectId: true, + organizationId: true, }, }, }, @@ -2340,6 +2416,16 @@ export class RunEngine { runnerId, }); + // Refill the token bucket for the release concurrency queue + await this.releaseConcurrencyQueue.refillTokens( + this.runQueue.keys.releaseConcurrencyKey({ + orgId: run.runtimeEnvironment.organizationId, + projectId: run.runtimeEnvironment.projectId, + envId: run.runtimeEnvironment.id, + }), + 1 + ); + return { ok: true as const, ...executionResultFromSnapshot(newSnapshot), diff --git a/internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts b/internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts new file mode 100644 index 0000000000..f36d6f5f3c --- /dev/null +++ b/internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts @@ -0,0 +1,533 @@ +import { Callback, createRedisClient, Redis, Result, type RedisOptions } from "@internal/redis"; +import { Tracer } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { setTimeout } from "node:timers/promises"; +import { z } from "zod"; + +export type ReleaseConcurrencyQueueRetryOptions = { + maxRetries?: number; + backoff?: { + minDelay?: number; // Defaults to 1000 + maxDelay?: number; // Defaults to 60000 + factor?: number; // Defaults to 2 + }; +}; + +export type ReleaseConcurrencyQueueOptions = { + redis: RedisOptions; + executor: (releaseQueue: T, runId: string) => Promise; + keys: { + fromDescriptor: (releaseQueue: T) => string; + toDescriptor: (releaseQueue: string) => T; + }; + consumersCount?: number; + masterQueuesKey?: string; + tracer?: Tracer; + logger?: Logger; + pollInterval?: number; + batchSize?: number; + retry?: ReleaseConcurrencyQueueRetryOptions; +}; + +const QueueItemMetadata = z.object({ + retryCount: z.number(), + lastAttempt: z.number(), +}); + +type QueueItemMetadata = z.infer; + +export class ReleaseConcurrencyQueue { + private redis: Redis; + private logger: Logger; + + private keyPrefix: string; + private masterQueuesKey: string; + private consumersCount: number; + private pollInterval: number; + private keys: ReleaseConcurrencyQueueOptions["keys"]; + private consumersEnabled: boolean; + private batchSize: number; + private maxRetries: number; + private backoff: NonNullable>; + + constructor(private readonly options: ReleaseConcurrencyQueueOptions) { + this.redis = createRedisClient(options.redis); + this.keyPrefix = options.redis.keyPrefix ?? "re2:release-concurrency-queue:"; + this.logger = options.logger ?? new Logger("ReleaseConcurrencyQueue"); + + this.masterQueuesKey = options.masterQueuesKey ?? "master-queue"; + this.consumersCount = options.consumersCount ?? 1; + this.pollInterval = options.pollInterval ?? 1000; + this.keys = options.keys; + this.batchSize = options.batchSize ?? 5; + this.maxRetries = options.retry?.maxRetries ?? 3; + this.backoff = { + minDelay: options.retry?.backoff?.minDelay ?? 1000, + maxDelay: options.retry?.backoff?.maxDelay ?? 60000, + factor: options.retry?.backoff?.factor ?? 2, + }; + + this.consumersEnabled = true; + + this.#registerCommands(); + this.#startConsumers(); + } + + public async quit() { + this.consumersEnabled = false; + await this.redis.quit(); + } + + /** + * Attempt to release concurrency for a run. + * + * If there is a token available, then immediately release the concurrency + * If there is no token available, then we'll add the operation to a queue + * and wait until the token is available. + */ + public async attemptToRelease(releaseQueueDescriptor: T, runId: string, maxTokens: number) { + const releaseQueue = this.keys.fromDescriptor(releaseQueueDescriptor); + + const result = await this.redis.consumeToken( + this.masterQueuesKey, + this.#bucketKey(releaseQueue), + this.#queueKey(releaseQueue), + this.#metadataKey(releaseQueue), + releaseQueue, + runId, + String(maxTokens), + String(Date.now()) + ); + + if (!!result) { + await this.#callExecutor(releaseQueueDescriptor, runId, { + retryCount: 0, + lastAttempt: Date.now(), + }); + } + } + + /** + * Refill the token bucket for a release queue. + * + * This will add the amount of tokens to the token bucket. + */ + public async refillTokens(releaseQueueDescriptor: T, maxTokens: number, amount: number = 1) { + const releaseQueue = this.keys.fromDescriptor(releaseQueueDescriptor); + + if (amount < 0) { + throw new Error("Cannot refill with negative tokens"); + } + + if (amount === 0) { + return []; + } + + await this.redis.refillTokens( + this.masterQueuesKey, + this.#bucketKey(releaseQueue), + this.#queueKey(releaseQueue), + releaseQueue, + String(amount), + String(maxTokens) + ); + } + + /** + * Get the next queue that has available capacity and process one item from it + * Returns true if an item was processed, false if no items were available + */ + public async processNextAvailableQueue(): Promise { + const result = await this.redis.processMasterQueue( + this.masterQueuesKey, + this.keyPrefix, + this.batchSize, + String(Date.now()) + ); + + if (!result || result.length === 0) { + return false; + } + + await Promise.all( + result.map(([queue, runId, metadata]) => { + const itemMetadata = QueueItemMetadata.parse(JSON.parse(metadata)); + const releaseQueueDescriptor = this.keys.toDescriptor(queue); + return this.#callExecutor(releaseQueueDescriptor, runId, itemMetadata); + }) + ); + + return true; + } + + async #callExecutor(releaseQueueDescriptor: T, runId: string, metadata: QueueItemMetadata) { + try { + this.logger.info("Executing run:", { releaseQueueDescriptor, runId }); + + await this.options.executor(releaseQueueDescriptor, runId); + } catch (error) { + this.logger.error("Error executing run:", { error }); + + if (metadata.retryCount >= this.maxRetries) { + this.logger.error("Max retries reached:", { + releaseQueueDescriptor, + runId, + retryCount: metadata.retryCount, + }); + + // 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, + runId + ); + + this.logger.info("Returned token:", { releaseQueueDescriptor, runId }); + + return; + } + + const updatedMetadata: QueueItemMetadata = { + ...metadata, + retryCount: metadata.retryCount + 1, + lastAttempt: Date.now(), + }; + + const releaseQueue = this.keys.fromDescriptor(releaseQueueDescriptor); + + await this.redis.returnTokenAndRequeue( + this.masterQueuesKey, + this.#bucketKey(releaseQueue), + this.#queueKey(releaseQueue), + this.#metadataKey(releaseQueue), + releaseQueue, + runId, + JSON.stringify(updatedMetadata), + this.#calculateBackoffScore(updatedMetadata) + ); + } + } + + #bucketKey(releaseQueue: string) { + return `${releaseQueue}:bucket`; + } + + #queueKey(releaseQueue: string) { + return `${releaseQueue}:queue`; + } + + #metadataKey(releaseQueue: string) { + return `${releaseQueue}:metadata`; + } + + #startConsumers() { + for (let i = 0; i < this.consumersCount; i++) { + this.#startConsumer(); + } + } + + async #startConsumer() { + while (this.consumersEnabled) { + try { + const processed = await this.processNextAvailableQueue(); + if (!processed) { + // No items available, wait before trying again + await setTimeout(this.pollInterval); + } + } catch (error) { + // Handle error, maybe wait before retrying + this.logger.error("Error processing queue:", { error }); + await setTimeout(this.pollInterval); + } + } + } + + #calculateBackoffScore(item: QueueItemMetadata): string { + const delay = Math.min( + this.backoff.maxDelay, + this.backoff.minDelay * Math.pow(this.backoff.factor, item.retryCount) + ); + return String(Date.now() + delay); + } + + #registerCommands() { + this.redis.defineCommand("consumeToken", { + numberOfKeys: 4, + lua: ` +local masterQueuesKey = KEYS[1] +local bucketKey = KEYS[2] +local queueKey = KEYS[3] +local metadataKey = KEYS[4] + +local releaseQueue = ARGV[1] +local runId = ARGV[2] +local maxTokens = tonumber(ARGV[3]) +local score = ARGV[4] + +-- Get the current token count +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) + redis.call("ZREM", queueKey, runId) + + -- Clean up metadata when successfully consuming + redis.call("HDEL", metadataKey, runId) + + -- Get queue length after removing the item + 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) + else + redis.call("ZREM", masterQueuesKey, releaseQueue) + end + + return true +end + +-- If we don't have enough tokens, then we need to add the operation to the queue +redis.call("ZADD", queueKey, score, runId) + +-- Initialize or update metadata +local metadata = cjson.encode({ + retryCount = 0, + lastAttempt = tonumber(score) +}) +redis.call("HSET", metadataKey, runId, metadata) + +-- Remove from the master queue +redis.call("ZREM", masterQueuesKey, releaseQueue) + +return false + `, + }); + + this.redis.defineCommand("refillTokens", { + numberOfKeys: 3, + lua: ` +local masterQueuesKey = KEYS[1] +local bucketKey = KEYS[2] +local queueKey = KEYS[3] + +local releaseQueue = ARGV[1] +local amount = tonumber(ARGV[2]) +local maxTokens = tonumber(ARGV[3]) + +local currentTokens = tonumber(redis.call("GET", bucketKey) or maxTokens) + +-- Add the amount of tokens to the token bucket +local newTokens = currentTokens + amount + +-- If we have more tokens than the max, then set the token bucket to the max +if newTokens > maxTokens then + newTokens = maxTokens +end + +redis.call("SET", bucketKey, newTokens) + +-- Get the number of items in the queue +local queueLength = redis.call("ZCARD", queueKey) + +-- If we have tokens available and items in the queue, add to available queues +if newTokens > 0 and queueLength > 0 then + redis.call("ZADD", masterQueuesKey, newTokens, releaseQueue) +else + redis.call("ZREM", masterQueuesKey, releaseQueue) +end + `, + }); + + this.redis.defineCommand("processMasterQueue", { + numberOfKeys: 1, + lua: ` +local masterQueuesKey = KEYS[1] + +local keyPrefix = ARGV[1] +local batchSize = tonumber(ARGV[2]) +local currentTime = tonumber(ARGV[3]) +-- Get the queue with the highest number of available tokens +local queues = redis.call("ZREVRANGE", masterQueuesKey, 0, 0, "WITHSCORES") +if #queues == 0 then + return nil +end + +local queueName = queues[1] +local availableTokens = tonumber(queues[2]) + +local bucketKey = keyPrefix .. queueName .. ":bucket" +local queueKey = keyPrefix .. queueName .. ":queue" +local metadataKey = keyPrefix .. queueName .. ":metadata" + +-- Get the oldest item from the queue +local items = redis.call("ZRANGEBYSCORE", queueKey, 0, currentTime, "LIMIT", 0, batchSize - 1) +if #items == 0 then +-- No items ready to be processed yet + return nil +end + +-- Calculate how many items we can actually process +local itemsToProcess = math.min(#items, availableTokens) +local results = {} + +-- Consume tokens and collect results +local currentTokens = tonumber(redis.call("GET", bucketKey)) +redis.call("SET", bucketKey, currentTokens - itemsToProcess) + +-- Remove the items from the queue and add to results +for i = 1, itemsToProcess do + local runId = items[i] + redis.call("ZREM", queueKey, runId) + + -- Get metadata before removing it + local metadata = redis.call("HGET", metadataKey, runId) + redis.call("HDEL", metadataKey, runId) + + table.insert(results, { queueName, runId, metadata }) +end + +-- Get remaining queue length +local queueLength = redis.call("ZCARD", queueKey) + +-- Update available queues score or remove if no more tokens +local remainingTokens = currentTokens - itemsToProcess +if remainingTokens > 0 and queueLength > 0 then + redis.call("ZADD", masterQueuesKey, remainingTokens, queueName) +else + redis.call("ZREM", masterQueuesKey, queueName) +end + +return results + `, + }); + + this.redis.defineCommand("returnTokenAndRequeue", { + numberOfKeys: 4, + lua: ` +local masterQueuesKey = KEYS[1] +local bucketKey = KEYS[2] +local queueKey = KEYS[3] +local metadataKey = KEYS[4] + +local releaseQueue = ARGV[1] +local runId = ARGV[2] +local metadata = ARGV[3] +local score = ARGV[4] + +-- Return the token to the bucket +local currentTokens = tonumber(redis.call("GET", bucketKey)) +local remainingTokens = currentTokens + 1 +redis.call("SET", bucketKey, remainingTokens) + +-- Add the item back to the queue +redis.call("ZADD", queueKey, score, runId) + +-- Add the metadata back to the item +redis.call("HSET", metadataKey, runId, metadata) + +-- Update the master queue +local queueLength = redis.call("ZCARD", queueKey) +if queueLength > 0 then + redis.call("ZADD", masterQueuesKey, remainingTokens, releaseQueue) +else + redis.call("ZREM", masterQueuesKey, releaseQueue) +end + +return true + `, + }); + + this.redis.defineCommand("returnTokenOnly", { + numberOfKeys: 4, + lua: ` +local masterQueuesKey = KEYS[1] +local bucketKey = KEYS[2] +local queueKey = KEYS[3] +local metadataKey = KEYS[4] + +local releaseQueue = ARGV[1] +local runId = ARGV[2] + +-- Return the token to the bucket +local currentTokens = tonumber(redis.call("GET", bucketKey)) +local remainingTokens = currentTokens + 1 +redis.call("SET", bucketKey, remainingTokens) + +-- Clean up metadata +redis.call("HDEL", metadataKey, runId) + +-- Update the master queue based on remaining queue length +local queueLength = redis.call("ZCARD", queueKey) +if queueLength > 0 then + redis.call("ZADD", masterQueuesKey, remainingTokens, releaseQueue) +else + redis.call("ZREM", masterQueuesKey, releaseQueue) +end + +return true + `, + }); + } +} + +declare module "@internal/redis" { + interface RedisCommander { + consumeToken( + masterQueuesKey: string, + bucketKey: string, + queueKey: string, + metadataKey: string, + releaseQueue: string, + runId: string, + maxTokens: string, + score: string, + callback?: Callback + ): Result; + + refillTokens( + masterQueuesKey: string, + bucketKey: string, + queueKey: string, + releaseQueue: string, + amount: string, + maxTokens: string, + callback?: Callback + ): Result; + + processMasterQueue( + masterQueuesKey: string, + keyPrefix: string, + batchSize: number, + currentTime: string, + callback?: Callback<[string, string, string][]> + ): Result<[string, string, string][], Context>; + + returnTokenAndRequeue( + masterQueuesKey: string, + bucketKey: string, + queueKey: string, + metadataKey: string, + releaseQueue: string, + runId: string, + metadata: string, + score: string, + callback?: Callback + ): Result; + + returnTokenOnly( + masterQueuesKey: string, + bucketKey: string, + queueKey: string, + metadataKey: string, + releaseQueue: string, + runId: string, + callback?: Callback + ): Result; + } +} diff --git a/internal-packages/run-engine/src/engine/statuses.ts b/internal-packages/run-engine/src/engine/statuses.ts index 27ba540be1..71212b6e75 100644 --- a/internal-packages/run-engine/src/engine/statuses.ts +++ b/internal-packages/run-engine/src/engine/statuses.ts @@ -44,3 +44,8 @@ export function isFinalRunStatus(status: TaskRunStatus): boolean { return finalStatuses.includes(status); } + +export function canReleaseConcurrency(status: TaskRunExecutionStatus): boolean { + const releaseableStatuses: TaskRunExecutionStatus[] = ["SUSPENDED", "EXECUTING_WITH_WAITPOINTS"]; + return releaseableStatuses.includes(status); +} diff --git a/internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts b/internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts new file mode 100644 index 0000000000..d7325db4f6 --- /dev/null +++ b/internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts @@ -0,0 +1,544 @@ +import { redisTest, StartedRedisContainer } from "@internal/testcontainers"; +import { ReleaseConcurrencyQueue } from "../releaseConcurrencyQueue.js"; +import { setTimeout } from "node:timers/promises"; + +type TestQueueDescriptor = { + name: string; +}; + +function createReleaseConcurrencyQueue(redisContainer: StartedRedisContainer) { + const executedRuns: { releaseQueue: string; runId: string }[] = []; + + const queue = new ReleaseConcurrencyQueue({ + redis: { + keyPrefix: "release-queue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + executor: async (releaseQueue, runId) => { + executedRuns.push({ releaseQueue: releaseQueue.name, runId }); + }, + keys: { + fromDescriptor: (descriptor) => descriptor.name, + toDescriptor: (name) => ({ name }), + }, + pollInterval: 100, + }); + + return { + queue, + executedRuns, + }; +} + +describe("ReleaseConcurrencyQueue", () => { + redisTest("Should manage token bucket and queue correctly", async ({ redisContainer }) => { + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + + try { + // First two attempts should execute immediately (we have 2 tokens) + await queue.attemptToRelease({ name: "test-queue" }, "run1", 2); + await queue.attemptToRelease({ name: "test-queue" }, "run2", 2); + + // Verify first two runs were executed + expect(executedRuns).toHaveLength(2); + expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run1" }); + expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run2" }); + + // Third attempt should be queued (no tokens left) + await queue.attemptToRelease({ name: "test-queue" }, "run3", 2); + expect(executedRuns).toHaveLength(2); // Still 2, run3 is queued + + // Refill one token, should execute run3 + await queue.refillTokens({ name: "test-queue" }, 2, 1); + + // Now we need to wait for the queue to be processed + await setTimeout(1000); + + expect(executedRuns).toHaveLength(3); + expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run3" }); + } finally { + await queue.quit(); + } + }); + + redisTest("Should handle multiple refills correctly", async ({ redisContainer }) => { + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + + try { + // Queue up 5 runs (more than maxTokens) + await queue.attemptToRelease({ name: "test-queue" }, "run1", 3); + await queue.attemptToRelease({ name: "test-queue" }, "run2", 3); + await queue.attemptToRelease({ name: "test-queue" }, "run3", 3); + await queue.attemptToRelease({ name: "test-queue" }, "run4", 3); + await queue.attemptToRelease({ name: "test-queue" }, "run5", 3); + + // First 3 should be executed immediately (maxTokens = 3) + expect(executedRuns).toHaveLength(3); + expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run1" }); + expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run2" }); + expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run3" }); + + // Refill 2 tokens + await queue.refillTokens({ name: "test-queue" }, 3, 2); + + await setTimeout(1000); + + // Should execute the remaining 2 runs + expect(executedRuns).toHaveLength(5); + expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run4" }); + expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run5" }); + } finally { + await queue.quit(); + } + }); + + redisTest("Should handle multiple queues independently", async ({ redisContainer }) => { + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + + try { + // Add runs to different queues + await queue.attemptToRelease({ name: "queue1" }, "run1", 1); + await queue.attemptToRelease({ name: "queue1" }, "run2", 1); + await queue.attemptToRelease({ name: "queue2" }, "run3", 1); + await queue.attemptToRelease({ name: "queue2" }, "run4", 1); + + // Only first run from each queue should be executed + expect(executedRuns).toHaveLength(2); + expect(executedRuns).toContainEqual({ releaseQueue: "queue1", runId: "run1" }); + expect(executedRuns).toContainEqual({ releaseQueue: "queue2", runId: "run3" }); + + // Refill tokens for queue1 + await queue.refillTokens({ name: "queue1" }, 1, 1); + + await setTimeout(1000); + + // Should only execute the queued run from queue1 + expect(executedRuns).toHaveLength(3); + expect(executedRuns).toContainEqual({ releaseQueue: "queue1", runId: "run2" }); + + // Refill tokens for queue2 + await queue.refillTokens({ name: "queue2" }, 1, 1); + + await setTimeout(1000); + + // Should execute the queued run from queue2 + expect(executedRuns).toHaveLength(4); + expect(executedRuns).toContainEqual({ releaseQueue: "queue2", runId: "run4" }); + } finally { + await queue.quit(); + } + }); + + redisTest("Should not allow refilling more than maxTokens", async ({ redisContainer }) => { + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + + try { + // Add two runs + await queue.attemptToRelease({ name: "test-queue" }, "run1", 1); + await queue.attemptToRelease({ name: "test-queue" }, "run2", 1); + + // First run should be executed immediately + expect(executedRuns).toHaveLength(1); + expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run1" }); + + // Refill with more tokens than needed + await queue.refillTokens({ name: "test-queue" }, 1, 5); + + await setTimeout(1000); + + // Should only execute the one remaining run + expect(executedRuns).toHaveLength(2); + expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run2" }); + + // Add another run - should NOT execute immediately because we don't have excess tokens + await queue.attemptToRelease({ name: "test-queue" }, "run3", 1); + expect(executedRuns).toHaveLength(2); + } finally { + await queue.quit(); + } + }); + + redisTest("Should maintain FIFO order when releasing", async ({ redisContainer }) => { + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + + try { + // Queue up multiple runs + await queue.attemptToRelease({ name: "test-queue" }, "run1", 1); + await queue.attemptToRelease({ name: "test-queue" }, "run2", 1); + await queue.attemptToRelease({ name: "test-queue" }, "run3", 1); + await queue.attemptToRelease({ name: "test-queue" }, "run4", 1); + + // First run should be executed immediately + expect(executedRuns).toHaveLength(1); + expect(executedRuns[0]).toEqual({ releaseQueue: "test-queue", runId: "run1" }); + + // Refill tokens one at a time and verify order + await queue.refillTokens({ name: "test-queue" }, 1, 1); + + await setTimeout(1000); + + expect(executedRuns).toHaveLength(2); + expect(executedRuns[1]).toEqual({ releaseQueue: "test-queue", runId: "run2" }); + + await queue.refillTokens({ name: "test-queue" }, 1, 1); + + await setTimeout(1000); + + expect(executedRuns).toHaveLength(3); + expect(executedRuns[2]).toEqual({ releaseQueue: "test-queue", runId: "run3" }); + + await queue.refillTokens({ name: "test-queue" }, 1, 1); + + await setTimeout(1000); + + expect(executedRuns).toHaveLength(4); + expect(executedRuns[3]).toEqual({ releaseQueue: "test-queue", runId: "run4" }); + } finally { + await queue.quit(); + } + }); + + redisTest( + "Should handle executor failures by returning the token and adding the item into the queue", + async ({ redisContainer }) => { + let shouldFail = true; + + const executedRuns: { releaseQueue: string; runId: string }[] = []; + + const queue = new ReleaseConcurrencyQueue({ + redis: { + keyPrefix: "release-queue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + executor: async (releaseQueue, runId) => { + if (shouldFail) { + throw new Error("Executor failed"); + } + executedRuns.push({ releaseQueue, runId }); + }, + keys: { + fromDescriptor: (descriptor) => descriptor, + toDescriptor: (name) => name, + }, + batchSize: 2, + }); + + try { + // Attempt to release with failing executor + await queue.attemptToRelease("test-queue", "run1", 2); + // Does not execute because the executor throws an error + expect(executedRuns).toHaveLength(0); + + // Token should have been returned to the bucket so this should try to execute immediately and fail again + await queue.attemptToRelease("test-queue", "run2", 2); + expect(executedRuns).toHaveLength(0); + + // Allow executor to succeed + shouldFail = false; + + await setTimeout(1000); + + // Should now execute successfully + expect(executedRuns).toHaveLength(2); + expect(executedRuns[0]).toEqual({ releaseQueue: "test-queue", runId: "run1" }); + expect(executedRuns[1]).toEqual({ releaseQueue: "test-queue", runId: "run2" }); + } finally { + await queue.quit(); + } + } + ); + + redisTest("Should handle invalid token amounts", async ({ redisContainer }) => { + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + + try { + // Try to refill with negative tokens + await expect(queue.refillTokens({ name: "test-queue" }, 1, -1)).rejects.toThrow(); + + // Try to refill with zero tokens + await queue.refillTokens({ name: "test-queue" }, 1, 0); + + await setTimeout(1000); + + expect(executedRuns).toHaveLength(0); + + // Verify normal operation still works + await queue.attemptToRelease({ name: "test-queue" }, "run1", 1); + expect(executedRuns).toHaveLength(1); + } finally { + await queue.quit(); + } + }); + + redisTest("Should handle concurrent operations correctly", async ({ redisContainer }) => { + const executedRuns: { releaseQueue: string; runId: string }[] = []; + + const queue = new ReleaseConcurrencyQueue({ + redis: { + keyPrefix: "release-queue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + executor: async (releaseQueue, runId) => { + // Add small delay to simulate work + await setTimeout(10); + executedRuns.push({ releaseQueue, runId }); + }, + keys: { + fromDescriptor: (descriptor) => descriptor, + toDescriptor: (name) => name, + }, + batchSize: 5, + }); + + try { + // Attempt multiple concurrent releases + await Promise.all([ + queue.attemptToRelease("test-queue", "run1", 2), + queue.attemptToRelease("test-queue", "run2", 2), + queue.attemptToRelease("test-queue", "run3", 2), + queue.attemptToRelease("test-queue", "run4", 2), + ]); + + // Should only execute maxTokens (2) runs + expect(executedRuns).toHaveLength(2); + + // Attempt concurrent refills + queue.refillTokens("test-queue", 2, 2); + + await setTimeout(1000); + + // Should execute remaining runs + expect(executedRuns).toHaveLength(4); + + // Verify all runs were executed exactly once + const runCounts = executedRuns.reduce( + (acc, { runId }) => { + acc[runId] = (acc[runId] || 0) + 1; + return acc; + }, + {} as Record + ); + + Object.values(runCounts).forEach((count) => { + expect(count).toBe(1); + }); + } finally { + await queue.quit(); + } + }); + + redisTest("Should clean up Redis resources on quit", async ({ redisContainer }) => { + const { queue } = createReleaseConcurrencyQueue(redisContainer); + + // Add some data + await queue.attemptToRelease({ name: "test-queue" }, "run1", 1); + await queue.attemptToRelease({ name: "test-queue" }, "run2", 1); + + // Quit the queue + await queue.quit(); + + // Verify we can't perform operations after quit + await expect(queue.attemptToRelease({ name: "test-queue" }, "run3", 1)).rejects.toThrow(); + await expect(queue.refillTokens({ name: "test-queue" }, 1, 1)).rejects.toThrow(); + }); + + redisTest("Should stop retrying after max retries is reached", async ({ redisContainer }) => { + let failCount = 0; + const executedRuns: { releaseQueue: string; runId: string; attempt: number }[] = []; + + const queue = new ReleaseConcurrencyQueue({ + redis: { + keyPrefix: "release-queue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + executor: async (releaseQueue, runId) => { + failCount++; + executedRuns.push({ releaseQueue, runId, attempt: failCount }); + throw new Error("Executor failed"); + }, + keys: { + fromDescriptor: (descriptor) => descriptor, + toDescriptor: (name) => name, + }, + retry: { + maxRetries: 2, // Set max retries to 2 (will attempt 3 times total: initial + 2 retries) + }, + pollInterval: 100, // Reduce poll interval for faster test + }); + + try { + // Attempt to release - this will fail and retry + await queue.attemptToRelease("test-queue", "run1", 1); + + // Wait for retries to occur + await setTimeout(2000); + + // Should have attempted exactly 3 times (initial + 2 retries) + expect(executedRuns).toHaveLength(3); + expect(executedRuns[0]).toEqual({ releaseQueue: "test-queue", runId: "run1", attempt: 1 }); + expect(executedRuns[1]).toEqual({ releaseQueue: "test-queue", runId: "run1", attempt: 2 }); + expect(executedRuns[2]).toEqual({ releaseQueue: "test-queue", runId: "run1", attempt: 3 }); + + // Verify that no more retries occur + await setTimeout(1000); + expect(executedRuns).toHaveLength(3); // Should still be 3 + + // Attempt a new release to verify the token was returned + let secondRunAttempted = false; + const queue2 = new ReleaseConcurrencyQueue({ + redis: { + keyPrefix: "release-queue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + executor: async (releaseQueue, runId) => { + secondRunAttempted = true; + }, + keys: { + fromDescriptor: (descriptor) => descriptor, + toDescriptor: (name) => name, + }, + }); + + await queue2.attemptToRelease("test-queue", "run2", 1); + expect(secondRunAttempted).toBe(true); // Should execute immediately because token was returned + + await queue2.quit(); + } finally { + await queue.quit(); + } + }); + + redisTest("Should handle max retries in batch processing", async ({ redisContainer }) => { + const executedRuns: { releaseQueue: string; runId: string; attempt: number }[] = []; + const runAttempts: Record = {}; + + const queue = new ReleaseConcurrencyQueue({ + redis: { + keyPrefix: "release-queue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + executor: async (releaseQueue, runId) => { + runAttempts[runId] = (runAttempts[runId] || 0) + 1; + executedRuns.push({ releaseQueue, runId, attempt: runAttempts[runId] }); + throw new Error("Executor failed"); + }, + keys: { + fromDescriptor: (descriptor) => descriptor, + toDescriptor: (name) => name, + }, + retry: { + maxRetries: 2, + }, + batchSize: 3, + pollInterval: 100, + }); + + try { + // Queue up multiple runs + await Promise.all([ + queue.attemptToRelease("test-queue", "run1", 3), + queue.attemptToRelease("test-queue", "run2", 3), + queue.attemptToRelease("test-queue", "run3", 3), + ]); + + // Wait for all retries to complete + await setTimeout(2000); + + // Each run should have been attempted exactly 3 times + expect(Object.values(runAttempts)).toHaveLength(3); // 3 runs + Object.values(runAttempts).forEach((attempts) => { + expect(attempts).toBe(3); // Each run attempted 3 times + }); + + // Verify execution order maintained retry attempts for each run + const run1Attempts = executedRuns.filter((r) => r.runId === "run1"); + const run2Attempts = executedRuns.filter((r) => r.runId === "run2"); + const run3Attempts = executedRuns.filter((r) => r.runId === "run3"); + + expect(run1Attempts).toHaveLength(3); + expect(run2Attempts).toHaveLength(3); + expect(run3Attempts).toHaveLength(3); + + // Verify attempts are numbered correctly for each run + [run1Attempts, run2Attempts, run3Attempts].forEach((attempts) => { + expect(attempts.map((a) => a.attempt)).toEqual([1, 2, 3]); + }); + + // Verify no more retries occur + await setTimeout(1000); + expect(executedRuns).toHaveLength(9); // 3 runs * 3 attempts each + } finally { + await queue.quit(); + } + }); + + redisTest("Should implement exponential backoff between retries", async ({ redisContainer }) => { + const executionTimes: number[] = []; + let startTime: number; + + const minDelay = 100; + const factor = 2; + + const queue = new ReleaseConcurrencyQueue({ + redis: { + keyPrefix: "release-queue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + executor: async (releaseQueue, runId) => { + const now = Date.now(); + executionTimes.push(now); + console.log(`Execution at ${now - startTime}ms from start`); + throw new Error("Executor failed"); + }, + keys: { + fromDescriptor: (descriptor) => descriptor, + toDescriptor: (name) => name, + }, + retry: { + maxRetries: 2, + backoff: { + minDelay, + maxDelay: 1000, + factor, + }, + }, + pollInterval: 50, + }); + + try { + startTime = Date.now(); + await queue.attemptToRelease("test-queue", "run1", 1); + + // Wait for all retries to complete + await setTimeout(1000); + + // Should have 3 execution times (initial + 2 retries) + expect(executionTimes).toHaveLength(3); + + const intervals = executionTimes.slice(1).map((time, i) => time - executionTimes[i]); + console.log("Intervals between retries:", intervals); + + // First retry should be after ~200ms (minDelay + processing overhead) + const expectedFirstDelay = minDelay * 2; // Account for observed overhead + expect(intervals[0]).toBeGreaterThanOrEqual(expectedFirstDelay * 0.8); + expect(intervals[0]).toBeLessThanOrEqual(expectedFirstDelay * 1.5); + + // Second retry should be after ~400ms (first delay * factor) + const expectedSecondDelay = expectedFirstDelay * factor; + expect(intervals[1]).toBeGreaterThanOrEqual(expectedSecondDelay * 0.8); + expect(intervals[1]).toBeLessThanOrEqual(expectedSecondDelay * 1.5); + + // Log expected vs actual delays + console.log("Expected delays:", { first: expectedFirstDelay, second: expectedSecondDelay }); + } finally { + await queue.quit(); + } + }); +}); diff --git a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts index db0eb3ec73..866d41e71e 100644 --- a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts @@ -105,9 +105,7 @@ describe("RunEngine Waitpoints", () => { environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.project.id, organizationId: authenticatedEnvironment.organization.id, - releaseConcurrency: { - releaseQueue: true, - }, + releaseConcurrency: true, }); expect(result.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); expect(result.runStatus).toBe("EXECUTING"); diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index aea71d605b..c5f4fc4788 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -8,11 +8,11 @@ import { FairQueueSelectionStrategyOptions } from "../run-queue/fairQueueSelecti export type RunEngineOptions = { prisma: PrismaClient; - worker: WorkerConcurrencyOptions & { + worker: { redis: RedisOptions; pollIntervalMs?: number; immediatePollIntervalMs?: number; - }; + } & WorkerConcurrencyOptions; machines: { defaultMachine: MachinePresetName; machines: Record; @@ -35,6 +35,10 @@ export type RunEngineOptions = { heartbeatTimeoutsMs?: Partial; queueRunsWaitingForWorkerBatchSize?: number; tracer: Tracer; + releaseConcurrency?: { + maxTokens?: number; + redis?: Partial; + }; }; export type HeartbeatTimeouts = { diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 4d17347532..8ec03dc922 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -589,11 +589,7 @@ export class RunQueue { ); } - public async releaseConcurrency( - orgId: string, - messageId: string, - releaseForRun: boolean = false - ) { + public async releaseConcurrency(orgId: string, messageId: string) { return this.#trace( "releaseConcurrency", async (span) => { @@ -617,7 +613,7 @@ export class RunQueue { return this.redis.releaseConcurrency( this.keys.messageKey(orgId, messageId), message.queue, - releaseForRun ? this.keys.currentConcurrencyKeyFromQueue(message.queue) : "", + this.keys.currentConcurrencyKeyFromQueue(message.queue), this.keys.envCurrentConcurrencyKeyFromQueue(message.queue), this.keys.projectCurrentConcurrencyKeyFromQueue(message.queue), this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.ts b/internal-packages/run-engine/src/run-queue/keyProducer.ts index a24bf8dbdd..033da935b8 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.ts @@ -299,7 +299,35 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { concurrencyKey: parts.at(9), }; } + releaseConcurrencyKey(env: EnvDescriptor): string; + releaseConcurrencyKey(env: MinimalAuthenticatedEnvironment): string; + releaseConcurrencyKey(envOrDescriptor: EnvDescriptor | MinimalAuthenticatedEnvironment): string { + if ("id" in envOrDescriptor) { + return [ + this.orgKeySection(envOrDescriptor.organization.id), + this.projKeySection(envOrDescriptor.project.id), + this.envKeySection(envOrDescriptor.id), + "release-concurrency", + ].join(":"); + } else { + return [ + this.orgKeySection(envOrDescriptor.orgId), + this.projKeySection(envOrDescriptor.projectId), + this.envKeySection(envOrDescriptor.envId), + "release-concurrency", + ].join(":"); + } + } + + releaseConcurrencyDescriptorFromQueue(queue: string): EnvDescriptor { + const parts = queue.split(":"); + return { + orgId: parts[1], + projectId: parts[3], + envId: parts[5], + }; + } private envKeySection(envId: string) { return `${constants.ENV_PART}:${envId}`; } diff --git a/internal-packages/run-engine/src/run-queue/types.ts b/internal-packages/run-engine/src/run-queue/types.ts index 563cececa8..cbf59ccedc 100644 --- a/internal-packages/run-engine/src/run-queue/types.ts +++ b/internal-packages/run-engine/src/run-queue/types.ts @@ -96,6 +96,11 @@ export interface RunQueueKeyProducer { deadLetterQueueKey(env: MinimalAuthenticatedEnvironment): string; deadLetterQueueKey(env: EnvDescriptor): string; deadLetterQueueKeyFromQueue(queue: string): string; + + releaseConcurrencyKey(env: MinimalAuthenticatedEnvironment): string; + releaseConcurrencyKey(env: EnvDescriptor): string; + + releaseConcurrencyDescriptorFromQueue(queue: string): EnvDescriptor; } export type EnvQueues = { From c8b99728b95aedd1a325b5dd1d74b82001e1ac44 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 13 Mar 2025 22:16:54 +0000 Subject: [PATCH 11/38] Get all the release concurrency queue tests passing --- .../tests/releaseConcurrencyQueue.test.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts b/internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts index d7325db4f6..91c7f069e6 100644 --- a/internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts +++ b/internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts @@ -223,6 +223,15 @@ describe("ReleaseConcurrencyQueue", () => { toDescriptor: (name) => name, }, batchSize: 2, + retry: { + maxRetries: 2, + backoff: { + minDelay: 100, + maxDelay: 1000, + factor: 1, + }, + }, + pollInterval: 50, }); try { @@ -291,6 +300,7 @@ describe("ReleaseConcurrencyQueue", () => { toDescriptor: (name) => name, }, batchSize: 5, + pollInterval: 50, }); try { @@ -366,8 +376,13 @@ describe("ReleaseConcurrencyQueue", () => { }, retry: { maxRetries: 2, // Set max retries to 2 (will attempt 3 times total: initial + 2 retries) + backoff: { + minDelay: 100, + maxDelay: 1000, + factor: 1, + }, }, - pollInterval: 100, // Reduce poll interval for faster test + pollInterval: 50, // Reduce poll interval for faster test }); try { @@ -402,6 +417,15 @@ describe("ReleaseConcurrencyQueue", () => { fromDescriptor: (descriptor) => descriptor, toDescriptor: (name) => name, }, + retry: { + maxRetries: 2, + backoff: { + minDelay: 100, + maxDelay: 1000, + factor: 1, + }, + }, + pollInterval: 50, }); await queue2.attemptToRelease("test-queue", "run2", 1); @@ -434,6 +458,11 @@ describe("ReleaseConcurrencyQueue", () => { }, retry: { maxRetries: 2, + backoff: { + minDelay: 100, + maxDelay: 1000, + factor: 1, + }, }, batchSize: 3, pollInterval: 100, From bf41703dfd392e77ec463c231870d5d59fc1344b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 13 Mar 2025 22:27:42 +0000 Subject: [PATCH 12/38] improve the consumer of the concurrency queue --- .../src/engine/releaseConcurrencyQueue.ts | 75 +++++++++++++------ 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts b/internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts index f36d6f5f3c..378ced1c56 100644 --- a/internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts +++ b/internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts @@ -1,7 +1,7 @@ import { Callback, createRedisClient, Redis, Result, type RedisOptions } from "@internal/redis"; import { Tracer } from "@internal/tracing"; import { Logger } from "@trigger.dev/core/logger"; -import { setTimeout } from "node:timers/promises"; +import { setInterval } from "node:timers/promises"; import { z } from "zod"; export type ReleaseConcurrencyQueueRetryOptions = { @@ -39,13 +39,14 @@ type QueueItemMetadata = z.infer; export class ReleaseConcurrencyQueue { private redis: Redis; private logger: Logger; + private abortController: AbortController; + private consumers: ReleaseConcurrencyQueueConsumer[]; private keyPrefix: string; private masterQueuesKey: string; private consumersCount: number; private pollInterval: number; private keys: ReleaseConcurrencyQueueOptions["keys"]; - private consumersEnabled: boolean; private batchSize: number; private maxRetries: number; private backoff: NonNullable>; @@ -54,6 +55,8 @@ export class ReleaseConcurrencyQueue { this.redis = createRedisClient(options.redis); this.keyPrefix = options.redis.keyPrefix ?? "re2:release-concurrency-queue:"; this.logger = options.logger ?? new Logger("ReleaseConcurrencyQueue"); + this.abortController = new AbortController(); + this.consumers = []; this.masterQueuesKey = options.masterQueuesKey ?? "master-queue"; this.consumersCount = options.consumersCount ?? 1; @@ -67,14 +70,12 @@ export class ReleaseConcurrencyQueue { factor: options.retry?.backoff?.factor ?? 2, }; - this.consumersEnabled = true; - this.#registerCommands(); this.#startConsumers(); } public async quit() { - this.consumersEnabled = false; + this.abortController.abort(); await this.redis.quit(); } @@ -225,24 +226,20 @@ export class ReleaseConcurrencyQueue { } #startConsumers() { - for (let i = 0; i < this.consumersCount; i++) { - this.#startConsumer(); - } - } - - async #startConsumer() { - while (this.consumersEnabled) { - try { - const processed = await this.processNextAvailableQueue(); - if (!processed) { - // No items available, wait before trying again - await setTimeout(this.pollInterval); - } - } catch (error) { - // Handle error, maybe wait before retrying - this.logger.error("Error processing queue:", { error }); - await setTimeout(this.pollInterval); - } + const consumerCount = this.consumersCount; + + for (let i = 0; i < consumerCount; i++) { + const consumer = new ReleaseConcurrencyQueueConsumer( + this, + this.pollInterval, + this.abortController.signal, + this.logger + ); + this.consumers.push(consumer); + // Start the consumer and don't await it + consumer.start().catch((error) => { + this.logger.error("Consumer failed to start:", { error, consumerId: i }); + }); } } @@ -531,3 +528,35 @@ declare module "@internal/redis" { ): Result; } } + +class ReleaseConcurrencyQueueConsumer { + private logger: Logger; + + constructor( + private readonly queue: ReleaseConcurrencyQueue, + private readonly pollInterval: number, + private readonly signal: AbortSignal, + logger?: Logger + ) { + this.logger = logger ?? new Logger("QueueConsumer"); + } + + async start() { + try { + for await (const _ of setInterval(this.pollInterval, null, { signal: this.signal })) { + try { + const processed = await this.queue.processNextAvailableQueue(); + if (!processed) { + continue; + } + } catch (error) { + this.logger.error("Error processing queue:", { error }); + } + } + } catch (error) { + if (error instanceof Error && error.name !== "AbortError") { + this.logger.error("Consumer loop error:", { error }); + } + } + } +} From b7ddf20d62888eddff6189aaea3455ba9cd4451d Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 13 Mar 2025 22:45:58 +0000 Subject: [PATCH 13/38] Correctly use the new release concurrency queue in the run engine --- .../run-engine/src/engine/index.ts | 65 ++++++--- ... => releaseConcurrencyTokenBucketQueue.ts} | 13 +- ...eleaseConcurrencyTokenBucketQueue.test.ts} | 136 ++++++++++-------- .../run-engine/src/engine/types.ts | 2 +- .../run-engine/src/run-queue/keyProducer.ts | 28 ---- .../run-engine/src/run-queue/types.ts | 5 - 6 files changed, 131 insertions(+), 118 deletions(-) rename internal-packages/run-engine/src/engine/{releaseConcurrencyQueue.ts => releaseConcurrencyTokenBucketQueue.ts} (96%) rename internal-packages/run-engine/src/engine/tests/{releaseConcurrencyQueue.test.ts => releaseConcurrencyTokenBucketQueue.test.ts} (85%) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 4bab97fa08..6b24768a7c 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -71,7 +71,7 @@ import { isPendingExecuting, } from "./statuses.js"; import { HeartbeatTimeouts, RunEngineOptions, TriggerParams } from "./types.js"; -import { ReleaseConcurrencyQueue } from "./releaseConcurrencyQueue.js"; +import { ReleaseConcurrencyTokenBucketQueue } from "./releaseConcurrencyTokenBucketQueue.js"; const workerCatalog = { finishWaitpoint: { @@ -139,7 +139,11 @@ export class RunEngine { private logger = new Logger("RunEngine", "debug"); private tracer: Tracer; private heartbeatTimeouts: HeartbeatTimeouts; - private releaseConcurrencyQueue: ReleaseConcurrencyQueue; + private releaseConcurrencyQueue: ReleaseConcurrencyTokenBucketQueue<{ + orgId: string; + projectId: string; + envId: string; + }>; eventBus = new EventEmitter(); constructor(private readonly options: RunEngineOptions) { @@ -244,15 +248,43 @@ export class RunEngine { }; // Initialize the ReleaseConcurrencyQueue - this.releaseConcurrencyQueue = new ReleaseConcurrencyQueue({ + this.releaseConcurrencyQueue = new ReleaseConcurrencyTokenBucketQueue({ redis: { ...options.queue.redis, // Use base queue redis options ...options.releaseConcurrency?.redis, // Allow overrides keyPrefix: `${options.queue.redis.keyPrefix}release-concurrency:`, }, - maxTokens: options.releaseConcurrency?.maxTokens ?? 10, // Default to 10 tokens - executor: async (releaseQueue, runId) => { - await this.#executeReleasedConcurrencyFromQueue(releaseQueue, runId); + retry: { + maxRetries: 5, // TODO: Make this configurable + backoff: { + minDelay: 1000, // TODO: Make this configurable + maxDelay: 10000, // TODO: Make this configurable + factor: 2, // TODO: Make this configurable + }, + }, + executor: async (descriptor, runId) => { + await this.#executeReleasedConcurrencyFromQueue(descriptor, runId); + }, + 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, }); @@ -2149,25 +2181,24 @@ export class RunEngine { } await this.releaseConcurrencyQueue.attemptToRelease( - this.runQueue.keys.releaseConcurrencyKey({ + { orgId: run.runtimeEnvironment.organizationId, projectId: run.runtimeEnvironment.projectId, envId: run.runtimeEnvironment.id, - }), + }, snapshot.runId ); return; } - async #executeReleasedConcurrencyFromQueue(releaseQueue: string, runId: string) { - const releaseQueueDescriptor = - this.runQueue.keys.releaseConcurrencyDescriptorFromQueue(releaseQueue); - + async #executeReleasedConcurrencyFromQueue( + descriptor: { orgId: string; projectId: string; envId: string }, + runId: string + ) { this.logger.debug("Executing released concurrency", { - releaseQueue, + descriptor, runId, - releaseQueueDescriptor, }); // - Runlock the run @@ -2186,7 +2217,7 @@ export class RunEngine { return; } - return await this.runQueue.releaseConcurrency(releaseQueueDescriptor.orgId, snapshot.runId); + return await this.runQueue.releaseConcurrency(descriptor.orgId, snapshot.runId); }); } @@ -2418,11 +2449,11 @@ export class RunEngine { // Refill the token bucket for the release concurrency queue await this.releaseConcurrencyQueue.refillTokens( - this.runQueue.keys.releaseConcurrencyKey({ + { orgId: run.runtimeEnvironment.organizationId, projectId: run.runtimeEnvironment.projectId, envId: run.runtimeEnvironment.id, - }), + }, 1 ); diff --git a/internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts b/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts similarity index 96% rename from internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts rename to internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts index 378ced1c56..c4cb99e170 100644 --- a/internal-packages/run-engine/src/engine/releaseConcurrencyQueue.ts +++ b/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts @@ -20,6 +20,7 @@ export type ReleaseConcurrencyQueueOptions = { fromDescriptor: (releaseQueue: T) => string; toDescriptor: (releaseQueue: string) => T; }; + maxTokens: (descriptor: T) => Promise; consumersCount?: number; masterQueuesKey?: string; tracer?: Tracer; @@ -36,7 +37,7 @@ const QueueItemMetadata = z.object({ type QueueItemMetadata = z.infer; -export class ReleaseConcurrencyQueue { +export class ReleaseConcurrencyTokenBucketQueue { private redis: Redis; private logger: Logger; private abortController: AbortController; @@ -47,6 +48,7 @@ export class ReleaseConcurrencyQueue { private consumersCount: number; private pollInterval: number; private keys: ReleaseConcurrencyQueueOptions["keys"]; + private maxTokens: ReleaseConcurrencyQueueOptions["maxTokens"]; private batchSize: number; private maxRetries: number; private backoff: NonNullable>; @@ -62,6 +64,7 @@ export class ReleaseConcurrencyQueue { this.consumersCount = options.consumersCount ?? 1; this.pollInterval = options.pollInterval ?? 1000; this.keys = options.keys; + this.maxTokens = options.maxTokens; this.batchSize = options.batchSize ?? 5; this.maxRetries = options.retry?.maxRetries ?? 3; this.backoff = { @@ -86,7 +89,8 @@ export class ReleaseConcurrencyQueue { * If there is no token available, then we'll add the operation to a queue * and wait until the token is available. */ - public async attemptToRelease(releaseQueueDescriptor: T, runId: string, maxTokens: number) { + public async attemptToRelease(releaseQueueDescriptor: T, runId: string) { + const maxTokens = await this.maxTokens(releaseQueueDescriptor); const releaseQueue = this.keys.fromDescriptor(releaseQueueDescriptor); const result = await this.redis.consumeToken( @@ -113,7 +117,8 @@ export class ReleaseConcurrencyQueue { * * This will add the amount of tokens to the token bucket. */ - public async refillTokens(releaseQueueDescriptor: T, maxTokens: number, amount: number = 1) { + public async refillTokens(releaseQueueDescriptor: T, amount: number = 1) { + const maxTokens = await this.maxTokens(releaseQueueDescriptor); const releaseQueue = this.keys.fromDescriptor(releaseQueueDescriptor); if (amount < 0) { @@ -533,7 +538,7 @@ class ReleaseConcurrencyQueueConsumer { private logger: Logger; constructor( - private readonly queue: ReleaseConcurrencyQueue, + private readonly queue: ReleaseConcurrencyTokenBucketQueue, private readonly pollInterval: number, private readonly signal: AbortSignal, logger?: Logger diff --git a/internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts b/internal-packages/run-engine/src/engine/tests/releaseConcurrencyTokenBucketQueue.test.ts similarity index 85% rename from internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts rename to internal-packages/run-engine/src/engine/tests/releaseConcurrencyTokenBucketQueue.test.ts index 91c7f069e6..9cce28ec1d 100644 --- a/internal-packages/run-engine/src/engine/tests/releaseConcurrencyQueue.test.ts +++ b/internal-packages/run-engine/src/engine/tests/releaseConcurrencyTokenBucketQueue.test.ts @@ -1,15 +1,18 @@ import { redisTest, StartedRedisContainer } from "@internal/testcontainers"; -import { ReleaseConcurrencyQueue } from "../releaseConcurrencyQueue.js"; +import { ReleaseConcurrencyTokenBucketQueue } from "../releaseConcurrencyTokenBucketQueue.js"; import { setTimeout } from "node:timers/promises"; type TestQueueDescriptor = { name: string; }; -function createReleaseConcurrencyQueue(redisContainer: StartedRedisContainer) { +function createReleaseConcurrencyQueue( + redisContainer: StartedRedisContainer, + maxTokens: number = 2 +) { const executedRuns: { releaseQueue: string; runId: string }[] = []; - const queue = new ReleaseConcurrencyQueue({ + const queue = new ReleaseConcurrencyTokenBucketQueue({ redis: { keyPrefix: "release-queue:test:", host: redisContainer.getHost(), @@ -18,6 +21,7 @@ function createReleaseConcurrencyQueue(redisContainer: StartedRedisContainer) { executor: async (releaseQueue, runId) => { executedRuns.push({ releaseQueue: releaseQueue.name, runId }); }, + maxTokens: async (_) => maxTokens, keys: { fromDescriptor: (descriptor) => descriptor.name, toDescriptor: (name) => ({ name }), @@ -33,12 +37,12 @@ function createReleaseConcurrencyQueue(redisContainer: StartedRedisContainer) { describe("ReleaseConcurrencyQueue", () => { redisTest("Should manage token bucket and queue correctly", async ({ redisContainer }) => { - const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 2); try { // First two attempts should execute immediately (we have 2 tokens) - await queue.attemptToRelease({ name: "test-queue" }, "run1", 2); - await queue.attemptToRelease({ name: "test-queue" }, "run2", 2); + await queue.attemptToRelease({ name: "test-queue" }, "run1"); + await queue.attemptToRelease({ name: "test-queue" }, "run2"); // Verify first two runs were executed expect(executedRuns).toHaveLength(2); @@ -46,11 +50,11 @@ describe("ReleaseConcurrencyQueue", () => { expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run2" }); // Third attempt should be queued (no tokens left) - await queue.attemptToRelease({ name: "test-queue" }, "run3", 2); + await queue.attemptToRelease({ name: "test-queue" }, "run3"); expect(executedRuns).toHaveLength(2); // Still 2, run3 is queued // Refill one token, should execute run3 - await queue.refillTokens({ name: "test-queue" }, 2, 1); + await queue.refillTokens({ name: "test-queue" }, 1); // Now we need to wait for the queue to be processed await setTimeout(1000); @@ -63,15 +67,15 @@ describe("ReleaseConcurrencyQueue", () => { }); redisTest("Should handle multiple refills correctly", async ({ redisContainer }) => { - const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 3); try { // Queue up 5 runs (more than maxTokens) - await queue.attemptToRelease({ name: "test-queue" }, "run1", 3); - await queue.attemptToRelease({ name: "test-queue" }, "run2", 3); - await queue.attemptToRelease({ name: "test-queue" }, "run3", 3); - await queue.attemptToRelease({ name: "test-queue" }, "run4", 3); - await queue.attemptToRelease({ name: "test-queue" }, "run5", 3); + await queue.attemptToRelease({ name: "test-queue" }, "run1"); + await queue.attemptToRelease({ name: "test-queue" }, "run2"); + await queue.attemptToRelease({ name: "test-queue" }, "run3"); + await queue.attemptToRelease({ name: "test-queue" }, "run4"); + await queue.attemptToRelease({ name: "test-queue" }, "run5"); // First 3 should be executed immediately (maxTokens = 3) expect(executedRuns).toHaveLength(3); @@ -80,7 +84,7 @@ describe("ReleaseConcurrencyQueue", () => { expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run3" }); // Refill 2 tokens - await queue.refillTokens({ name: "test-queue" }, 3, 2); + await queue.refillTokens({ name: "test-queue" }, 2); await setTimeout(1000); @@ -94,14 +98,14 @@ describe("ReleaseConcurrencyQueue", () => { }); redisTest("Should handle multiple queues independently", async ({ redisContainer }) => { - const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 1); try { // Add runs to different queues - await queue.attemptToRelease({ name: "queue1" }, "run1", 1); - await queue.attemptToRelease({ name: "queue1" }, "run2", 1); - await queue.attemptToRelease({ name: "queue2" }, "run3", 1); - await queue.attemptToRelease({ name: "queue2" }, "run4", 1); + await queue.attemptToRelease({ name: "queue1" }, "run1"); + await queue.attemptToRelease({ name: "queue1" }, "run2"); + await queue.attemptToRelease({ name: "queue2" }, "run3"); + await queue.attemptToRelease({ name: "queue2" }, "run4"); // Only first run from each queue should be executed expect(executedRuns).toHaveLength(2); @@ -109,7 +113,7 @@ describe("ReleaseConcurrencyQueue", () => { expect(executedRuns).toContainEqual({ releaseQueue: "queue2", runId: "run3" }); // Refill tokens for queue1 - await queue.refillTokens({ name: "queue1" }, 1, 1); + await queue.refillTokens({ name: "queue1" }, 1); await setTimeout(1000); @@ -118,7 +122,7 @@ describe("ReleaseConcurrencyQueue", () => { expect(executedRuns).toContainEqual({ releaseQueue: "queue1", runId: "run2" }); // Refill tokens for queue2 - await queue.refillTokens({ name: "queue2" }, 1, 1); + await queue.refillTokens({ name: "queue2" }, 1); await setTimeout(1000); @@ -131,19 +135,19 @@ describe("ReleaseConcurrencyQueue", () => { }); redisTest("Should not allow refilling more than maxTokens", async ({ redisContainer }) => { - const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 1); try { // Add two runs - await queue.attemptToRelease({ name: "test-queue" }, "run1", 1); - await queue.attemptToRelease({ name: "test-queue" }, "run2", 1); + await queue.attemptToRelease({ name: "test-queue" }, "run1"); + await queue.attemptToRelease({ name: "test-queue" }, "run2"); // First run should be executed immediately expect(executedRuns).toHaveLength(1); expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run1" }); // Refill with more tokens than needed - await queue.refillTokens({ name: "test-queue" }, 1, 5); + await queue.refillTokens({ name: "test-queue" }, 5); await setTimeout(1000); @@ -152,7 +156,7 @@ describe("ReleaseConcurrencyQueue", () => { expect(executedRuns).toContainEqual({ releaseQueue: "test-queue", runId: "run2" }); // Add another run - should NOT execute immediately because we don't have excess tokens - await queue.attemptToRelease({ name: "test-queue" }, "run3", 1); + await queue.attemptToRelease({ name: "test-queue" }, "run3"); expect(executedRuns).toHaveLength(2); } finally { await queue.quit(); @@ -160,35 +164,35 @@ describe("ReleaseConcurrencyQueue", () => { }); redisTest("Should maintain FIFO order when releasing", async ({ redisContainer }) => { - const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 1); try { // Queue up multiple runs - await queue.attemptToRelease({ name: "test-queue" }, "run1", 1); - await queue.attemptToRelease({ name: "test-queue" }, "run2", 1); - await queue.attemptToRelease({ name: "test-queue" }, "run3", 1); - await queue.attemptToRelease({ name: "test-queue" }, "run4", 1); + await queue.attemptToRelease({ name: "test-queue" }, "run1"); + await queue.attemptToRelease({ name: "test-queue" }, "run2"); + await queue.attemptToRelease({ name: "test-queue" }, "run3"); + await queue.attemptToRelease({ name: "test-queue" }, "run4"); // First run should be executed immediately expect(executedRuns).toHaveLength(1); expect(executedRuns[0]).toEqual({ releaseQueue: "test-queue", runId: "run1" }); // Refill tokens one at a time and verify order - await queue.refillTokens({ name: "test-queue" }, 1, 1); + await queue.refillTokens({ name: "test-queue" }, 1); await setTimeout(1000); expect(executedRuns).toHaveLength(2); expect(executedRuns[1]).toEqual({ releaseQueue: "test-queue", runId: "run2" }); - await queue.refillTokens({ name: "test-queue" }, 1, 1); + await queue.refillTokens({ name: "test-queue" }, 1); await setTimeout(1000); expect(executedRuns).toHaveLength(3); expect(executedRuns[2]).toEqual({ releaseQueue: "test-queue", runId: "run3" }); - await queue.refillTokens({ name: "test-queue" }, 1, 1); + await queue.refillTokens({ name: "test-queue" }, 1); await setTimeout(1000); @@ -206,7 +210,7 @@ describe("ReleaseConcurrencyQueue", () => { const executedRuns: { releaseQueue: string; runId: string }[] = []; - const queue = new ReleaseConcurrencyQueue({ + const queue = new ReleaseConcurrencyTokenBucketQueue({ redis: { keyPrefix: "release-queue:test:", host: redisContainer.getHost(), @@ -218,6 +222,7 @@ describe("ReleaseConcurrencyQueue", () => { } executedRuns.push({ releaseQueue, runId }); }, + maxTokens: async (_) => 2, keys: { fromDescriptor: (descriptor) => descriptor, toDescriptor: (name) => name, @@ -236,12 +241,12 @@ describe("ReleaseConcurrencyQueue", () => { try { // Attempt to release with failing executor - await queue.attemptToRelease("test-queue", "run1", 2); + await queue.attemptToRelease("test-queue", "run1"); // Does not execute because the executor throws an error expect(executedRuns).toHaveLength(0); // Token should have been returned to the bucket so this should try to execute immediately and fail again - await queue.attemptToRelease("test-queue", "run2", 2); + await queue.attemptToRelease("test-queue", "run2"); expect(executedRuns).toHaveLength(0); // Allow executor to succeed @@ -260,21 +265,21 @@ describe("ReleaseConcurrencyQueue", () => { ); redisTest("Should handle invalid token amounts", async ({ redisContainer }) => { - const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer); + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 1); try { // Try to refill with negative tokens - await expect(queue.refillTokens({ name: "test-queue" }, 1, -1)).rejects.toThrow(); + await expect(queue.refillTokens({ name: "test-queue" }, -1)).rejects.toThrow(); // Try to refill with zero tokens - await queue.refillTokens({ name: "test-queue" }, 1, 0); + await queue.refillTokens({ name: "test-queue" }, 0); await setTimeout(1000); expect(executedRuns).toHaveLength(0); // Verify normal operation still works - await queue.attemptToRelease({ name: "test-queue" }, "run1", 1); + await queue.attemptToRelease({ name: "test-queue" }, "run1"); expect(executedRuns).toHaveLength(1); } finally { await queue.quit(); @@ -284,7 +289,7 @@ describe("ReleaseConcurrencyQueue", () => { redisTest("Should handle concurrent operations correctly", async ({ redisContainer }) => { const executedRuns: { releaseQueue: string; runId: string }[] = []; - const queue = new ReleaseConcurrencyQueue({ + const queue = new ReleaseConcurrencyTokenBucketQueue({ redis: { keyPrefix: "release-queue:test:", host: redisContainer.getHost(), @@ -299,6 +304,7 @@ describe("ReleaseConcurrencyQueue", () => { fromDescriptor: (descriptor) => descriptor, toDescriptor: (name) => name, }, + maxTokens: async (_) => 2, batchSize: 5, pollInterval: 50, }); @@ -306,17 +312,17 @@ describe("ReleaseConcurrencyQueue", () => { try { // Attempt multiple concurrent releases await Promise.all([ - queue.attemptToRelease("test-queue", "run1", 2), - queue.attemptToRelease("test-queue", "run2", 2), - queue.attemptToRelease("test-queue", "run3", 2), - queue.attemptToRelease("test-queue", "run4", 2), + queue.attemptToRelease("test-queue", "run1"), + queue.attemptToRelease("test-queue", "run2"), + queue.attemptToRelease("test-queue", "run3"), + queue.attemptToRelease("test-queue", "run4"), ]); // Should only execute maxTokens (2) runs expect(executedRuns).toHaveLength(2); // Attempt concurrent refills - queue.refillTokens("test-queue", 2, 2); + await queue.refillTokens("test-queue", 2); await setTimeout(1000); @@ -341,25 +347,25 @@ describe("ReleaseConcurrencyQueue", () => { }); redisTest("Should clean up Redis resources on quit", async ({ redisContainer }) => { - const { queue } = createReleaseConcurrencyQueue(redisContainer); + const { queue } = createReleaseConcurrencyQueue(redisContainer, 1); // Add some data - await queue.attemptToRelease({ name: "test-queue" }, "run1", 1); - await queue.attemptToRelease({ name: "test-queue" }, "run2", 1); + await queue.attemptToRelease({ name: "test-queue" }, "run1"); + await queue.attemptToRelease({ name: "test-queue" }, "run2"); // Quit the queue await queue.quit(); // Verify we can't perform operations after quit - await expect(queue.attemptToRelease({ name: "test-queue" }, "run3", 1)).rejects.toThrow(); - await expect(queue.refillTokens({ name: "test-queue" }, 1, 1)).rejects.toThrow(); + await expect(queue.attemptToRelease({ name: "test-queue" }, "run3")).rejects.toThrow(); + await expect(queue.refillTokens({ name: "test-queue" }, 1)).rejects.toThrow(); }); redisTest("Should stop retrying after max retries is reached", async ({ redisContainer }) => { let failCount = 0; const executedRuns: { releaseQueue: string; runId: string; attempt: number }[] = []; - const queue = new ReleaseConcurrencyQueue({ + const queue = new ReleaseConcurrencyTokenBucketQueue({ redis: { keyPrefix: "release-queue:test:", host: redisContainer.getHost(), @@ -374,6 +380,7 @@ describe("ReleaseConcurrencyQueue", () => { fromDescriptor: (descriptor) => descriptor, toDescriptor: (name) => name, }, + maxTokens: async (_) => 1, retry: { maxRetries: 2, // Set max retries to 2 (will attempt 3 times total: initial + 2 retries) backoff: { @@ -387,7 +394,7 @@ describe("ReleaseConcurrencyQueue", () => { try { // Attempt to release - this will fail and retry - await queue.attemptToRelease("test-queue", "run1", 1); + await queue.attemptToRelease("test-queue", "run1"); // Wait for retries to occur await setTimeout(2000); @@ -404,7 +411,7 @@ describe("ReleaseConcurrencyQueue", () => { // Attempt a new release to verify the token was returned let secondRunAttempted = false; - const queue2 = new ReleaseConcurrencyQueue({ + const queue2 = new ReleaseConcurrencyTokenBucketQueue({ redis: { keyPrefix: "release-queue:test:", host: redisContainer.getHost(), @@ -417,6 +424,7 @@ describe("ReleaseConcurrencyQueue", () => { fromDescriptor: (descriptor) => descriptor, toDescriptor: (name) => name, }, + maxTokens: async (_) => 1, retry: { maxRetries: 2, backoff: { @@ -428,7 +436,7 @@ describe("ReleaseConcurrencyQueue", () => { pollInterval: 50, }); - await queue2.attemptToRelease("test-queue", "run2", 1); + await queue2.attemptToRelease("test-queue", "run2"); expect(secondRunAttempted).toBe(true); // Should execute immediately because token was returned await queue2.quit(); @@ -441,7 +449,7 @@ describe("ReleaseConcurrencyQueue", () => { const executedRuns: { releaseQueue: string; runId: string; attempt: number }[] = []; const runAttempts: Record = {}; - const queue = new ReleaseConcurrencyQueue({ + const queue = new ReleaseConcurrencyTokenBucketQueue({ redis: { keyPrefix: "release-queue:test:", host: redisContainer.getHost(), @@ -456,6 +464,7 @@ describe("ReleaseConcurrencyQueue", () => { fromDescriptor: (descriptor) => descriptor, toDescriptor: (name) => name, }, + maxTokens: async (_) => 3, retry: { maxRetries: 2, backoff: { @@ -471,9 +480,9 @@ describe("ReleaseConcurrencyQueue", () => { try { // Queue up multiple runs await Promise.all([ - queue.attemptToRelease("test-queue", "run1", 3), - queue.attemptToRelease("test-queue", "run2", 3), - queue.attemptToRelease("test-queue", "run3", 3), + queue.attemptToRelease("test-queue", "run1"), + queue.attemptToRelease("test-queue", "run2"), + queue.attemptToRelease("test-queue", "run3"), ]); // Wait for all retries to complete @@ -514,7 +523,7 @@ describe("ReleaseConcurrencyQueue", () => { const minDelay = 100; const factor = 2; - const queue = new ReleaseConcurrencyQueue({ + const queue = new ReleaseConcurrencyTokenBucketQueue({ redis: { keyPrefix: "release-queue:test:", host: redisContainer.getHost(), @@ -530,6 +539,7 @@ describe("ReleaseConcurrencyQueue", () => { fromDescriptor: (descriptor) => descriptor, toDescriptor: (name) => name, }, + maxTokens: async (_) => 1, retry: { maxRetries: 2, backoff: { @@ -543,7 +553,7 @@ describe("ReleaseConcurrencyQueue", () => { try { startTime = Date.now(); - await queue.attemptToRelease("test-queue", "run1", 1); + await queue.attemptToRelease("test-queue", "run1"); // Wait for all retries to complete await setTimeout(1000); diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index c5f4fc4788..9be6341510 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -36,7 +36,7 @@ export type RunEngineOptions = { queueRunsWaitingForWorkerBatchSize?: number; tracer: Tracer; releaseConcurrency?: { - maxTokens?: number; + maxTokensRatio?: number; redis?: Partial; }; }; diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.ts b/internal-packages/run-engine/src/run-queue/keyProducer.ts index 033da935b8..a24bf8dbdd 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.ts @@ -299,35 +299,7 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { concurrencyKey: parts.at(9), }; } - releaseConcurrencyKey(env: EnvDescriptor): string; - releaseConcurrencyKey(env: MinimalAuthenticatedEnvironment): string; - releaseConcurrencyKey(envOrDescriptor: EnvDescriptor | MinimalAuthenticatedEnvironment): string { - if ("id" in envOrDescriptor) { - return [ - this.orgKeySection(envOrDescriptor.organization.id), - this.projKeySection(envOrDescriptor.project.id), - this.envKeySection(envOrDescriptor.id), - "release-concurrency", - ].join(":"); - } else { - return [ - this.orgKeySection(envOrDescriptor.orgId), - this.projKeySection(envOrDescriptor.projectId), - this.envKeySection(envOrDescriptor.envId), - "release-concurrency", - ].join(":"); - } - } - - releaseConcurrencyDescriptorFromQueue(queue: string): EnvDescriptor { - const parts = queue.split(":"); - return { - orgId: parts[1], - projectId: parts[3], - envId: parts[5], - }; - } private envKeySection(envId: string) { return `${constants.ENV_PART}:${envId}`; } diff --git a/internal-packages/run-engine/src/run-queue/types.ts b/internal-packages/run-engine/src/run-queue/types.ts index cbf59ccedc..563cececa8 100644 --- a/internal-packages/run-engine/src/run-queue/types.ts +++ b/internal-packages/run-engine/src/run-queue/types.ts @@ -96,11 +96,6 @@ export interface RunQueueKeyProducer { deadLetterQueueKey(env: MinimalAuthenticatedEnvironment): string; deadLetterQueueKey(env: EnvDescriptor): string; deadLetterQueueKeyFromQueue(queue: string): string; - - releaseConcurrencyKey(env: MinimalAuthenticatedEnvironment): string; - releaseConcurrencyKey(env: EnvDescriptor): string; - - releaseConcurrencyDescriptorFromQueue(queue: string): EnvDescriptor; } export type EnvQueues = { From cce402dfe763456f88e3e2f716823b42d9b01a7c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 14 Mar 2025 09:29:30 +0000 Subject: [PATCH 14/38] If max tokens is 0, then don't do releasings --- .../releaseConcurrencyTokenBucketQueue.ts | 20 +++++++++- ...releaseConcurrencyTokenBucketQueue.test.ts | 38 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts b/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts index c4cb99e170..74b767667c 100644 --- a/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts +++ b/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts @@ -90,7 +90,12 @@ export class ReleaseConcurrencyTokenBucketQueue { * and wait until the token is available. */ public async attemptToRelease(releaseQueueDescriptor: T, runId: string) { - const maxTokens = await this.maxTokens(releaseQueueDescriptor); + const maxTokens = await this.#callMaxTokens(releaseQueueDescriptor); + + if (maxTokens === 0) { + return; + } + const releaseQueue = this.keys.fromDescriptor(releaseQueueDescriptor); const result = await this.redis.consumeToken( @@ -118,7 +123,7 @@ export class ReleaseConcurrencyTokenBucketQueue { * This will add the amount of tokens to the token bucket. */ public async refillTokens(releaseQueueDescriptor: T, amount: number = 1) { - const maxTokens = await this.maxTokens(releaseQueueDescriptor); + const maxTokens = await this.#callMaxTokens(releaseQueueDescriptor); const releaseQueue = this.keys.fromDescriptor(releaseQueueDescriptor); if (amount < 0) { @@ -218,6 +223,17 @@ export class ReleaseConcurrencyTokenBucketQueue { } } + // Make sure maxTokens is an integer (round down) + // And if it throws, return 0 + async #callMaxTokens(releaseQueueDescriptor: T) { + try { + const maxTokens = await this.maxTokens(releaseQueueDescriptor); + return Math.floor(maxTokens); + } catch (error) { + return 0; + } + } + #bucketKey(releaseQueue: string) { return `${releaseQueue}:bucket`; } diff --git a/internal-packages/run-engine/src/engine/tests/releaseConcurrencyTokenBucketQueue.test.ts b/internal-packages/run-engine/src/engine/tests/releaseConcurrencyTokenBucketQueue.test.ts index 9cce28ec1d..0c416457d9 100644 --- a/internal-packages/run-engine/src/engine/tests/releaseConcurrencyTokenBucketQueue.test.ts +++ b/internal-packages/run-engine/src/engine/tests/releaseConcurrencyTokenBucketQueue.test.ts @@ -580,4 +580,42 @@ describe("ReleaseConcurrencyQueue", () => { await queue.quit(); } }); + + redisTest("Should not execute or queue when maxTokens is 0", async ({ redisContainer }) => { + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 0); + + try { + // Attempt to release with maxTokens of 0 + await queue.attemptToRelease({ name: "test-queue" }, "run1"); + await queue.attemptToRelease({ name: "test-queue" }, "run2"); + + // Wait some time to ensure no processing occurs + await setTimeout(1000); + + // Should not have executed any runs + expect(executedRuns).toHaveLength(0); + } finally { + await queue.quit(); + } + }); + + // Makes sure that the maxTokens is an integer (round down) + // And if it throws, returns 0 + redisTest("Should handle maxTokens errors", async ({ redisContainer }) => { + const { queue, executedRuns } = createReleaseConcurrencyQueue(redisContainer, 0.5); + + try { + // Attempt to release with maxTokens of 0 + await queue.attemptToRelease({ name: "test-queue" }, "run1"); + await queue.attemptToRelease({ name: "test-queue" }, "run2"); + + // Wait some time to ensure no processing occurs + await setTimeout(1000); + + // Should not have executed any runs + expect(executedRuns).toHaveLength(0); + } finally { + await queue.quit(); + } + }); }); From de9e29655195652c8426b3a7dc8818f0e31cc8e9 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 14 Mar 2025 10:37:47 +0000 Subject: [PATCH 15/38] Add configuration for the release concurrency queue --- internal-packages/run-engine/src/engine/index.ts | 11 +++++++---- .../src/engine/tests/releasingConcurrency.test.ts | 10 ++++++++++ internal-packages/run-engine/src/engine/types.ts | 9 +++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 internal-packages/run-engine/src/engine/tests/releasingConcurrency.test.ts diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 6b24768a7c..9f35cc2a52 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -255,13 +255,16 @@ export class RunEngine { keyPrefix: `${options.queue.redis.keyPrefix}release-concurrency:`, }, retry: { - maxRetries: 5, // TODO: Make this configurable + maxRetries: options.releaseConcurrency?.maxRetries ?? 5, backoff: { - minDelay: 1000, // TODO: Make this configurable - maxDelay: 10000, // TODO: Make this configurable - factor: 2, // TODO: Make this configurable + minDelay: options.releaseConcurrency?.backoff?.minDelay ?? 1000, + maxDelay: options.releaseConcurrency?.backoff?.maxDelay ?? 10000, + factor: options.releaseConcurrency?.backoff?.factor ?? 2, }, }, + consumersCount: options.releaseConcurrency?.consumersCount ?? 1, + pollInterval: options.releaseConcurrency?.pollInterval ?? 1000, + batchSize: options.releaseConcurrency?.batchSize ?? 10, executor: async (descriptor, runId) => { await this.#executeReleasedConcurrencyFromQueue(descriptor, runId); }, diff --git a/internal-packages/run-engine/src/engine/tests/releasingConcurrency.test.ts b/internal-packages/run-engine/src/engine/tests/releasingConcurrency.test.ts new file mode 100644 index 0000000000..7d5c2e89e7 --- /dev/null +++ b/internal-packages/run-engine/src/engine/tests/releasingConcurrency.test.ts @@ -0,0 +1,10 @@ +import { containerTest } from "@internal/testcontainers"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("RunEngine Releasing Concurrency", () => { + containerTest( + "blocking a run with a waitpoint with releasing concurrency", + async ({ prisma, redisOptions }) => {} + ); +}); diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index 9be6341510..3dd49e2701 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -38,6 +38,15 @@ export type RunEngineOptions = { releaseConcurrency?: { maxTokensRatio?: number; redis?: Partial; + maxRetries?: number; + consumersCount?: number; + pollInterval?: number; + batchSize?: number; + backoff?: { + minDelay?: number; // Defaults to 1000 + maxDelay?: number; // Defaults to 60000 + factor?: number; // Defaults to 2 + }; }; }; From cf3b23862eeb8b51f8b9b5fe6188ee4033e78990 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 14 Mar 2025 13:30:08 +0000 Subject: [PATCH 16/38] remove project and task current concurrency tracking --- .../run-engine/src/run-queue/index.test.ts | 235 +++++------------- .../run-engine/src/run-queue/index.ts | 176 ++----------- .../src/run-queue/keyProducer.test.ts | 67 ----- .../run-engine/src/run-queue/keyProducer.ts | 45 ---- .../src/run-queue/tests/ack.test.ts | 18 -- .../dequeueMessageFromMasterQueue.test.ts | 18 -- .../run-queue/tests/enqueueMessage.test.ts | 27 -- .../src/run-queue/tests/nack.test.ts | 11 - .../run-engine/src/run-queue/types.ts | 10 - 9 files changed, 88 insertions(+), 519 deletions(-) diff --git a/internal-packages/run-engine/src/run-queue/index.test.ts b/internal-packages/run-engine/src/run-queue/index.test.ts index 952df3b28c..4085d87dfd 100644 --- a/internal-packages/run-engine/src/run-queue/index.test.ts +++ b/internal-packages/run-engine/src/run-queue/index.test.ts @@ -216,13 +216,6 @@ describe("RunQueue", () => { expect(queueConcurrency).toBe(0); const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); expect(envConcurrency).toBe(0); - const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); - expect(projectConcurrency).toBe(0); - const taskConcurrency = await queue.currentConcurrencyOfTask( - authenticatedEnvDev, - messageDev.taskIdentifier - ); - expect(taskConcurrency).toBe(0); const dequeued = await queue.dequeueMessageFromMasterQueue( "test_12345", @@ -243,13 +236,6 @@ describe("RunQueue", () => { expect(queueConcurrency2).toBe(1); const envConcurrency2 = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); expect(envConcurrency2).toBe(1); - const projectConcurrency2 = await queue.currentConcurrencyOfProject(authenticatedEnvDev); - expect(projectConcurrency2).toBe(1); - const taskConcurrency2 = await queue.currentConcurrencyOfTask( - authenticatedEnvDev, - messageDev.taskIdentifier - ); - expect(taskConcurrency2).toBe(1); //queue lengths const result3 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); @@ -337,13 +323,6 @@ describe("RunQueue", () => { expect(queueConcurrency).toBe(0); const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd); expect(envConcurrency).toBe(0); - const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvProd); - expect(projectConcurrency).toBe(0); - const taskConcurrency = await queue.currentConcurrencyOfTask( - authenticatedEnvProd, - messageProd.taskIdentifier - ); - expect(taskConcurrency).toBe(0); //dequeue const dequeued = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 10); @@ -361,13 +340,6 @@ describe("RunQueue", () => { expect(queueConcurrency2).toBe(1); const envConcurrency2 = await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd); expect(envConcurrency2).toBe(1); - const projectConcurrency2 = await queue.currentConcurrencyOfProject(authenticatedEnvProd); - expect(projectConcurrency2).toBe(1); - const taskConcurrency2 = await queue.currentConcurrencyOfTask( - authenticatedEnvProd, - messageProd.taskIdentifier - ); - expect(taskConcurrency2).toBe(1); //queue length const length2 = await queue.lengthOfQueue(authenticatedEnvProd, messageProd.queue); @@ -517,13 +489,6 @@ describe("RunQueue", () => { expect(queueConcurrency).toBe(0); const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd); expect(envConcurrency).toBe(0); - const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvProd); - expect(projectConcurrency).toBe(0); - const taskConcurrency = await queue.currentConcurrencyOfTask( - authenticatedEnvProd, - messageProd.taskIdentifier - ); - expect(taskConcurrency).toBe(0); //queue lengths const queueLength3 = await queue.lengthOfQueue(authenticatedEnvProd, messageProd.queue); @@ -586,13 +551,6 @@ describe("RunQueue", () => { expect(queueConcurrency).toBe(0); const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd); expect(envConcurrency).toBe(0); - const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvProd); - expect(projectConcurrency).toBe(0); - const taskConcurrency = await queue.currentConcurrencyOfTask( - authenticatedEnvProd, - messageProd.taskIdentifier - ); - expect(taskConcurrency).toBe(0); //queue lengths const queueLength3 = await queue.lengthOfQueue(authenticatedEnvProd, messageProd.queue); @@ -651,13 +609,6 @@ describe("RunQueue", () => { expect(queueConcurrency).toBe(1); const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd); expect(envConcurrency).toBe(1); - const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvProd); - expect(projectConcurrency).toBe(1); - const taskConcurrency = await queue.currentConcurrencyOfTask( - authenticatedEnvProd, - messageProd.taskIdentifier - ); - expect(taskConcurrency).toBe(1); await queue.nackMessage({ orgId: messages[0].message.orgId, @@ -675,13 +626,6 @@ describe("RunQueue", () => { expect(queueConcurrency2).toBe(0); const envConcurrency2 = await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd); expect(envConcurrency2).toBe(0); - const projectConcurrency2 = await queue.currentConcurrencyOfProject(authenticatedEnvProd); - expect(projectConcurrency2).toBe(0); - const taskConcurrency2 = await queue.currentConcurrencyOfTask( - authenticatedEnvProd, - messageProd.taskIdentifier - ); - expect(taskConcurrency2).toBe(0); //queue lengths const queueLength = await queue.lengthOfQueue(authenticatedEnvProd, messageProd.queue); @@ -704,127 +648,89 @@ describe("RunQueue", () => { } }); - redisTest( - "Releasing concurrency", - { timeout: 5_000 }, - async ({ redisContainer, redisOptions }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), + redisTest("Releasing concurrency", async ({ redisContainer, redisOptions }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ redis: { keyPrefix: "runqueue:test:", host: redisContainer.getHost(), port: redisContainer.getPort(), }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + const redis = createRedisClient({ ...redisOptions, keyPrefix: "runqueue:test:" }); + + try { + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: messageProd, + masterQueues: "main", }); - const redis = createRedisClient({ ...redisOptions, keyPrefix: "runqueue:test:" }); + const messages = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 10); + expect(messages.length).toBe(1); - try { - await queue.enqueueMessage({ - env: authenticatedEnvProd, - message: messageProd, - masterQueues: "main", - }); + //check the message is gone + const key = queue.keys.messageKey(messages[0].message.orgId, messages[0].messageId); + const exists = await redis.exists(key); + expect(exists).toBe(1); - const messages = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 10); - expect(messages.length).toBe(1); + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); - //check the message is gone - const key = queue.keys.messageKey(messages[0].message.orgId, messages[0].messageId); - const exists = await redis.exists(key); - expect(exists).toBe(1); + //release the concurrency + await queue.releaseConcurrency(authenticatedEnvProd.organization.id, messages[0].messageId); - //concurrencies - expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 1 - ); - expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); - expect(await queue.currentConcurrencyOfProject(authenticatedEnvProd)).toBe(1); - expect( - await queue.currentConcurrencyOfTask(authenticatedEnvProd, messageProd.taskIdentifier) - ).toBe(1); - - //release the concurrency (not the queue) - await queue.releaseConcurrency( - authenticatedEnvProd.organization.id, - messages[0].messageId, - false - ); + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 0 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(0); - //concurrencies - expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 1 - ); - expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(0); - expect(await queue.currentConcurrencyOfProject(authenticatedEnvProd)).toBe(0); - expect( - await queue.currentConcurrencyOfTask(authenticatedEnvProd, messageProd.taskIdentifier) - ).toBe(0); - - //reacquire the concurrency - await queue.reacquireConcurrency( - authenticatedEnvProd.organization.id, - messages[0].messageId - ); + //reacquire the concurrency + await queue.reacquireConcurrency(authenticatedEnvProd.organization.id, messages[0].messageId); - //check concurrencies are back to what they were before - expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 1 - ); - expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); - expect(await queue.currentConcurrencyOfProject(authenticatedEnvProd)).toBe(1); - expect( - await queue.currentConcurrencyOfTask(authenticatedEnvProd, messageProd.taskIdentifier) - ).toBe(1); - - //release the concurrency (with the queue this time) - await queue.releaseConcurrency( - authenticatedEnvProd.organization.id, - messages[0].messageId, - true - ); + //check concurrencies are back to what they were before + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); - //concurrencies - expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 0 - ); - expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(0); - expect(await queue.currentConcurrencyOfProject(authenticatedEnvProd)).toBe(0); - expect( - await queue.currentConcurrencyOfTask(authenticatedEnvProd, messageProd.taskIdentifier) - ).toBe(0); - - //reacquire the concurrency - await queue.reacquireConcurrency( - authenticatedEnvProd.organization.id, - messages[0].messageId - ); + //release the concurrency (with the queue this time) + await queue.releaseConcurrency(authenticatedEnvProd.organization.id, messages[0].messageId); - //check concurrencies are back to what they were before - expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 1 - ); - expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); - expect(await queue.currentConcurrencyOfProject(authenticatedEnvProd)).toBe(1); - expect( - await queue.currentConcurrencyOfTask(authenticatedEnvProd, messageProd.taskIdentifier) - ).toBe(1); - } finally { - try { - await queue.quit(); - await redis.quit(); - } catch (e) {} - } + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 0 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(0); + + //reacquire the concurrency + await queue.reacquireConcurrency(authenticatedEnvProd.organization.id, messages[0].messageId); + + //check concurrencies are back to what they were before + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + } finally { + try { + await queue.quit(); + await redis.quit(); + } catch (e) {} } - ); + }); redisTest("Dead Letter Queue", async ({ redisContainer, redisOptions }) => { const queue = new RunQueue({ @@ -882,13 +788,6 @@ describe("RunQueue", () => { expect(queueConcurrency2).toBe(0); const envConcurrency2 = await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd); expect(envConcurrency2).toBe(0); - const projectConcurrency2 = await queue.currentConcurrencyOfProject(authenticatedEnvProd); - expect(projectConcurrency2).toBe(0); - const taskConcurrency2 = await queue.currentConcurrencyOfTask( - authenticatedEnvProd, - messageProd.taskIdentifier - ); - expect(taskConcurrency2).toBe(0); //check the message is still there const message = await queue.readMessage(messages[0].message.orgId, messages[0].messageId); diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 8ec03dc922..34d717c318 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -293,21 +293,10 @@ export class RunQueue { return this.redis.scard(this.keys.reserveConcurrencyKey(env, queue)); } - public async currentConcurrencyOfProject(env: MinimalAuthenticatedEnvironment) { - return this.redis.scard(this.keys.projectCurrentConcurrencyKey(env)); - } - public async messageExists(orgId: string, messageId: string) { return this.redis.exists(this.keys.messageKey(orgId, messageId)); } - public async currentConcurrencyOfTask( - env: MinimalAuthenticatedEnvironment, - taskIdentifier: string - ) { - return this.redis.scard(this.keys.taskIdentifierCurrentConcurrencyKey(env, taskIdentifier)); - } - public async readMessage(orgId: string, messageId: string) { return this.#trace( "readMessage", @@ -615,11 +604,6 @@ export class RunQueue { message.queue, this.keys.currentConcurrencyKeyFromQueue(message.queue), this.keys.envCurrentConcurrencyKeyFromQueue(message.queue), - this.keys.projectCurrentConcurrencyKeyFromQueue(message.queue), - this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( - message.queue, - message.taskIdentifier - ), messageId, JSON.stringify(message.masterQueues) ); @@ -661,11 +645,6 @@ export class RunQueue { message.queue, this.keys.currentConcurrencyKeyFromQueue(message.queue), this.keys.envCurrentConcurrencyKeyFromQueue(message.queue), - this.keys.projectCurrentConcurrencyKeyFromQueue(message.queue), - this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( - message.queue, - message.taskIdentifier - ), messageId, JSON.stringify(message.masterQueues) ); @@ -806,13 +785,6 @@ export class RunQueue { const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(message.queue); const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(message.queue); - const taskCurrentConcurrencyKey = this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( - message.queue, - message.taskIdentifier - ); - const projectCurrentConcurrencyKey = this.keys.projectCurrentConcurrencyKeyFromQueue( - message.queue - ); const envQueueKey = this.keys.envQueueKeyFromQueue(message.queue); const queueName = message.queue; @@ -828,8 +800,6 @@ export class RunQueue { messageKey, queueCurrentConcurrencyKey, envCurrentConcurrencyKey, - taskCurrentConcurrencyKey, - projectCurrentConcurrencyKey, envQueueKey, queueName, messageId, @@ -846,8 +816,6 @@ export class RunQueue { queueReserveConcurrencyKey, envCurrentConcurrencyKey, envReserveConcurrencyKey, - taskCurrentConcurrencyKey, - projectCurrentConcurrencyKey, envQueueKey, queueName, messageId, @@ -891,8 +859,6 @@ export class RunQueue { envCurrentConcurrencyKey, envReserveConcurrencyKey, envConcurrencyLimitKey, - taskCurrentConcurrencyKey, - projectCurrentConcurrencyKey, envQueueKey, queueName, messageId, @@ -936,8 +902,6 @@ export class RunQueue { envCurrentConcurrencyKey, envReserveConcurrencyKey, envConcurrencyLimitKey, - taskCurrentConcurrencyKey, - projectCurrentConcurrencyKey, envQueueKey, queueName, messageId, @@ -964,12 +928,8 @@ export class RunQueue { const envConcurrencyLimitKey = this.keys.envConcurrencyLimitKeyFromQueue(messageQueue); const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(messageQueue); const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(messageQueue); - const projectCurrentConcurrencyKey = - this.keys.projectCurrentConcurrencyKeyFromQueue(messageQueue); const messageKeyPrefix = this.keys.messageKeyPrefixFromQueue(messageQueue); const envQueueKey = this.keys.envQueueKeyFromQueue(messageQueue); - const taskCurrentConcurrentKeyPrefix = - this.keys.taskIdentifierCurrentConcurrencyKeyPrefixFromQueue(messageQueue); this.logger.debug("#callDequeueMessage", { messageQueue, @@ -979,10 +939,8 @@ export class RunQueue { queueReserveConcurrencyKey, envCurrentConcurrencyKey, envReserveConcurrencyKey, - projectCurrentConcurrencyKey, messageKeyPrefix, envQueueKey, - taskCurrentConcurrentKeyPrefix, }); const result = await this.redis.dequeueMessage( @@ -994,10 +952,8 @@ export class RunQueue { queueReserveConcurrencyKey, envCurrentConcurrencyKey, envReserveConcurrencyKey, - projectCurrentConcurrencyKey, messageKeyPrefix, envQueueKey, - taskCurrentConcurrentKeyPrefix, //args messageQueue, String(Date.now()), @@ -1051,14 +1007,7 @@ export class RunQueue { const messageQueue = message.queue; const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); - const projectCurrentConcurrencyKey = this.keys.projectCurrentConcurrencyKeyFromQueue( - message.queue - ); const envQueueKey = this.keys.envQueueKeyFromQueue(message.queue); - const taskCurrentConcurrencyKey = this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( - message.queue, - message.taskIdentifier - ); const masterQueues = message.masterQueues; this.logger.debug("Calling acknowledgeMessage", { @@ -1066,9 +1015,7 @@ export class RunQueue { messageQueue, queueCurrentConcurrencyKey, envCurrentConcurrencyKey, - projectCurrentConcurrencyKey, envQueueKey, - taskCurrentConcurrencyKey, messageId, masterQueues, service: this.name, @@ -1082,9 +1029,7 @@ export class RunQueue { messageQueue, queueCurrentConcurrencyKey, envCurrentConcurrencyKey, - projectCurrentConcurrencyKey, envQueueKey, - taskCurrentConcurrencyKey, queueReserveConcurrencyKey, envReserveConcurrencyKey, messageId, @@ -1100,14 +1045,7 @@ export class RunQueue { const messageQueue = message.queue; const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); - const projectCurrentConcurrencyKey = this.keys.projectCurrentConcurrencyKeyFromQueue( - message.queue - ); const envQueueKey = this.keys.envQueueKeyFromQueue(message.queue); - const taskCurrentConcurrencyKey = this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( - message.queue, - message.taskIdentifier - ); const nextRetryDelay = calculateNextRetryDelay(this.retryOptions, message.attempt); const messageScore = retryAt ?? (nextRetryDelay ? Date.now() + nextRetryDelay : Date.now()); @@ -1118,9 +1056,7 @@ export class RunQueue { masterQueues: message.masterQueues, queueCurrentConcurrencyKey, envCurrentConcurrencyKey, - projectCurrentConcurrencyKey, envQueueKey, - taskCurrentConcurrencyKey, messageId, messageScore, attempt: message.attempt, @@ -1133,9 +1069,7 @@ export class RunQueue { messageQueue, queueCurrentConcurrencyKey, envCurrentConcurrencyKey, - projectCurrentConcurrencyKey, envQueueKey, - taskCurrentConcurrencyKey, //args messageId, messageQueue, @@ -1152,14 +1086,7 @@ export class RunQueue { const messageQueue = message.queue; const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); - const projectCurrentConcurrencyKey = this.keys.projectCurrentConcurrencyKeyFromQueue( - message.queue - ); const envQueueKey = this.keys.envQueueKeyFromQueue(message.queue); - const taskCurrentConcurrencyKey = this.keys.taskIdentifierCurrentConcurrencyKeyFromQueue( - message.queue, - message.taskIdentifier - ); const deadLetterQueueKey = this.keys.deadLetterQueueKeyFromQueue(message.queue); await this.redis.moveToDeadLetterQueue( @@ -1167,9 +1094,7 @@ export class RunQueue { messageQueue, queueCurrentConcurrencyKey, envCurrentConcurrencyKey, - projectCurrentConcurrencyKey, envQueueKey, - taskCurrentConcurrencyKey, deadLetterQueueKey, messageId, messageQueue, @@ -1193,7 +1118,7 @@ export class RunQueue { #registerCommands() { this.redis.defineCommand("enqueueMessage", { - numberOfKeys: 9, + numberOfKeys: 7, lua: ` local queueKey = KEYS[1] local messageKey = KEYS[2] @@ -1201,9 +1126,7 @@ local queueCurrentConcurrencyKey = KEYS[3] local queueReserveConcurrencyKey = KEYS[4] local envCurrentConcurrencyKey = KEYS[5] local envReserveConcurrencyKey = KEYS[6] -local taskCurrentConcurrencyKey = KEYS[7] -local projectCurrentConcurrencyKey = KEYS[8] -local envQueueKey = KEYS[9] +local envQueueKey = KEYS[7] local queueName = ARGV[1] local messageId = ARGV[2] @@ -1236,8 +1159,6 @@ end -- Update the concurrency keys redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', taskCurrentConcurrencyKey, messageId) -redis.call('SREM', projectCurrentConcurrencyKey, messageId) redis.call('SREM', envReserveConcurrencyKey, messageId) redis.call('SREM', queueReserveConcurrencyKey, messageId) @@ -1246,7 +1167,7 @@ return true }); this.redis.defineCommand("enqueueMessageWithReservingConcurrency", { - numberOfKeys: 10, + numberOfKeys: 8, lua: ` local queueKey = KEYS[1] local messageKey = KEYS[2] @@ -1255,9 +1176,7 @@ local queueReserveConcurrencyKey = KEYS[4] local envCurrentConcurrencyKey = KEYS[5] local envReserveConcurrencyKey = KEYS[6] local envConcurrencyLimitKey = KEYS[7] -local taskCurrentConcurrencyKey = KEYS[8] -local projectCurrentConcurrencyKey = KEYS[9] -local envQueueKey = KEYS[10] +local envQueueKey = KEYS[8] local queueName = ARGV[1] local messageId = ARGV[2] @@ -1292,8 +1211,6 @@ end -- Update the concurrency keys redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', taskCurrentConcurrencyKey, messageId) -redis.call('SREM', projectCurrentConcurrencyKey, messageId) redis.call('SREM', envReserveConcurrencyKey, messageId) redis.call('SREM', queueReserveConcurrencyKey, messageId) @@ -1312,7 +1229,7 @@ return true }); this.redis.defineCommand("enqueueMessageWithReservingConcurrencyOnRecursiveQueue", { - numberOfKeys: 11, + numberOfKeys: 9, lua: ` local queueKey = KEYS[1] local messageKey = KEYS[2] @@ -1322,9 +1239,7 @@ local queueConcurrencyLimitKey = KEYS[5] local envCurrentConcurrencyKey = KEYS[6] local envReserveConcurrencyKey = KEYS[7] local envConcurrencyLimitKey = KEYS[8] -local taskCurrentConcurrencyKey = KEYS[9] -local projectCurrentConcurrencyKey = KEYS[10] -local envQueueKey = KEYS[11] +local envQueueKey = KEYS[9] local queueName = ARGV[1] local messageId = ARGV[2] @@ -1374,8 +1289,6 @@ end -- Update the concurrency keys redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', taskCurrentConcurrencyKey, messageId) -redis.call('SREM', projectCurrentConcurrencyKey, messageId) redis.call('SREM', envReserveConcurrencyKey, messageId) redis.call('SREM', queueReserveConcurrencyKey, messageId) @@ -1394,7 +1307,7 @@ return true }); this.redis.defineCommand("dequeueMessage", { - numberOfKeys: 11, + numberOfKeys: 9, lua: ` local queueKey = KEYS[1] local queueConcurrencyLimitKey = KEYS[2] @@ -1403,10 +1316,8 @@ local queueCurrentConcurrencyKey = KEYS[4] local queueReserveConcurrencyKey = KEYS[5] local envCurrentConcurrencyKey = KEYS[6] local envReserveConcurrencyKey = KEYS[7] -local projectCurrentConcurrencyKey = KEYS[8] -local messageKeyPrefix = KEYS[9] -local envQueueKey = KEYS[10] -local taskCurrentConcurrentKeyPrefix = KEYS[11] +local messageKeyPrefix = KEYS[8] +local envQueueKey = KEYS[9] local queueName = ARGV[1] local currentTime = tonumber(ARGV[2]) @@ -1449,19 +1360,11 @@ local messageKey = messageKeyPrefix .. messageId local messagePayload = redis.call('GET', messageKey) local decodedPayload = cjson.decode(messagePayload); --- Extract taskIdentifier -local taskIdentifier = decodedPayload.taskIdentifier - --- Perform SADD with taskIdentifier and messageId -local taskCurrentConcurrencyKey = taskCurrentConcurrentKeyPrefix .. taskIdentifier - -- Update concurrency redis.call('ZREM', queueKey, messageId) redis.call('ZREM', envQueueKey, messageId) redis.call('SADD', queueCurrentConcurrencyKey, messageId) redis.call('SADD', envCurrentConcurrencyKey, messageId) -redis.call('SADD', projectCurrentConcurrencyKey, messageId) -redis.call('SADD', taskCurrentConcurrencyKey, messageId) -- Remove the message from the queue reserve concurrency set redis.call('SREM', queueReserveConcurrencyKey, messageId) @@ -1485,18 +1388,16 @@ return {messageId, messageScore, messagePayload} -- Return message details }); this.redis.defineCommand("acknowledgeMessage", { - numberOfKeys: 9, + numberOfKeys: 7, lua: ` -- Keys: local messageKey = KEYS[1] local messageQueue = KEYS[2] local concurrencyKey = KEYS[3] local envCurrentConcurrencyKey = KEYS[4] -local projectCurrentConcurrencyKey = KEYS[5] -local envQueueKey = KEYS[6] -local taskCurrentConcurrencyKey = KEYS[7] -local queueReserveConcurrencyKey = KEYS[8] -local envReserveConcurrencyKey = KEYS[9] +local envQueueKey = KEYS[5] +local queueReserveConcurrencyKey = KEYS[6] +local envReserveConcurrencyKey = KEYS[7] -- Args: local messageId = ARGV[1] @@ -1525,8 +1426,6 @@ end -- Update the concurrency keys redis.call('SREM', concurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', projectCurrentConcurrencyKey, messageId) -redis.call('SREM', taskCurrentConcurrencyKey, messageId) -- Clear reserve concurrency redis.call('SREM', queueReserveConcurrencyKey, messageId) @@ -1535,16 +1434,14 @@ redis.call('SREM', envReserveConcurrencyKey, messageId) }); this.redis.defineCommand("nackMessage", { - numberOfKeys: 7, + numberOfKeys: 5, lua: ` -- Keys: local messageKey = KEYS[1] local messageQueueKey = KEYS[2] local queueCurrentConcurrencyKey = KEYS[3] local envCurrentConcurrencyKey = KEYS[4] -local projectCurrentConcurrencyKey = KEYS[5] -local envQueueKey = KEYS[6] -local taskCurrentConcurrencyKey = KEYS[7] +local envQueueKey = KEYS[5] -- Args: local messageId = ARGV[1] @@ -1560,8 +1457,6 @@ redis.call('SET', messageKey, messageData) -- Update the concurrency keys redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', projectCurrentConcurrencyKey, messageId) -redis.call('SREM', taskCurrentConcurrencyKey, messageId) -- Enqueue the message into the queue redis.call('ZADD', messageQueueKey, messageScore, messageId) @@ -1581,17 +1476,15 @@ end }); this.redis.defineCommand("moveToDeadLetterQueue", { - numberOfKeys: 8, + numberOfKeys: 6, lua: ` -- Keys: local messageKey = KEYS[1] local messageQueue = KEYS[2] local queueCurrentConcurrencyKey = KEYS[3] local envCurrentConcurrencyKey = KEYS[4] -local projectCurrentConcurrencyKey = KEYS[5] -local envQueueKey = KEYS[6] -local taskCurrentConcurrencyKeyPrefix = KEYS[7] -local deadLetterQueueKey = KEYS[8] +local envQueueKey = KEYS[5] +local deadLetterQueueKey = KEYS[6] -- Args: local messageId = ARGV[1] @@ -1620,21 +1513,18 @@ redis.call('ZADD', deadLetterQueueKey, tonumber(redis.call('TIME')[1]), messageI -- Update the concurrency keys redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', projectCurrentConcurrencyKey, messageId) -redis.call('SREM', taskCurrentConcurrencyKeyPrefix, messageId) `, }); this.redis.defineCommand("releaseConcurrency", { - numberOfKeys: 6, + numberOfKeys: 5, lua: ` -- Keys: local messageKey = KEYS[1] local messageQueue = KEYS[2] local concurrencyKey = KEYS[3] local envCurrentConcurrencyKey = KEYS[4] -local projectCurrentConcurrencyKey = KEYS[5] -local taskCurrentConcurrencyKey = KEYS[6] +local envQueueKey = KEYS[5] -- Args: local messageId = ARGV[1] @@ -1644,21 +1534,17 @@ if concurrencyKey ~= "" then redis.call('SREM', concurrencyKey, messageId) end redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', projectCurrentConcurrencyKey, messageId) -redis.call('SREM', taskCurrentConcurrencyKey, messageId) `, }); this.redis.defineCommand("reacquireConcurrency", { - numberOfKeys: 6, + numberOfKeys: 4, lua: ` -- Keys: local messageKey = KEYS[1] local messageQueue = KEYS[2] local concurrencyKey = KEYS[3] local envCurrentConcurrencyKey = KEYS[4] -local projectCurrentConcurrencyKey = KEYS[5] -local taskCurrentConcurrencyKey = KEYS[6] -- Args: local messageId = ARGV[1] @@ -1666,8 +1552,6 @@ local messageId = ARGV[1] -- Update the concurrency keys redis.call('SADD', concurrencyKey, messageId) redis.call('SADD', envCurrentConcurrencyKey, messageId) -redis.call('SADD', projectCurrentConcurrencyKey, messageId) -redis.call('SADD', taskCurrentConcurrencyKey, messageId) `, }); @@ -1696,8 +1580,6 @@ declare module "@internal/redis" { queueReserveConcurrencyKey: string, envCurrentConcurrencyKey: string, envReserveConcurrencyKey: string, - taskCurrentConcurrencyKey: string, - projectCurrentConcurrencyKey: string, envQueueKey: string, //args queueName: string, @@ -1718,8 +1600,6 @@ declare module "@internal/redis" { envCurrentConcurrencyKey: string, envReserveConcurrencyKey: string, envConcurrencyLimitKey: string, - taskCurrentConcurrencyKey: string, - projectCurrentConcurrencyKey: string, envQueueKey: string, //args queueName: string, @@ -1743,8 +1623,6 @@ declare module "@internal/redis" { envCurrentConcurrencyKey: string, envReserveConcurrencyKey: string, envConcurrencyLimitKey: string, - taskCurrentConcurrencyKey: string, - projectCurrentConcurrencyKey: string, envQueueKey: string, //args queueName: string, @@ -1767,10 +1645,8 @@ declare module "@internal/redis" { queueReserveConcurrencyKey: string, envCurrentConcurrencyKey: string, envReserveConcurrencyKey: string, - projectCurrentConcurrencyKey: string, messageKeyPrefix: string, envQueueKey: string, - taskCurrentConcurrentKeyPrefix: string, //args childQueueName: string, currentTime: string, @@ -1784,9 +1660,7 @@ declare module "@internal/redis" { messageQueue: string, concurrencyKey: string, envConcurrencyKey: string, - projectConcurrencyKey: string, envQueueKey: string, - taskConcurrencyKey: string, queueReserveConcurrencyKey: string, envReserveConcurrencyKey: string, messageId: string, @@ -1801,9 +1675,7 @@ declare module "@internal/redis" { messageQueue: string, queueCurrentConcurrencyKey: string, envCurrentConcurrencyKey: string, - projectCurrentConcurrencyKey: string, envQueueKey: string, - taskCurrentConcurrencyKey: string, messageId: string, messageQueueName: string, messageData: string, @@ -1818,9 +1690,7 @@ declare module "@internal/redis" { messageQueue: string, queueCurrentConcurrencyKey: string, envCurrentConcurrencyKey: string, - projectCurrentConcurrencyKey: string, envQueueKey: string, - taskCurrentConcurrencyKey: string, deadLetterQueueKey: string, messageId: string, messageQueueName: string, @@ -1834,8 +1704,6 @@ declare module "@internal/redis" { messageQueue: string, concurrencyKey: string, envConcurrencyKey: string, - projectConcurrencyKey: string, - taskConcurrencyKey: string, messageId: string, masterQueues: string, callback?: Callback @@ -1846,8 +1714,6 @@ declare module "@internal/redis" { messageQueue: string, concurrencyKey: string, envConcurrencyKey: string, - projectConcurrencyKey: string, - taskConcurrencyKey: string, messageId: string, masterQueues: string, callback?: Callback diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.test.ts b/internal-packages/run-engine/src/run-queue/keyProducer.test.ts index b7cafb56f0..77ec4f50a6 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.test.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.test.ts @@ -163,73 +163,6 @@ describe("KeyProducer", () => { expect(key).toBe("{org:o1234}:proj:p1234:env:e1234:queue:task/task-name:currentConcurrency"); }); - it("taskIdentifierCurrentConcurrencyKeyPrefixFromQueue", () => { - const keyProducer = new RunQueueFullKeyProducer(); - const queueKey = keyProducer.queueKey( - { - id: "e1234", - type: "PRODUCTION", - maximumConcurrencyLimit: 10, - project: { id: "p1234" }, - organization: { id: "o1234" }, - }, - "task/task-name" - ); - const key = keyProducer.taskIdentifierCurrentConcurrencyKeyPrefixFromQueue(queueKey); - expect(key).toBe("{org:o1234}:proj:p1234:env:e1234:task:"); - }); - - it("taskIdentifierCurrentConcurrencyKeyFromQueue", () => { - const keyProducer = new RunQueueFullKeyProducer(); - const queueKey = keyProducer.queueKey( - { - id: "e1234", - type: "PRODUCTION", - maximumConcurrencyLimit: 10, - project: { id: "p1234" }, - organization: { id: "o1234" }, - }, - "task/task-name" - ); - const key = keyProducer.taskIdentifierCurrentConcurrencyKeyFromQueue(queueKey, "task-name"); - expect(key).toBe("{org:o1234}:proj:p1234:env:e1234:task:task-name"); - }); - - it("taskIdentifierCurrentConcurrencyKey", () => { - const keyProducer = new RunQueueFullKeyProducer(); - const key = keyProducer.taskIdentifierCurrentConcurrencyKey( - { - id: "e1234", - type: "PRODUCTION", - maximumConcurrencyLimit: 10, - project: { id: "p1234" }, - organization: { id: "o1234" }, - }, - "task-name" - ); - expect(key).toBe("{org:o1234}:proj:p1234:env:e1234:task:task-name"); - }); - - it("projectCurrentConcurrencyKey", () => { - const keyProducer = new RunQueueFullKeyProducer(); - const key = keyProducer.projectCurrentConcurrencyKey({ - id: "e1234", - type: "PRODUCTION", - maximumConcurrencyLimit: 10, - project: { id: "p1234" }, - organization: { id: "o1234" }, - }); - expect(key).toBe("{org:o1234}:proj:p1234:currentConcurrency"); - }); - - it("projectCurrentConcurrencyKeyFromQueue", () => { - const keyProducer = new RunQueueFullKeyProducer(); - const key = keyProducer.projectCurrentConcurrencyKeyFromQueue( - "{org:o1234}:proj:p1234:currentConcurrency" - ); - expect(key).toBe("{org:o1234}:proj:p1234:currentConcurrency"); - }); - it("disabledConcurrencyLimitKeyFromQueue", () => { const keyProducer = new RunQueueFullKeyProducer(); const queueKey = keyProducer.queueKey( diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.ts b/internal-packages/run-engine/src/run-queue/keyProducer.ts index a24bf8dbdd..4c4fd9dbbd 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.ts @@ -178,51 +178,6 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { } } - taskIdentifierCurrentConcurrencyKeyPrefixFromQueue(queue: string) { - const { orgId, envId, projectId } = this.descriptorFromQueue(queue); - - return `${[ - this.orgKeySection(orgId), - this.projKeySection(projectId), - this.envKeySection(envId), - constants.TASK_PART, - ] - .filter(Boolean) - .join(":")}:`; - } - - taskIdentifierCurrentConcurrencyKeyFromQueue(queue: string, taskIdentifier: string) { - return `${this.taskIdentifierCurrentConcurrencyKeyPrefixFromQueue(queue)}${taskIdentifier}`; - } - - taskIdentifierCurrentConcurrencyKey( - env: MinimalAuthenticatedEnvironment, - taskIdentifier: string - ): string { - return [ - this.orgKeySection(env.organization.id), - this.projKeySection(env.project.id), - this.envKeySection(env.id), - constants.TASK_PART, - taskIdentifier, - ].join(":"); - } - - projectCurrentConcurrencyKey(env: MinimalAuthenticatedEnvironment): string { - return [ - this.orgKeySection(env.organization.id), - this.projKeySection(env.project.id), - constants.CURRENT_CONCURRENCY_PART, - ].join(":"); - } - - projectCurrentConcurrencyKeyFromQueue(queue: string): string { - const { orgId, projectId } = this.descriptorFromQueue(queue); - return `${this.orgKeySection(orgId)}:${this.projKeySection(projectId)}:${ - constants.CURRENT_CONCURRENCY_PART - }`; - } - messageKeyPrefixFromQueue(queue: string) { const { orgId } = this.descriptorFromQueue(queue); return `${this.orgKeySection(orgId)}:${constants.MESSAGE_PART}:`; diff --git a/internal-packages/run-engine/src/run-queue/tests/ack.test.ts b/internal-packages/run-engine/src/run-queue/tests/ack.test.ts index b9b1be1776..3887dad533 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ack.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ack.test.ts @@ -87,15 +87,6 @@ describe("RunQueue.acknowledgeMessage", () => { const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); expect(envConcurrency).toBe(1); - const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); - expect(projectConcurrency).toBe(1); - - const taskConcurrency = await queue.currentConcurrencyOfTask( - authenticatedEnvDev, - messageDev.taskIdentifier - ); - expect(taskConcurrency).toBe(1); - // Acknowledge the message await queue.acknowledgeMessage(messageDev.orgId, messageDev.runId); @@ -108,15 +99,6 @@ describe("RunQueue.acknowledgeMessage", () => { const envConcurrencyAfter = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); expect(envConcurrencyAfter).toBe(0); - - const projectConcurrencyAfter = await queue.currentConcurrencyOfProject(authenticatedEnvDev); - expect(projectConcurrencyAfter).toBe(0); - - const taskConcurrencyAfter = await queue.currentConcurrencyOfTask( - authenticatedEnvDev, - messageDev.taskIdentifier - ); - expect(taskConcurrencyAfter).toBe(0); } finally { await queue.quit(); } diff --git a/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts b/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts index b29af519a0..59ac23b5de 100644 --- a/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts @@ -112,15 +112,6 @@ describe("RunQueue.dequeueMessageFromMasterQueue", () => { ); expect(envReserveConcurrency).toBe(0); - const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); - expect(projectConcurrency).toBe(0); - - const taskConcurrency = await queue.currentConcurrencyOfTask( - authenticatedEnvDev, - messageDev.taskIdentifier - ); - expect(taskConcurrency).toBe(0); - const dequeued = await queue.dequeueMessageFromMasterQueue("test_12345", envMasterQueue, 10); expect(dequeued.length).toBe(1); expect(dequeued[0].messageId).toEqual(messageDev.runId); @@ -143,15 +134,6 @@ describe("RunQueue.dequeueMessageFromMasterQueue", () => { ); expect(envReserveConcurrencyAfter).toBe(0); - const projectConcurrencyAfter = await queue.currentConcurrencyOfProject(authenticatedEnvDev); - expect(projectConcurrencyAfter).toBe(1); - - const taskConcurrencyAfter = await queue.currentConcurrencyOfTask( - authenticatedEnvDev, - messageDev.taskIdentifier - ); - expect(taskConcurrencyAfter).toBe(1); - //queue length const result3 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); expect(result3).toBe(0); diff --git a/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts b/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts index dee0bdaaf1..c4e1fadb44 100644 --- a/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts @@ -111,15 +111,6 @@ describe("RunQueue.enqueueMessage", () => { authenticatedEnvDev ); expect(envReserveConcurrency).toBe(0); - - const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); - expect(projectConcurrency).toBe(0); - - const taskConcurrency = await queue.currentConcurrencyOfTask( - authenticatedEnvDev, - messageDev.taskIdentifier - ); - expect(taskConcurrency).toBe(0); } finally { await queue.quit(); } @@ -199,15 +190,6 @@ describe("RunQueue.enqueueMessage", () => { authenticatedEnvDev ); expect(envReserveConcurrency).toBe(1); - - const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); - expect(projectConcurrency).toBe(0); - - const taskConcurrency = await queue.currentConcurrencyOfTask( - authenticatedEnvDev, - messageDev.taskIdentifier - ); - expect(taskConcurrency).toBe(0); } finally { await queue.quit(); } @@ -294,15 +276,6 @@ describe("RunQueue.enqueueMessage", () => { authenticatedEnvDev ); expect(envReserveConcurrency).toBe(1); - - const projectConcurrency = await queue.currentConcurrencyOfProject(authenticatedEnvDev); - expect(projectConcurrency).toBe(0); - - const taskConcurrency = await queue.currentConcurrencyOfTask( - authenticatedEnvDev, - messageDev.taskIdentifier - ); - expect(taskConcurrency).toBe(0); } finally { await queue.quit(); } diff --git a/internal-packages/run-engine/src/run-queue/tests/nack.test.ts b/internal-packages/run-engine/src/run-queue/tests/nack.test.ts index 67ec26b589..e57c5acec2 100644 --- a/internal-packages/run-engine/src/run-queue/tests/nack.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/nack.test.ts @@ -125,17 +125,6 @@ describe("RunQueue.nackMessage", () => { ); expect(envCurrentConcurrencyAfterNack).toBe(0); - const projectCurrentConcurrencyAfterNack = await queue.currentConcurrencyOfProject( - authenticatedEnvDev - ); - expect(projectCurrentConcurrencyAfterNack).toBe(0); - - const taskCurrentConcurrencyAfterNack = await queue.currentConcurrencyOfTask( - authenticatedEnvDev, - messageDev.taskIdentifier - ); - expect(taskCurrentConcurrencyAfterNack).toBe(0); - const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); expect(envQueueLength).toBe(1); diff --git a/internal-packages/run-engine/src/run-queue/types.ts b/internal-packages/run-engine/src/run-queue/types.ts index 563cececa8..d74bf5e382 100644 --- a/internal-packages/run-engine/src/run-queue/types.ts +++ b/internal-packages/run-engine/src/run-queue/types.ts @@ -71,16 +71,6 @@ export interface RunQueueKeyProducer { envConcurrencyLimitKeyFromQueue(queue: string): string; envCurrentConcurrencyKeyFromQueue(queue: string): string; - //task concurrency - taskIdentifierCurrentConcurrencyKey( - env: MinimalAuthenticatedEnvironment, - taskIdentifier: string - ): string; - taskIdentifierCurrentConcurrencyKeyPrefixFromQueue(queue: string): string; - taskIdentifierCurrentConcurrencyKeyFromQueue(queue: string, taskIdentifier: string): string; - //project concurrency - projectCurrentConcurrencyKey(env: MinimalAuthenticatedEnvironment): string; - projectCurrentConcurrencyKeyFromQueue(queue: string): string; //message payload messageKeyPrefixFromQueue(queue: string): string; messageKey(orgId: string, messageId: string): string; From fd9b0bf6763e2d5739e84787921fd7cf0515bc39 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 14 Mar 2025 16:13:09 +0000 Subject: [PATCH 17/38] WIP new reacquire concurrency system --- .../migration.sql | 4 + .../database/prisma/schema.prisma | 2 + .../run-engine/src/engine/index.ts | 3 + .../run-engine/src/engine/statuses.ts | 2 +- .../src/engine/{ => tests}/locking.test.ts | 0 .../run-engine/src/run-queue/errors.ts | 5 + .../run-engine/src/run-queue/index.ts | 123 +++-- .../tests/reacquireConcurrency.test.ts | 437 ++++++++++++++++++ .../tests/releaseConcurrency.test.ts | 152 ++++++ .../src/entryPoints/dev-run-controller.ts | 1 + .../src/entryPoints/managed-run-controller.ts | 1 + packages/core/src/v3/schemas/runEngine.ts | 1 + 12 files changed, 688 insertions(+), 43 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250314133612_add_queued_executing_status_to_snapshots/migration.sql rename internal-packages/run-engine/src/engine/{ => tests}/locking.test.ts (100%) create mode 100644 internal-packages/run-engine/src/run-queue/errors.ts create mode 100644 internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts create mode 100644 internal-packages/run-engine/src/run-queue/tests/releaseConcurrency.test.ts diff --git a/internal-packages/database/prisma/migrations/20250314133612_add_queued_executing_status_to_snapshots/migration.sql b/internal-packages/database/prisma/migrations/20250314133612_add_queued_executing_status_to_snapshots/migration.sql new file mode 100644 index 0000000000..307831bc06 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250314133612_add_queued_executing_status_to_snapshots/migration.sql @@ -0,0 +1,4 @@ +-- AlterEnum +ALTER TYPE "TaskRunExecutionStatus" +ADD + VALUE 'QUEUED_EXECUTING'; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 84d8c55702..94f5dcaede 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2012,6 +2012,8 @@ enum TaskRunExecutionStatus { RUN_CREATED /// Run is in the RunQueue QUEUED + /// Run is in the RunQueue, and is also executing. This happens when a run is continued cannot reacquire concurrency + QUEUED_EXECUTING /// Run has been pulled from the queue, but isn't executing yet PENDING_EXECUTING /// Run is executing on a worker diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 9f35cc2a52..e8dc83237a 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -3920,6 +3920,9 @@ export class RunEngine { case "QUEUED": { throw new NotImplementedError("There shouldn't be a heartbeat for QUEUED"); } + case "QUEUED_EXECUTING": { + throw new NotImplementedError("There shouldn't be a heartbeat for QUEUED_EXECUTING"); + } case "PENDING_EXECUTING": { //the run didn't start executing, we need to requeue it const run = await prisma.taskRun.findFirst({ diff --git a/internal-packages/run-engine/src/engine/statuses.ts b/internal-packages/run-engine/src/engine/statuses.ts index 71212b6e75..c21bcb105c 100644 --- a/internal-packages/run-engine/src/engine/statuses.ts +++ b/internal-packages/run-engine/src/engine/statuses.ts @@ -1,7 +1,7 @@ import { TaskRunExecutionStatus, TaskRunStatus } from "@trigger.dev/database"; export function isDequeueableExecutionStatus(status: TaskRunExecutionStatus): boolean { - const dequeuableExecutionStatuses: TaskRunExecutionStatus[] = ["QUEUED"]; + const dequeuableExecutionStatuses: TaskRunExecutionStatus[] = ["QUEUED", "QUEUED_EXECUTING"]; return dequeuableExecutionStatuses.includes(status); } diff --git a/internal-packages/run-engine/src/engine/locking.test.ts b/internal-packages/run-engine/src/engine/tests/locking.test.ts similarity index 100% rename from internal-packages/run-engine/src/engine/locking.test.ts rename to internal-packages/run-engine/src/engine/tests/locking.test.ts diff --git a/internal-packages/run-engine/src/run-queue/errors.ts b/internal-packages/run-engine/src/run-queue/errors.ts new file mode 100644 index 0000000000..eecebdab54 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/errors.ts @@ -0,0 +1,5 @@ +export class MessageNotFoundError extends Error { + constructor(messageId: string) { + super(`Message not found: ${messageId}`); + } +} diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 34d717c318..e30a00d2f1 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -29,6 +29,7 @@ import { type RedisOptions, type Result, } from "@internal/redis"; +import { MessageNotFoundError } from "./errors.js"; const SemanticAttributes = { QUEUE: "runqueue.queue", @@ -600,12 +601,9 @@ export class RunQueue { }); return this.redis.releaseConcurrency( - this.keys.messageKey(orgId, messageId), - message.queue, this.keys.currentConcurrencyKeyFromQueue(message.queue), this.keys.envCurrentConcurrencyKeyFromQueue(message.queue), - messageId, - JSON.stringify(message.masterQueues) + messageId ); }, { @@ -626,11 +624,7 @@ export class RunQueue { const message = await this.readMessage(orgId, messageId); if (!message) { - this.logger.log(`[${this.name}].acknowledgeMessage() message not found`, { - messageId, - service: this.name, - }); - return; + throw new MessageNotFoundError(messageId); } span.setAttributes({ @@ -640,14 +634,25 @@ export class RunQueue { [SemanticAttributes.CONCURRENCY_KEY]: message.concurrencyKey, }); - return this.redis.reacquireConcurrency( - this.keys.messageKey(orgId, messageId), - message.queue, - this.keys.currentConcurrencyKeyFromQueue(message.queue), - this.keys.envCurrentConcurrencyKeyFromQueue(message.queue), + const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); + const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); + const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(message.queue); + const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(message.queue); + const queueConcurrencyLimitKey = this.keys.concurrencyLimitKeyFromQueue(message.queue); + const envConcurrencyLimitKey = this.keys.envConcurrencyLimitKeyFromQueue(message.queue); + + const result = await this.redis.reacquireConcurrency( + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + queueReserveConcurrencyKey, + envReserveConcurrencyKey, + queueConcurrencyLimitKey, + envConcurrencyLimitKey, messageId, - JSON.stringify(message.masterQueues) + String(this.options.defaultEnvConcurrency) ); + + return !!result; }, { kind: SpanKind.CONSUMER, @@ -1517,41 +1522,76 @@ redis.call('SREM', envCurrentConcurrencyKey, messageId) }); this.redis.defineCommand("releaseConcurrency", { - numberOfKeys: 5, + numberOfKeys: 2, lua: ` -- Keys: -local messageKey = KEYS[1] -local messageQueue = KEYS[2] -local concurrencyKey = KEYS[3] -local envCurrentConcurrencyKey = KEYS[4] -local envQueueKey = KEYS[5] +local queueCurrentConcurrencyKey = KEYS[1] +local envCurrentConcurrencyKey = KEYS[2] -- Args: local messageId = ARGV[1] -- Update the concurrency keys -if concurrencyKey ~= "" then - redis.call('SREM', concurrencyKey, messageId) -end +redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) `, }); this.redis.defineCommand("reacquireConcurrency", { - numberOfKeys: 4, + numberOfKeys: 6, lua: ` -- Keys: -local messageKey = KEYS[1] -local messageQueue = KEYS[2] -local concurrencyKey = KEYS[3] -local envCurrentConcurrencyKey = KEYS[4] +local queueCurrentConcurrencyKey = KEYS[1] +local envCurrentConcurrencyKey = KEYS[2] +local queueReserveConcurrencyKey = KEYS[3] +local envReserveConcurrencyKey = KEYS[4] +local queueConcurrencyLimitKey = KEYS[5] +local envConcurrencyLimitKey = KEYS[6] -- Args: local messageId = ARGV[1] +local defaultEnvConcurrencyLimit = ARGV[2] + +-- Check if the message is already in either current concurrency set +local isInQueueConcurrency = redis.call('SISMEMBER', queueCurrentConcurrencyKey, messageId) == 1 +local isInEnvConcurrency = redis.call('SISMEMBER', envCurrentConcurrencyKey, messageId) == 1 + +-- If it's already in both sets, we're done +if isInQueueConcurrency and isInEnvConcurrency then + return true +end + +-- Check current env concurrency against the limit +local envCurrentConcurrency = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') +local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) +local envReserveConcurrency = tonumber(redis.call('SCARD', envReserveConcurrencyKey) or '0') +local totalEnvConcurrencyLimit = envConcurrencyLimit + envReserveConcurrency + +if envCurrentConcurrency >= totalEnvConcurrencyLimit then + return false +end + +-- Check current queue concurrency against the limit +local queueCurrentConcurrency = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') +local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) +local queueReserveConcurrency = tonumber(redis.call('SCARD', queueReserveConcurrencyKey) or '0') +local totalQueueConcurrencyLimit = queueConcurrencyLimit + queueReserveConcurrency + +if queueCurrentConcurrency >= totalQueueConcurrencyLimit then + return false +end -- Update the concurrency keys -redis.call('SADD', concurrencyKey, messageId) +redis.call('SADD', queueCurrentConcurrencyKey, messageId) redis.call('SADD', envCurrentConcurrencyKey, messageId) + +-- Remove the message from the queue reserve concurrency set +redis.call('SREM', queueReserveConcurrencyKey, messageId) + +-- Remove the message from the env reserve concurrency set +redis.call('SREM', envReserveConcurrencyKey, messageId) + +return true `, }); @@ -1700,24 +1740,23 @@ declare module "@internal/redis" { ): Result; releaseConcurrency( - messageKey: string, - messageQueue: string, - concurrencyKey: string, - envConcurrencyKey: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, messageId: string, - masterQueues: string, callback?: Callback ): Result; reacquireConcurrency( - messageKey: string, - messageQueue: string, - concurrencyKey: string, - envConcurrencyKey: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueReserveConcurrencyKey: string, + envReserveConcurrencyKey: string, + queueConcurrencyLimitKey: string, + envConcurrencyLimitKey: string, messageId: string, - masterQueues: string, - callback?: Callback - ): Result; + defaultEnvConcurrencyLimit: string, + callback?: Callback + ): Result; updateGlobalConcurrencyLimits( envConcurrencyLimitKey: string, diff --git a/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts b/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts new file mode 100644 index 0000000000..4f776ce7aa --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts @@ -0,0 +1,437 @@ +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import { InputPayload } from "../types.js"; +import { MessageNotFoundError } from "../errors.js"; + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + enableRebalancing: false, + logger: new Logger("RunQueue", "warn"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys: new RunQueueFullKeyProducer(), +}; + +const authenticatedEnvProd = { + id: "e1234", + type: "PRODUCTION" as const, + maximumConcurrencyLimit: 10, + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +const messageProd: InputPayload = { + runId: "r1234", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e1234", + environmentType: "PRODUCTION", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, +}; + +describe("RunQueue.reacquireConcurrency", () => { + redisTest( + "It should return true if we can reacquire the concurrency", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + await queue.updateEnvConcurrencyLimits({ + ...authenticatedEnvProd, + maximumConcurrencyLimit: 1, + }); + + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: messageProd, + masterQueues: "main", + }); + + const messages = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 10); + expect(messages.length).toBe(1); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + + // First, release the concurrency + await queue.releaseConcurrency(authenticatedEnvProd.organization.id, messageProd.runId); + + //reacquire the concurrency + const result = await queue.reacquireConcurrency( + authenticatedEnvProd.organization.id, + messageProd.runId + ); + expect(result).toBe(true); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "It should return true if the run is already being counted as concurrency", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + await queue.updateEnvConcurrencyLimits({ + ...authenticatedEnvProd, + maximumConcurrencyLimit: 1, + }); + + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: messageProd, + masterQueues: "main", + }); + + const messages = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 10); + expect(messages.length).toBe(1); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + + //reacquire the concurrency + const result = await queue.reacquireConcurrency( + authenticatedEnvProd.organization.id, + messageProd.runId + ); + expect(result).toBe(true); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "It should return true if the run is already being counted as concurrency", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + await queue.updateEnvConcurrencyLimits({ + ...authenticatedEnvProd, + maximumConcurrencyLimit: 1, + }); + + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: messageProd, + masterQueues: "main", + }); + + const messages = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 10); + expect(messages.length).toBe(1); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + + //reacquire the concurrency + const result = await queue.reacquireConcurrency( + authenticatedEnvProd.organization.id, + messageProd.runId + ); + expect(result).toBe(true); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "It should return true and remove the run from the reserve concurrency set if it's already in the current concurrency set", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + await queue.updateEnvConcurrencyLimits({ + ...authenticatedEnvProd, + maximumConcurrencyLimit: 1, + }); + + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: messageProd, + masterQueues: "main", + }); + + const messages = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 1); + expect(messages.length).toBe(1); + expect(messages[0].message.runId).toBe(messageProd.runId); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + + // Now we need to enqueue a second message message, adding the first to the reserve concurrency set + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: { + ...messageProd, + runId: "r1235", + queue: "task/my-task-2", + }, + masterQueues: "main", + reserveConcurrency: { + messageId: messageProd.runId, + recursiveQueue: false, // It will only be in the env reserve concurrency set + }, + }); + + expect(await queue.reserveConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + expect(await queue.reserveConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 0 + ); + + // Now we can dequeue the second message + const message2 = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 1); + expect(message2.length).toBe(1); + expect(message2[0].message.runId).toBe("r1235"); + + // Now lets assert the concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, "task/my-task-2")).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(2); + expect(await queue.reserveConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + expect(await queue.reserveConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 0 + ); + + //reacquire the concurrency + const result = await queue.reacquireConcurrency( + authenticatedEnvProd.organization.id, + messageProd.runId + ); + expect(result).toBe(true); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, "task/my-task-2")).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(2); + expect(await queue.reserveConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + expect(await queue.reserveConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 0 + ); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "It should false if the run is not in the current concurrency set and there is no capacity in the environment", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + await queue.updateEnvConcurrencyLimits({ + ...authenticatedEnvProd, + maximumConcurrencyLimit: 1, + }); + + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: messageProd, + masterQueues: "main", + }); + + const messages = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 1); + expect(messages.length).toBe(1); + expect(messages[0].message.runId).toBe(messageProd.runId); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + + // Enqueue a second message + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: { + ...messageProd, + runId: "r1235", + queue: "task/my-task-2", + }, + masterQueues: "main", + }); + + //reacquire the concurrency + const result = await queue.reacquireConcurrency( + authenticatedEnvProd.organization.id, + "r1235" + ); + expect(result).toBe(false); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, "task/my-task-2")).toBe( + 0 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + expect(await queue.reserveConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(0); + expect(await queue.reserveConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 0 + ); + } finally { + await queue.quit(); + } + } + ); + + redisTest("It should throw an error if the message is not found", async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + await expect( + queue.reacquireConcurrency(authenticatedEnvProd.organization.id, "r1235") + ).rejects.toThrow(MessageNotFoundError); + } finally { + await queue.quit(); + } + }); +}); diff --git a/internal-packages/run-engine/src/run-queue/tests/releaseConcurrency.test.ts b/internal-packages/run-engine/src/run-queue/tests/releaseConcurrency.test.ts new file mode 100644 index 0000000000..47be728c60 --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/releaseConcurrency.test.ts @@ -0,0 +1,152 @@ +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import { InputPayload } from "../types.js"; + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + enableRebalancing: false, + logger: new Logger("RunQueue", "warn"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys: new RunQueueFullKeyProducer(), +}; + +const authenticatedEnvProd = { + id: "e1234", + type: "PRODUCTION" as const, + maximumConcurrencyLimit: 10, + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +const messageProd: InputPayload = { + runId: "r1234", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e1234", + environmentType: "PRODUCTION", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, +}; + +describe("RunQueue.releaseConcurrency", () => { + redisTest( + "It should release the concurrency on the queue and the env", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: messageProd, + masterQueues: "main", + }); + + const messages = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 10); + expect(messages.length).toBe(1); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + + //release the concurrency + await queue.releaseConcurrency(authenticatedEnvProd.organization.id, messageProd.runId); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 0 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(0); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "it shouldn't affect the current concurrency if the run hasn't been dequeued", + async ({ redisContainer }) => { + const queue = new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + + try { + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: messageProd, + masterQueues: "main", + }); + + await queue.enqueueMessage({ + env: authenticatedEnvProd, + message: { ...messageProd, runId: "r1235" }, + masterQueues: "main", + }); + + const messages = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 1); + expect(messages.length).toBe(1); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + + //release the concurrency + await queue.releaseConcurrency(authenticatedEnvProd.organization.id, "r1235"); + + //concurrencies + expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( + 1 + ); + expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); + } finally { + await queue.quit(); + } + } + ); +}); diff --git a/packages/cli-v3/src/entryPoints/dev-run-controller.ts b/packages/cli-v3/src/entryPoints/dev-run-controller.ts index 68c051d8f0..8807915d53 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-controller.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-controller.ts @@ -449,6 +449,7 @@ export class DevRunController { return; } case "RUN_CREATED": + case "QUEUED_EXECUTING": case "QUEUED": { logger.debug("Status change not handled", { status: snapshot.executionStatus }); return; diff --git a/packages/cli-v3/src/entryPoints/managed-run-controller.ts b/packages/cli-v3/src/entryPoints/managed-run-controller.ts index 5eab5839f7..540a599f53 100644 --- a/packages/cli-v3/src/entryPoints/managed-run-controller.ts +++ b/packages/cli-v3/src/entryPoints/managed-run-controller.ts @@ -627,6 +627,7 @@ class ManagedRunController { return; } case "RUN_CREATED": + case "QUEUED_EXECUTING": case "QUEUED": { console.log("Status change not handled", { status: snapshot.executionStatus }); return; diff --git a/packages/core/src/v3/schemas/runEngine.ts b/packages/core/src/v3/schemas/runEngine.ts index db0c9221cd..392d29d823 100644 --- a/packages/core/src/v3/schemas/runEngine.ts +++ b/packages/core/src/v3/schemas/runEngine.ts @@ -6,6 +6,7 @@ import type * as DB_TYPES from "@trigger.dev/database"; export const TaskRunExecutionStatus = { RUN_CREATED: "RUN_CREATED", QUEUED: "QUEUED", + QUEUED_EXECUTING: "QUEUED_EXECUTING", PENDING_EXECUTING: "PENDING_EXECUTING", EXECUTING: "EXECUTING", EXECUTING_WITH_WAITPOINTS: "EXECUTING_WITH_WAITPOINTS", From 61c0834aae673019b32ded73510258a0983dcf62 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 14 Mar 2025 16:45:15 +0000 Subject: [PATCH 18/38] Implement reserve concurrency clearing when the child run is acked --- .../run-engine/src/engine/index.ts | 23 ++- .../run-engine/src/run-queue/index.ts | 147 +++++++++++++----- .../src/run-queue/tests/ack.test.ts | 12 +- 3 files changed, 136 insertions(+), 46 deletions(-) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index e8dc83237a..45d998c3aa 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -1540,6 +1540,7 @@ export class RunEngine { createdAt: true, completedAt: true, taskEventStore: true, + parentTaskRunId: true, runtimeEnvironment: { select: { organizationId: true, @@ -1559,7 +1560,9 @@ export class RunEngine { }); //remove it from the queue and release concurrency - await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); + await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId, { + messageId: run.parentTaskRunId ?? undefined, + }); //if executing, we need to message the worker to cancel the run and put it into `PENDING_CANCEL` status if (isExecuting(latestSnapshot.executionStatus)) { @@ -2790,10 +2793,13 @@ export class RunEngine { createdAt: true, completedAt: true, taskEventStore: true, + parentTaskRunId: true, }, }); - await this.runQueue.acknowledgeMessage(updatedRun.runtimeEnvironment.organizationId, runId); + await this.runQueue.acknowledgeMessage(updatedRun.runtimeEnvironment.organizationId, runId, { + messageId: updatedRun.parentTaskRunId ?? undefined, + }); if (!updatedRun.associatedWaitpoint) { throw new ServiceValidationError("No associated waitpoint found", 400); @@ -2931,10 +2937,14 @@ export class RunEngine { createdAt: true, completedAt: true, taskEventStore: true, + parentTaskRunId: true, }, }); const newSnapshot = await getLatestExecutionSnapshot(prisma, runId); - await this.runQueue.acknowledgeMessage(run.project.organizationId, runId); + + await this.runQueue.acknowledgeMessage(run.project.organizationId, runId, { + messageId: run.parentTaskRunId ?? undefined, + }); // We need to manually emit this as we created the final snapshot as part of the task run update this.eventBus.emit("executionSnapshotCreated", { @@ -3248,6 +3258,7 @@ export class RunEngine { attemptNumber: true, spanId: true, batchId: true, + parentTaskRunId: true, associatedWaitpoint: { select: { id: true, @@ -3282,13 +3293,15 @@ export class RunEngine { throw new ServiceValidationError("No associated waitpoint found", 400); } + await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId, { + messageId: run.parentTaskRunId ?? undefined, + }); + await this.completeWaitpoint({ id: run.associatedWaitpoint.id, output: { value: JSON.stringify(error), isError: true }, }); - await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); - this.eventBus.emit("runFailed", { time: failedAt, run: { diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index e30a00d2f1..a4b9e98d73 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -481,18 +481,20 @@ export class RunQueue { * This is done when the run is in a final state. * @param messageId */ - public async acknowledgeMessage(orgId: string, messageId: string) { + public async acknowledgeMessage( + orgId: string, + messageId: string, + reserveConcurrency?: { + messageId?: string; + } + ) { return this.#trace( "acknowledgeMessage", async (span) => { const message = await this.readMessage(orgId, messageId); if (!message) { - this.logger.log(`[${this.name}].acknowledgeMessage() message not found`, { - messageId, - service: this.name, - }); - return; + throw new MessageNotFoundError(messageId); } span.setAttributes({ @@ -504,6 +506,7 @@ export class RunQueue { await this.#callAcknowledgeMessage({ message, + reserveConcurrency, }); }, { @@ -1006,7 +1009,15 @@ export class RunQueue { }; } - async #callAcknowledgeMessage({ message }: { message: OutputPayload }) { + async #callAcknowledgeMessage({ + message, + reserveConcurrency, + }: { + message: OutputPayload; + reserveConcurrency?: { + messageId?: string; + }; + }) { const messageId = message.runId; const messageKey = this.keys.messageKey(message.orgId, messageId); const messageQueue = message.queue; @@ -1026,22 +1037,37 @@ export class RunQueue { service: this.name, }); - const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(messageQueue); - const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(messageQueue); + if (!reserveConcurrency?.messageId) { + return this.redis.acknowledgeMessage( + messageKey, + messageQueue, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + envQueueKey, + messageId, + messageQueue, + JSON.stringify(masterQueues), + this.options.redis.keyPrefix ?? "" + ); + } else { + const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(messageQueue); + const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(messageQueue); - return this.redis.acknowledgeMessage( - messageKey, - messageQueue, - queueCurrentConcurrencyKey, - envCurrentConcurrencyKey, - envQueueKey, - queueReserveConcurrencyKey, - envReserveConcurrencyKey, - messageId, - messageQueue, - JSON.stringify(masterQueues), - this.options.redis.keyPrefix ?? "" - ); + return this.redis.acknowledgeMessageWithReserveConcurrency( + messageKey, + messageQueue, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + envQueueKey, + envReserveConcurrencyKey, + queueReserveConcurrencyKey, + messageId, + messageQueue, + JSON.stringify(masterQueues), + this.options.redis.keyPrefix ?? "", + reserveConcurrency.messageId + ); + } } async #callNackMessage({ message, retryAt }: { message: OutputPayload; retryAt?: number }) { @@ -1393,12 +1419,52 @@ return {messageId, messageScore, messagePayload} -- Return message details }); this.redis.defineCommand("acknowledgeMessage", { + numberOfKeys: 5, + lua: ` +-- Keys: +local messageKey = KEYS[1] +local messageQueueKey = KEYS[2] +local queueCurrentConcurrencyKey = KEYS[3] +local envCurrentConcurrencyKey = KEYS[4] +local envQueueKey = KEYS[5] + +-- Args: +local messageId = ARGV[1] +local messageQueueName = ARGV[2] +local parentQueues = cjson.decode(ARGV[3]) +local keyPrefix = ARGV[4] + +-- Remove the message from the message key +redis.call('DEL', messageKey) + +-- Remove the message from the queue +redis.call('ZREM', messageQueueKey, messageId) +redis.call('ZREM', envQueueKey, messageId) + +-- Rebalance the parent queues +local earliestMessage = redis.call('ZRANGE', messageQueueKey, 0, 0, 'WITHSCORES') +for _, parentQueue in ipairs(parentQueues) do + local prefixedParentQueue = keyPrefix .. parentQueue + if #earliestMessage == 0 then + redis.call('ZREM', prefixedParentQueue, messageQueueName) + else + redis.call('ZADD', prefixedParentQueue, earliestMessage[2], messageQueueName) + end +end + +-- Update the concurrency keys +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +`, + }); + + this.redis.defineCommand("acknowledgeMessageWithReserveConcurrency", { numberOfKeys: 7, lua: ` -- Keys: local messageKey = KEYS[1] -local messageQueue = KEYS[2] -local concurrencyKey = KEYS[3] +local messageQueueKey = KEYS[2] +local queueCurrentConcurrencyKey = KEYS[3] local envCurrentConcurrencyKey = KEYS[4] local envQueueKey = KEYS[5] local queueReserveConcurrencyKey = KEYS[6] @@ -1409,16 +1475,17 @@ local messageId = ARGV[1] local messageQueueName = ARGV[2] local parentQueues = cjson.decode(ARGV[3]) local keyPrefix = ARGV[4] +local reserveMessageId = ARGV[5] -- Remove the message from the message key redis.call('DEL', messageKey) -- Remove the message from the queue -redis.call('ZREM', messageQueue, messageId) +redis.call('ZREM', messageQueueKey, messageId) redis.call('ZREM', envQueueKey, messageId) -- Rebalance the parent queues -local earliestMessage = redis.call('ZRANGE', messageQueue, 0, 0, 'WITHSCORES') +local earliestMessage = redis.call('ZRANGE', messageQueueKey, 0, 0, 'WITHSCORES') for _, parentQueue in ipairs(parentQueues) do local prefixedParentQueue = keyPrefix .. parentQueue if #earliestMessage == 0 then @@ -1429,12 +1496,12 @@ for _, parentQueue in ipairs(parentQueues) do end -- Update the concurrency keys -redis.call('SREM', concurrencyKey, messageId) +redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -- Clear reserve concurrency -redis.call('SREM', queueReserveConcurrencyKey, messageId) -redis.call('SREM', envReserveConcurrencyKey, messageId) +redis.call('SREM', queueReserveConcurrencyKey, reserveMessageId) +redis.call('SREM', envReserveConcurrencyKey, reserveMessageId) `, }); @@ -1585,12 +1652,6 @@ end redis.call('SADD', queueCurrentConcurrencyKey, messageId) redis.call('SADD', envCurrentConcurrencyKey, messageId) --- Remove the message from the queue reserve concurrency set -redis.call('SREM', queueReserveConcurrencyKey, messageId) - --- Remove the message from the env reserve concurrency set -redis.call('SREM', envReserveConcurrencyKey, messageId) - return true `, }); @@ -1701,12 +1762,26 @@ declare module "@internal/redis" { concurrencyKey: string, envConcurrencyKey: string, envQueueKey: string, - queueReserveConcurrencyKey: string, + messageId: string, + messageQueueName: string, + masterQueues: string, + keyPrefix: string, + callback?: Callback + ): Result; + + acknowledgeMessageWithReserveConcurrency( + messageKey: string, + messageQueue: string, + concurrencyKey: string, + envConcurrencyKey: string, + envQueueKey: string, envReserveConcurrencyKey: string, + queueReserveConcurrencyKey: string, messageId: string, messageQueueName: string, masterQueues: string, keyPrefix: string, + reserveMessageId: string, callback?: Callback ): Result; diff --git a/internal-packages/run-engine/src/run-queue/tests/ack.test.ts b/internal-packages/run-engine/src/run-queue/tests/ack.test.ts index 3887dad533..6805984d96 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ack.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ack.test.ts @@ -274,7 +274,7 @@ describe("RunQueue.acknowledgeMessage", () => { }); redisTest( - "acknowledging a message clears reserve concurrency sets even when not dequeued", + "acknowledging a message clears env reserve concurrency when recursive queue is false", async ({ redisContainer }) => { const queue = new RunQueue({ ...testOptions, @@ -302,8 +302,8 @@ describe("RunQueue.acknowledgeMessage", () => { message: messageDev, masterQueues: ["main", envMasterQueue], reserveConcurrency: { - messageId: messageDev.runId, - recursiveQueue: true, + messageId: "r1235", + recursiveQueue: false, }, }); @@ -317,7 +317,7 @@ describe("RunQueue.acknowledgeMessage", () => { authenticatedEnvDev, messageDev.queue ); - expect(queueReserveConcurrency).toBe(1); + expect(queueReserveConcurrency).toBe(0); // Verify message is in queue before acknowledging const queueLengthBefore = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); @@ -327,7 +327,9 @@ describe("RunQueue.acknowledgeMessage", () => { expect(envQueueLengthBefore).toBe(1); // Acknowledge the message before dequeuing - await queue.acknowledgeMessage(messageDev.orgId, messageDev.runId); + await queue.acknowledgeMessage(messageDev.orgId, messageDev.runId, { + messageId: "r1235", + }); // Verify reserve concurrency is cleared const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( From 2048b72288141c3a4c06e2f32f94ef8a1f130d96 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 15 Mar 2025 09:05:05 +0000 Subject: [PATCH 19/38] go to QUEUE_EXECUTING state if reacquiring concurrency doesn't work --- .../run-engine/src/engine/index.ts | 68 ++++++++++++------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 45d998c3aa..0284c2459e 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -3346,6 +3346,7 @@ export class RunEngine { timestamp: number; tx?: PrismaClientOrTransaction; snapshot?: { + status?: Extract; description?: string; }; batchId?: string; @@ -3362,9 +3363,9 @@ export class RunEngine { return await this.runLock.lock([run.id], 5000, async (signal) => { const newSnapshot = await this.#createExecutionSnapshot(prisma, { - run: run, + run, snapshot: { - executionStatus: "QUEUED", + executionStatus: snapshot?.status ?? "QUEUED", description: snapshot?.description ?? "Run was QUEUED", }, batchId, @@ -3531,29 +3532,50 @@ export class RunEngine { //run is still executing, send a message to the worker if (isExecuting(snapshot.executionStatus)) { - const newSnapshot = await this.#createExecutionSnapshot(this.prisma, { - run: { - id: runId, - status: snapshot.runStatus, - attemptNumber: snapshot.attemptNumber, - }, - snapshot: { - executionStatus: "EXECUTING", - description: "Run was continued, whilst still executing.", - }, - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - batchId: snapshot.batchId ?? undefined, - completedWaitpoints: blockingWaitpoints.map((b) => ({ - id: b.waitpoint.id, - index: b.batchIndex ?? undefined, - })), - }); + const result = await this.runQueue.reacquireConcurrency( + run.runtimeEnvironment.organization.id, + runId + ); - //we reacquire the concurrency if it's still running because we're not going to be dequeuing (which also does this) - await this.runQueue.reacquireConcurrency(run.runtimeEnvironment.organization.id, runId); + if (result) { + const newSnapshot = await this.#createExecutionSnapshot(this.prisma, { + run: { + id: runId, + status: snapshot.runStatus, + attemptNumber: snapshot.attemptNumber, + }, + snapshot: { + executionStatus: "EXECUTING", + description: "Run was continued, whilst still executing.", + }, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + batchId: snapshot.batchId ?? undefined, + completedWaitpoints: blockingWaitpoints.map((b) => ({ + id: b.waitpoint.id, + index: b.batchIndex ?? undefined, + })), + }); - await this.#sendNotificationToWorker({ runId, snapshot: newSnapshot }); + await this.#sendNotificationToWorker({ runId, snapshot: newSnapshot }); + } else { + // Because we cannot reacquire the concurrency, we need to enqueue the run again + // and because the run is still executing, we need to set the status to QUEUED_EXECUTING + await this.#enqueueRun({ + run, + env: run.runtimeEnvironment, + timestamp: run.createdAt.getTime() - run.priorityMs, + snapshot: { + status: "QUEUED_EXECUTING", + description: "Run can continue, but is waiting for concurrency", + }, + batchId: snapshot.batchId ?? undefined, + completedWaitpoints: blockingWaitpoints.map((b) => ({ + id: b.waitpoint.id, + index: b.batchIndex ?? undefined, + })), + }); + } } else { if (snapshot.executionStatus !== "RUN_CREATED" && !snapshot.checkpointId) { // TODO: We're screwed, should probably fail the run immediately From 063651c7b853ee7bc0f6c914eaf472f24c585628 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 13:12:26 +0000 Subject: [PATCH 20/38] Upgrade vitest in the run-engine package --- internal-packages/run-engine/package.json | 12 +- internal-packages/run-engine/vitest.config.ts | 3 + pnpm-lock.yaml | 524 ++++++++++++++---- 3 files changed, 418 insertions(+), 121 deletions(-) diff --git a/internal-packages/run-engine/package.json b/internal-packages/run-engine/package.json index 450ea1cb06..ebf084dd53 100644 --- a/internal-packages/run-engine/package.json +++ b/internal-packages/run-engine/package.json @@ -19,23 +19,25 @@ "@internal/tracing": "workspace:*", "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", + "@unkey/cache": "^1.5.0", "assert-never": "^1.2.1", "nanoid": "^3.3.4", "redlock": "5.0.0-beta.2", - "zod": "3.23.8", - "@unkey/cache": "^1.5.0", - "seedrandom": "^3.0.5" + "seedrandom": "^3.0.5", + "zod": "3.23.8" }, "devDependencies": { "@internal/testcontainers": "workspace:*", - "vitest": "^1.4.0", "@types/seedrandom": "^3.0.8", - "rimraf": "6.0.1" + "@vitest/coverage-v8": "^3.0.8", + "rimraf": "6.0.1", + "vitest": "^3.0.8" }, "scripts": { "clean": "rimraf dist", "typecheck": "tsc --noEmit -p tsconfig.build.json", "test": "vitest --sequence.concurrent=false --no-file-parallelism", + "test:coverage": "vitest --sequence.concurrent=false --no-file-parallelism --coverage.enabled", "build": "pnpm run clean && tsc -p tsconfig.build.json", "dev": "tsc --watch -p tsconfig.build.json" } diff --git a/internal-packages/run-engine/vitest.config.ts b/internal-packages/run-engine/vitest.config.ts index 044760d6b6..0c2cb6798f 100644 --- a/internal-packages/run-engine/vitest.config.ts +++ b/internal-packages/run-engine/vitest.config.ts @@ -13,5 +13,8 @@ export default defineConfig({ }, }, testTimeout: 60_000, + coverage: { + provider: "v8", + }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 371734e70e..c7ca9c675d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1011,12 +1011,15 @@ importers: '@types/seedrandom': specifier: ^3.0.8 version: 3.0.8 + '@vitest/coverage-v8': + specifier: ^3.0.8 + version: 3.0.8(vitest@3.0.8) rimraf: specifier: 6.0.1 version: 6.0.1 vitest: - specifier: ^1.4.0 - version: 1.6.0(@types/node@20.14.14) + specifier: ^3.0.8 + version: 3.0.8(@types/node@20.14.14) internal-packages/testcontainers: dependencies: @@ -3512,18 +3515,18 @@ packages: resolution: {integrity: sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==} engines: {node: '>=6.9.0'} dependencies: - '@ampproject/remapping': 2.2.1 + '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.24.7 '@babel/generator': 7.24.7 '@babel/helper-compilation-targets': 7.25.2 '@babel/helper-module-transforms': 7.25.2(@babel/core@7.24.5) '@babel/helpers': 7.25.6 - '@babel/parser': 7.24.7 + '@babel/parser': 7.26.8 '@babel/template': 7.24.7 '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 convert-source-map: 2.0.0 - debug: 4.3.7 + debug: 4.4.0 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3582,7 +3585,7 @@ packages: resolution: {integrity: sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 jsesc: 2.5.2 @@ -3591,7 +3594,7 @@ packages: resolution: {integrity: sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.26.8 '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 jsesc: 2.5.2 @@ -3612,7 +3615,7 @@ packages: resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-builder-binary-assignment-operator-visitor@7.18.9: @@ -3620,7 +3623,7 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/helper-explode-assignable-expression': 7.18.6 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-compilation-targets@7.22.15: @@ -3712,7 +3715,7 @@ packages: '@babel/core': 7.22.17 '@babel/helper-compilation-targets': 7.22.15 '@babel/helper-plugin-utils': 7.24.0 - debug: 4.3.7 + debug: 4.4.0 lodash.debounce: 4.0.8 resolve: 1.22.8 semver: 6.3.1 @@ -3728,7 +3731,7 @@ packages: '@babel/core': 7.22.17 '@babel/helper-compilation-targets': 7.22.15 '@babel/helper-plugin-utils': 7.24.0 - debug: 4.3.7 + debug: 4.4.0 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -3748,13 +3751,13 @@ packages: resolution: {integrity: sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 /@babel/helper-explode-assignable-expression@7.18.6: resolution: {integrity: sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-function-name@7.22.5: @@ -3762,7 +3765,7 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.22.15 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-function-name@7.23.0: @@ -3770,7 +3773,7 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-function-name@7.24.7: @@ -3778,47 +3781,47 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 /@babel/helper-hoist-variables@7.22.5: resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-hoist-variables@7.24.7: resolution: {integrity: sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 /@babel/helper-member-expression-to-functions@7.21.5: resolution: {integrity: sha512-nIcGfgwpH2u4n9GG1HpStW5Ogx7x7ekiFHbjjFRKXbn5zUvqO9ZgotCO4x1aNbKn/x/xOUaXEhyNHCwtFCpxWg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-member-expression-to-functions@7.23.0: resolution: {integrity: sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-module-imports@7.22.15: resolution: {integrity: sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 /@babel/helper-module-imports@7.24.7: resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} engines: {node: '>=6.9.0'} dependencies: '@babel/traverse': 7.25.6 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 transitivePeerDependencies: - supports-color dev: false @@ -3844,7 +3847,7 @@ packages: '@babel/helper-module-imports': 7.22.15 '@babel/helper-simple-access': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 - '@babel/helper-validator-identifier': 7.24.7 + '@babel/helper-validator-identifier': 7.25.9 /@babel/helper-module-transforms@7.25.2(@babel/core@7.24.5): resolution: {integrity: sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==} @@ -3855,7 +3858,7 @@ packages: '@babel/core': 7.24.5 '@babel/helper-module-imports': 7.24.7 '@babel/helper-simple-access': 7.24.7 - '@babel/helper-validator-identifier': 7.24.7 + '@babel/helper-validator-identifier': 7.25.9 '@babel/traverse': 7.25.6 transitivePeerDependencies: - supports-color @@ -3879,14 +3882,14 @@ packages: resolution: {integrity: sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-optimise-call-expression@7.22.5: resolution: {integrity: sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-plugin-utils@7.22.5: @@ -3909,7 +3912,7 @@ packages: '@babel/helper-annotate-as-pure': 7.22.5 '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-wrap-function': 7.20.5 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 transitivePeerDependencies: - supports-color dev: true @@ -3923,7 +3926,7 @@ packages: '@babel/helper-optimise-call-expression': 7.22.5 '@babel/template': 7.24.7 '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 transitivePeerDependencies: - supports-color dev: true @@ -3944,14 +3947,14 @@ packages: resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 /@babel/helper-simple-access@7.24.7: resolution: {integrity: sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==} engines: {node: '>=6.9.0'} dependencies: '@babel/traverse': 7.25.6 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 transitivePeerDependencies: - supports-color dev: false @@ -3960,37 +3963,32 @@ packages: resolution: {integrity: sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-skip-transparent-expression-wrappers@7.22.5: resolution: {integrity: sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/helper-split-export-declaration@7.22.6: resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 /@babel/helper-split-export-declaration@7.24.7: resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 /@babel/helper-string-parser@7.24.7: resolution: {integrity: sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==} engines: {node: '>=6.9.0'} - /@babel/helper-string-parser@7.24.8: - resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} - engines: {node: '>=6.9.0'} - dev: false - /@babel/helper-string-parser@7.25.9: resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} engines: {node: '>=6.9.0'} @@ -4024,7 +4022,7 @@ packages: '@babel/helper-function-name': 7.24.7 '@babel/template': 7.24.7 '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 transitivePeerDependencies: - supports-color dev: true @@ -4035,7 +4033,7 @@ packages: dependencies: '@babel/template': 7.22.15 '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 transitivePeerDependencies: - supports-color @@ -4044,7 +4042,7 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.25.0 - '@babel/types': 7.25.6 + '@babel/types': 7.26.8 dev: false /@babel/helpers@7.26.7: @@ -4059,7 +4057,7 @@ packages: resolution: {integrity: sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-validator-identifier': 7.24.7 + '@babel/helper-validator-identifier': 7.25.9 chalk: 2.4.2 js-tokens: 4.0.0 @@ -4067,7 +4065,7 @@ packages: resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-validator-identifier': 7.24.7 + '@babel/helper-validator-identifier': 7.25.9 chalk: 2.4.2 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -4085,7 +4083,7 @@ packages: engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.26.8 dev: false /@babel/parser@7.24.5: @@ -4093,7 +4091,7 @@ packages: engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: false /@babel/parser@7.24.7: @@ -4103,14 +4101,6 @@ packages: dependencies: '@babel/types': 7.24.7 - /@babel/parser@7.25.6: - resolution: {integrity: sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==} - engines: {node: '>=6.0.0'} - hasBin: true - dependencies: - '@babel/types': 7.25.6 - dev: false - /@babel/parser@7.26.8: resolution: {integrity: sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==} engines: {node: '>=6.0.0'} @@ -4748,7 +4738,7 @@ packages: '@babel/helper-hoist-variables': 7.22.5 '@babel/helper-module-transforms': 7.22.17(@babel/core@7.22.17) '@babel/helper-plugin-utils': 7.24.0 - '@babel/helper-validator-identifier': 7.24.7 + '@babel/helper-validator-identifier': 7.25.9 dev: true /@babel/plugin-transform-modules-umd@7.18.6(@babel/core@7.22.17): @@ -4845,7 +4835,7 @@ packages: '@babel/helper-module-imports': 7.22.15 '@babel/helper-plugin-utils': 7.24.0 '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.17) - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 dev: true /@babel/plugin-transform-react-pure-annotations@7.18.6(@babel/core@7.22.17): @@ -5078,7 +5068,7 @@ packages: '@babel/helper-plugin-utils': 7.24.0 '@babel/plugin-proposal-unicode-property-regex': 7.18.6(@babel/core@7.22.17) '@babel/plugin-transform-dotall-regex': 7.18.6(@babel/core@7.22.17) - '@babel/types': 7.24.7 + '@babel/types': 7.26.8 esutils: 2.0.3 dev: true @@ -5141,24 +5131,24 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.22.13 - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 + '@babel/parser': 7.26.8 + '@babel/types': 7.26.8 /@babel/template@7.24.7: resolution: {integrity: sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==} engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.24.7 - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 + '@babel/parser': 7.26.8 + '@babel/types': 7.26.8 /@babel/template@7.25.0: resolution: {integrity: sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==} engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.24.7 - '@babel/parser': 7.25.6 - '@babel/types': 7.25.6 + '@babel/parser': 7.26.8 + '@babel/types': 7.26.8 dev: false /@babel/template@7.26.8: @@ -5198,9 +5188,9 @@ packages: '@babel/helper-function-name': 7.24.7 '@babel/helper-hoist-variables': 7.24.7 '@babel/helper-split-export-declaration': 7.24.7 - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 - debug: 4.3.7 + '@babel/parser': 7.26.8 + '@babel/types': 7.26.8 + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5211,10 +5201,10 @@ packages: dependencies: '@babel/code-frame': 7.24.7 '@babel/generator': 7.25.6 - '@babel/parser': 7.25.6 + '@babel/parser': 7.26.8 '@babel/template': 7.25.0 - '@babel/types': 7.25.6 - debug: 4.3.7 + '@babel/types': 7.26.8 + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5239,8 +5229,8 @@ packages: resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-string-parser': 7.24.7 - '@babel/helper-validator-identifier': 7.24.7 + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 to-fast-properties: 2.0.0 /@babel/types@7.24.7: @@ -5251,15 +5241,6 @@ packages: '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 - /@babel/types@7.25.6: - resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.24.8 - '@babel/helper-validator-identifier': 7.24.7 - to-fast-properties: 2.0.0 - dev: false - /@babel/types@7.26.8: resolution: {integrity: sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==} engines: {node: '>=6.9.0'} @@ -5270,6 +5251,11 @@ packages: /@balena/dockerignore@1.0.2: resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + /@bcoe/v8-coverage@1.0.2: + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + dev: true + /@bufbuild/protobuf@1.10.0: resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} dev: false @@ -7929,6 +7915,11 @@ packages: minipass: 7.1.2 dev: false + /@istanbuljs/schema@0.1.3: + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + dev: true + /@jest/schemas@29.6.3: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9830,7 +9821,7 @@ packages: engines: {node: '>=18'} hasBin: true dependencies: - debug: 4.3.7 + debug: 4.4.0 extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.4.0 @@ -17817,6 +17808,32 @@ packages: resolution: {integrity: sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==} dev: true + /@vitest/coverage-v8@3.0.8(vitest@3.0.8): + resolution: {integrity: sha512-y7SAKsQirsEJ2F8bulBck4DoluhI2EEgTimHd6EEUgJBGKy9tC25cpywh1MH4FvDGoG2Unt7+asVd1kj4qOSAw==} + peerDependencies: + '@vitest/browser': 3.0.8 + vitest: 3.0.8 + peerDependenciesMeta: + '@vitest/browser': + optional: true + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + debug: 4.4.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.17 + magicast: 0.3.5 + std-env: 3.8.1 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.0.8(@types/node@20.14.14) + transitivePeerDependencies: + - supports-color + dev: true + /@vitest/expect@0.28.5: resolution: {integrity: sha512-gqTZwoUTwepwGIatnw4UKpQfnoyV0Z9Czn9+Lo2/jLIt4/AXLTn+oVZxlQ7Ng8bzcNkR+3DqLJ08kNr8jRmdNQ==} dependencies: @@ -17850,12 +17867,44 @@ packages: tinyrainbow: 1.2.0 dev: true + /@vitest/expect@3.0.8: + resolution: {integrity: sha512-Xu6TTIavTvSSS6LZaA3EebWFr6tsoXPetOWNMOlc7LO88QVVBwq2oQWBoDiLCN6YTvNYsGSjqOO8CAdjom5DCQ==} + dependencies: + '@vitest/spy': 3.0.8 + '@vitest/utils': 3.0.8 + chai: 5.2.0 + tinyrainbow: 2.0.0 + dev: true + + /@vitest/mocker@3.0.8(vite@5.2.7): + resolution: {integrity: sha512-n3LjS7fcW1BCoF+zWZxG7/5XvuYH+lsFg+BDwwAz0arIwHQJFUEsKBQ0BLU49fCxuM/2HSeBPHQD8WjgrxMfow==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + dependencies: + '@vitest/spy': 3.0.8 + estree-walker: 3.0.3 + magic-string: 0.30.17 + vite: 5.2.7(@types/node@20.14.14) + dev: true + /@vitest/pretty-format@2.0.5: resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} dependencies: tinyrainbow: 1.2.0 dev: true + /@vitest/pretty-format@3.0.8: + resolution: {integrity: sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==} + dependencies: + tinyrainbow: 2.0.0 + dev: true + /@vitest/runner@0.28.5: resolution: {integrity: sha512-NKkHtLB+FGjpp5KmneQjTcPLWPTDfB7ie+MmF1PnUBf/tGe2OjGxWyB62ySYZ25EYp9krR5Bw0YPLS/VWh1QiA==} dependencies: @@ -17887,6 +17936,13 @@ packages: pathe: 1.1.2 dev: true + /@vitest/runner@3.0.8: + resolution: {integrity: sha512-c7UUw6gEcOzI8fih+uaAXS5DwjlBaCJUo7KJ4VvJcjL95+DSR1kova2hFuRt3w41KZEFcOEiq098KkyrjXeM5w==} + dependencies: + '@vitest/utils': 3.0.8 + pathe: 2.0.3 + dev: true + /@vitest/snapshot@1.4.0: resolution: {integrity: sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==} dependencies: @@ -17911,6 +17967,14 @@ packages: pathe: 1.1.2 dev: true + /@vitest/snapshot@3.0.8: + resolution: {integrity: sha512-x8IlMGSEMugakInj44nUrLSILh/zy1f2/BgH0UeHpNyOocG18M9CWVIFBaXPt8TrqVZWmcPjwfG/ht5tnpba8A==} + dependencies: + '@vitest/pretty-format': 3.0.8 + magic-string: 0.30.17 + pathe: 2.0.3 + dev: true + /@vitest/spy@0.28.5: resolution: {integrity: sha512-7if6rsHQr9zbmvxN7h+gGh2L9eIIErgf8nSKYDlg07HHimCxp4H6I/X/DPXktVPPLQfiZ1Cw2cbDIx9fSqDjGw==} dependencies: @@ -17935,6 +17999,12 @@ packages: tinyspy: 3.0.0 dev: true + /@vitest/spy@3.0.8: + resolution: {integrity: sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==} + dependencies: + tinyspy: 3.0.2 + dev: true + /@vitest/utils@0.28.5: resolution: {integrity: sha512-UyZdYwdULlOa4LTUSwZ+Paz7nBHGTT72jKwdFSV4IjHF1xsokp+CabMdhjvVhYwkLfO88ylJT46YMilnkSARZA==} dependencies: @@ -17972,6 +18042,14 @@ packages: tinyrainbow: 1.2.0 dev: true + /@vitest/utils@3.0.8: + resolution: {integrity: sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==} + dependencies: + '@vitest/pretty-format': 3.0.8 + loupe: 3.1.3 + tinyrainbow: 2.0.0 + dev: true + /@vue/compiler-core@3.4.38: resolution: {integrity: sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==} dependencies: @@ -17996,7 +18074,7 @@ packages: '@vue/compiler-ssr': 3.4.38 '@vue/shared': 3.4.38 estree-walker: 2.0.2 - magic-string: 0.30.11 + magic-string: 0.30.17 postcss: 8.4.44 source-map-js: 1.2.0 @@ -18298,7 +18376,7 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} dependencies: - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: false @@ -18307,7 +18385,7 @@ packages: resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} engines: {node: '>= 14'} dependencies: - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -18315,7 +18393,7 @@ packages: resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} engines: {node: '>= 14'} dependencies: - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -19118,7 +19196,7 @@ packages: resolution: {integrity: sha512-fdRxJkQ9MUSEi4jH2DcV3FAPFktk0wefilxrwNyUuWpoWawQGN7G7cB+fOYTtFfI6XNkFgwqJ/D3G18BoJJ/jg==} engines: {node: '>= 10.0.0'} dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.26.8 dev: false /bail@2.0.2: @@ -19530,7 +19608,7 @@ packages: /capnp-ts@0.7.0: resolution: {integrity: sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g==} dependencies: - debug: 4.3.7 + debug: 4.4.0 tslib: 2.6.2 transitivePeerDependencies: - supports-color @@ -19556,7 +19634,7 @@ packages: assertion-error: 1.1.0 check-error: 1.0.3 deep-eql: 4.1.3 - get-func-name: 2.0.0 + get-func-name: 2.0.2 loupe: 2.3.7 pathval: 1.1.1 type-detect: 4.0.8 @@ -19586,6 +19664,17 @@ packages: pathval: 2.0.0 dev: true + /chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.1 + pathval: 2.0.0 + dev: true + /chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -20890,7 +20979,7 @@ packages: resolution: {integrity: sha512-f0ReSURdM3pcKPNS30mxOHSbaFLcknGmQjwSfmbcdOw1XWKXVhukM3NJHhr7NpY9BIyyWQb0EBo3KQvvuU5egQ==} engines: {node: '>= 8.0'} dependencies: - debug: 4.3.7 + debug: 4.4.0 readable-stream: 3.6.0 split-ca: 1.0.1 ssh2: 1.16.0 @@ -21319,6 +21408,10 @@ packages: /es-module-lexer@1.3.1: resolution: {integrity: sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==} + /es-module-lexer@1.6.0: + resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + dev: true + /es-object-atoms@1.0.0: resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} engines: {node: '>= 0.4'} @@ -22510,6 +22603,11 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + /expect-type@1.2.0: + resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==} + engines: {node: '>=12.0.0'} + dev: true + /exponential-backoff@3.1.1: resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==} dev: false @@ -22622,7 +22720,7 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true dependencies: - debug: 4.3.7 + debug: 4.4.0 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -23162,10 +23260,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - /get-func-name@2.0.0: - resolution: {integrity: sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==} - dev: true - /get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} dev: true @@ -23276,7 +23370,7 @@ packages: dependencies: basic-ftp: 5.0.3 data-uri-to-buffer: 5.0.1 - debug: 4.3.7 + debug: 4.4.0 fs-extra: 8.1.0 transitivePeerDependencies: - supports-color @@ -23340,6 +23434,18 @@ packages: path-scurry: 1.10.1 dev: false + /glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + dependencies: + foreground-child: 3.1.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 1.11.1 + dev: true + /glob@11.0.0: resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} engines: {node: 20 || >=22} @@ -23508,7 +23614,7 @@ packages: '@types/node': 20.14.14 '@types/semver': 7.5.1 chalk: 4.1.2 - debug: 4.3.7 + debug: 4.4.0 interpret: 3.1.1 semver: 7.6.3 tslib: 2.6.2 @@ -23762,7 +23868,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -23771,7 +23877,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.1 - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: false @@ -23790,7 +23896,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: false @@ -23800,7 +23906,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.1 - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -23809,7 +23915,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.1 - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: false @@ -24448,6 +24554,39 @@ packages: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} dev: false + /istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + dev: true + + /istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + dev: true + + /istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + debug: 4.4.0 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + dev: true + /jackspeak@2.3.6: resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} engines: {node: '>=14'} @@ -24456,6 +24595,14 @@ packages: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + /jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: true + /jackspeak@4.0.1: resolution: {integrity: sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==} engines: {node: 20 || >=22} @@ -25066,6 +25213,10 @@ packages: get-func-name: 2.0.2 dev: true + /loupe@3.1.3: + resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + dev: true + /lowercase-keys@1.0.1: resolution: {integrity: sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==} engines: {node: '>=0.10.0'} @@ -25080,6 +25231,10 @@ packages: resolution: {integrity: sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==} engines: {node: 14 || >=16.14} + /lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + dev: true + /lru-cache@11.0.0: resolution: {integrity: sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==} engines: {node: 20 || >=22} @@ -25145,6 +25300,12 @@ packages: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + dev: true + + /magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 /magic-string@0.30.8: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} @@ -25160,6 +25321,14 @@ packages: source-map-js: 1.2.0 dev: false + /magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + dependencies: + '@babel/parser': 7.26.8 + '@babel/types': 7.26.8 + source-map-js: 1.2.0 + dev: true + /make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -25722,7 +25891,7 @@ packages: resolution: {integrity: sha512-6Mj0yHLdUZjHnOPgr5xfWIMqMWS12zDN6iws9SLuSz76W8jTtAv24MN4/CL7gJrl5vtxGInkkqDv/JIoRsQOvA==} dependencies: '@types/debug': 4.1.12 - debug: 4.3.7 + debug: 4.4.0 decode-named-character-reference: 1.0.2 micromark-core-commonmark: 1.0.6 micromark-factory-space: 1.0.0 @@ -25879,6 +26048,13 @@ packages: dependencies: brace-expansion: 2.0.1 + /minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options@4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -27097,7 +27273,7 @@ packages: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.1 - debug: 4.3.7 + debug: 4.4.0 get-uri: 6.0.1 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.5 @@ -27248,6 +27424,14 @@ packages: lru-cache: 10.0.1 minipass: 7.1.2 + /path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + dev: true + /path-scurry@2.0.0: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} @@ -27290,6 +27474,10 @@ packages: /pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + /pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + dev: true + /pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} dev: true @@ -28175,7 +28363,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.1 - debug: 4.3.7 + debug: 4.4.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.5 lru-cache: 7.18.3 @@ -28232,7 +28420,7 @@ packages: dependencies: '@puppeteer/browsers': 2.4.0 chromium-bidi: 0.6.5(devtools-protocol@0.0.1342118) - debug: 4.3.7 + debug: 4.4.0 devtools-protocol: 0.0.1342118 typed-query-selector: 2.12.0 ws: 8.18.0 @@ -29276,7 +29464,7 @@ packages: remix-auth: ^3.6.0 dependencies: '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) - debug: 4.3.7 + debug: 4.4.0 remix-auth: 3.6.0(@remix-run/react@2.1.0)(@remix-run/server-runtime@2.1.0) transitivePeerDependencies: - supports-color @@ -29396,7 +29584,7 @@ packages: resolution: {integrity: sha512-OScOjQjrrjhAdFpQmnkE/qbIBGCRFhQB/YaJhcC3CPOlmhe7llnW46Ac1J5+EjcNXOTnDdpF96Erw/yedsGksQ==} engines: {node: '>=8.6.0'} dependencies: - debug: 4.3.7 + debug: 4.4.0 module-details-from-path: 1.0.3 resolve: 1.22.8 transitivePeerDependencies: @@ -29795,7 +29983,7 @@ packages: resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==} engines: {node: '>= 18'} dependencies: - debug: 4.3.7 + debug: 4.4.0 destroy: 1.2.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -30175,7 +30363,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.1 - debug: 4.3.7 + debug: 4.4.0 socks: 2.8.3 transitivePeerDependencies: - supports-color @@ -30783,7 +30971,7 @@ packages: estree-walker: 3.0.3 is-reference: 3.0.1 locate-character: 3.0.0 - magic-string: 0.30.11 + magic-string: 0.30.17 periscopic: 3.1.0 /swr@2.2.5(react@18.3.1): @@ -31161,6 +31349,15 @@ packages: commander: 2.20.3 source-map-support: 0.5.21 + /test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + dev: true + /testcontainers@10.13.1: resolution: {integrity: sha512-JBbOhxmygj/ouH/47GnoVNt+c55Telh/45IjVxEbDoswsLchVmJiuKiw/eF6lE5i7LN+/99xsrSCttI3YRtirg==} dependencies: @@ -31262,7 +31459,6 @@ packages: /tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - dev: false /tinyglobby@0.2.10: resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==} @@ -31302,11 +31498,21 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} dev: true + /tinypool@1.0.2: + resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + engines: {node: ^18.0.0 || >=20.0.0} + dev: true + /tinyrainbow@1.2.0: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} dev: true + /tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + dev: true + /tinyspy@1.0.2: resolution: {integrity: sha512-bSGlgwLBYf7PnUsQ6WOc6SJ3pGOcd+d8AA6EUnLDDM0kWEstC1JIlSZA3UNliDXhd9ABoS7hiRBDCu+XP/sf1Q==} engines: {node: '>=14.0.0'} @@ -31322,6 +31528,11 @@ packages: engines: {node: '>=14.0.0'} dev: true + /tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + dev: true + /tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} dependencies: @@ -32530,7 +32741,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.3.7 + debug: 4.4.0 mlly: 1.7.1 pathe: 1.1.2 picocolors: 1.1.1 @@ -32554,7 +32765,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.3.7 + debug: 4.4.0 mlly: 1.7.1 pathe: 1.1.2 picocolors: 1.1.1 @@ -32578,7 +32789,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.3.7 + debug: 4.4.0 pathe: 1.1.2 picocolors: 1.1.1 vite: 5.2.7(@types/node@20.14.14) @@ -32620,7 +32831,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.3.7 + debug: 4.4.0 pathe: 1.1.2 tinyrainbow: 1.2.0 vite: 5.2.7(@types/node@20.14.14) @@ -32635,6 +32846,27 @@ packages: - terser dev: true + /vite-node@3.0.8(@types/node@20.14.14): + resolution: {integrity: sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.4.0 + es-module-lexer: 1.6.0 + pathe: 2.0.3 + vite: 5.2.7(@types/node@20.14.14) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /vite-tsconfig-paths@4.0.5(typescript@5.5.4): resolution: {integrity: sha512-/L/eHwySFYjwxoYt1WRJniuK/jPv+WGwgRGBYx3leciR5wBeqntQpUE6Js6+TJemChc+ter7fDBKieyEWDx4yQ==} dependencies: @@ -33045,6 +33277,66 @@ packages: - terser dev: true + /vitest@3.0.8(@types/node@20.14.14): + resolution: {integrity: sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.0.8 + '@vitest/ui': 3.0.8 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/node': 20.14.14 + '@vitest/expect': 3.0.8 + '@vitest/mocker': 3.0.8(vite@5.2.7) + '@vitest/pretty-format': 3.0.8 + '@vitest/runner': 3.0.8 + '@vitest/snapshot': 3.0.8 + '@vitest/spy': 3.0.8 + '@vitest/utils': 3.0.8 + chai: 5.2.0 + debug: 4.4.0 + expect-type: 1.2.0 + magic-string: 0.30.17 + pathe: 2.0.3 + std-env: 3.8.1 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.0.2 + tinyrainbow: 2.0.0 + vite: 5.2.7(@types/node@20.14.14) + vite-node: 3.0.8(@types/node@20.14.14) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /vue@3.4.38(typescript@5.5.4): resolution: {integrity: sha512-f0ZgN+mZ5KFgVv9wz0f4OgVKukoXtS3nwET4c2vLBGQR50aI8G0cqbFtLlX9Yiyg3LFGBitruPHt2PxwTduJEw==} peerDependencies: From 8c66ec3189632594631c0abbb1fa3e3eed233713 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 13:40:06 +0000 Subject: [PATCH 21/38] Remove reserve concurrency system from run queue --- ...dmin.api.v1.environments.$environmentId.ts | 9 +- .../engine/tests/reserveConcurrency.test.ts | 585 ------------------ .../run-queue/fairQueueSelectionStrategy.ts | 25 +- .../run-engine/src/run-queue/index.ts | 544 ++-------------- .../run-engine/src/run-queue/keyProducer.ts | 45 -- .../src/run-queue/tests/ack.test.ts | 198 ------ .../dequeueMessageFromMasterQueue.test.ts | 182 +----- .../run-queue/tests/enqueueMessage.test.ts | 417 +------------ .../fairQueueSelectionStrategy.test.ts | 74 +-- .../run-queue/{ => tests}/keyProducer.test.ts | 2 +- .../src/run-queue/tests/nack.test.ts | 16 - .../tests/reacquireConcurrency.test.ts | 109 ---- .../run-engine/src/run-queue/types.ts | 7 - references/test-tasks/src/utils.ts | 2 - 14 files changed, 69 insertions(+), 2146 deletions(-) delete mode 100644 internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts rename internal-packages/run-engine/src/run-queue/{ => tests}/fairQueueSelectionStrategy.test.ts (94%) rename internal-packages/run-engine/src/run-queue/{ => tests}/keyProducer.test.ts (99%) diff --git a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts index 49483a9678..f448c5b5ac 100644 --- a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts +++ b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts @@ -115,7 +115,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const concurrencyLimit = await engine.runQueue.getEnvConcurrencyLimit(environment); const currentConcurrency = await engine.runQueue.currentConcurrencyOfEnvironment(environment); - const reserveConcurrency = await engine.runQueue.reserveConcurrencyOfEnvironment(environment); if (searchParams.queue) { const queueConcurrencyLimit = await engine.runQueue.getQueueConcurrencyLimit( @@ -126,21 +125,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) { environment, searchParams.queue ); - const queueReserveConcurrency = await engine.runQueue.reserveConcurrencyOfQueue( - environment, - searchParams.queue - ); return json({ id: environment.id, concurrencyLimit, currentConcurrency, - reserveConcurrency, queueConcurrencyLimit, queueCurrentConcurrency, - queueReserveConcurrency, }); } - return json({ id: environment.id, concurrencyLimit, currentConcurrency, reserveConcurrency }); + return json({ id: environment.id, concurrencyLimit, currentConcurrency }); } diff --git a/internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts b/internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts deleted file mode 100644 index 97b6319353..0000000000 --- a/internal-packages/run-engine/src/engine/tests/reserveConcurrency.test.ts +++ /dev/null @@ -1,585 +0,0 @@ -import { - assertNonNullable, - containerTest, - setupAuthenticatedEnvironment, - setupBackgroundWorker, -} from "@internal/testcontainers"; -import { trace } from "@internal/tracing"; -import { expect } from "vitest"; -import { RunEngine } from "../index.js"; -import { setTimeout } from "node:timers/promises"; -import { TaskRunErrorCodes } from "@trigger.dev/core/v3/schemas"; - -vi.setConfig({ testTimeout: 60_000 }); - -describe("Reserve concurrency", () => { - containerTest( - "triggerAndWait reserves concurrency on the environment when triggering a child task on a different queue", - 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, - }, - tracer: trace.getTracer("test", "0.0.0"), - }); - - try { - await engine.runQueue.updateEnvConcurrencyLimits({ - ...authenticatedEnvironment, - maximumConcurrencyLimit: 1, - }); - - const parentTask = "parent-task"; - const childTask = "child-task"; - - //create background worker - await setupBackgroundWorker(prisma, authenticatedEnvironment, [parentTask, childTask]); - - //trigger the run - const parentRun = await engine.trigger( - { - number: 1, - friendlyId: "run_p1234", - environment: authenticatedEnvironment, - taskIdentifier: parentTask, - payload: "{}", - payloadType: "application/json", - context: {}, - traceContext: {}, - traceId: "t12345", - spanId: "s12345", - masterQueue: "main", - queueName: `task/${parentTask}`, - isTest: false, - tags: [], - }, - prisma - ); - - //dequeue parent - const dequeued = await engine.dequeueFromMasterQueue({ - consumerId: "test_12345", - masterQueue: parentRun.masterQueue, - maxRunCount: 10, - }); - - //create an attempt - const initialExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); - assertNonNullable(initialExecutionData); - const attemptResult = await engine.startRunAttempt({ - runId: parentRun.id, - snapshotId: initialExecutionData.snapshot.id, - }); - - const childRun = await engine.trigger( - { - number: 1, - friendlyId: "run_c1234", - environment: authenticatedEnvironment, - taskIdentifier: childTask, - payload: "{}", - payloadType: "application/json", - context: {}, - traceContext: {}, - traceId: "t12345", - spanId: "s12345", - masterQueue: "main", - queueName: `task/${childTask}`, - isTest: false, - tags: [], - resumeParentOnCompletion: true, - parentTaskRunId: parentRun.id, - }, - prisma - ); - - const childExecutionData = await engine.getRunExecutionData({ runId: childRun.id }); - assertNonNullable(childExecutionData); - expect(childExecutionData.snapshot.executionStatus).toBe("QUEUED"); - - const parentExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); - assertNonNullable(parentExecutionData); - expect(parentExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); - - //check the waitpoint blocking the parent run - const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: parentRun.id, - }, - include: { - waitpoint: true, - }, - }); - assertNonNullable(runWaitpoint); - expect(runWaitpoint.waitpoint.type).toBe("RUN"); - expect(runWaitpoint.waitpoint.completedByTaskRunId).toBe(childRun.id); - - //dequeue the child run - const dequeuedChild = await engine.dequeueFromMasterQueue({ - consumerId: "test_12345", - masterQueue: childRun.masterQueue, - maxRunCount: 10, - }); - - expect(dequeuedChild.length).toBe(1); - - //start the child run - const childAttempt = await engine.startRunAttempt({ - runId: childRun.id, - snapshotId: dequeuedChild[0].snapshot.id, - }); - - // complete the child run - await engine.completeRunAttempt({ - runId: childRun.id, - snapshotId: childAttempt.snapshot.id, - completion: { - id: childRun.id, - ok: true, - output: '{"foo":"bar"}', - outputType: "application/json", - }, - }); - - //child snapshot - const childExecutionDataAfter = await engine.getRunExecutionData({ runId: childRun.id }); - assertNonNullable(childExecutionDataAfter); - expect(childExecutionDataAfter.snapshot.executionStatus).toBe("FINISHED"); - - const waitpointAfter = await prisma.waitpoint.findFirst({ - where: { - id: runWaitpoint.waitpointId, - }, - }); - expect(waitpointAfter?.completedAt).not.toBeNull(); - expect(waitpointAfter?.status).toBe("COMPLETED"); - expect(waitpointAfter?.output).toBe('{"foo":"bar"}'); - - await setTimeout(500); - - const runWaitpointAfter = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: parentRun.id, - }, - include: { - waitpoint: true, - }, - }); - expect(runWaitpointAfter).toBeNull(); - - //parent snapshot - const parentExecutionDataAfter = await engine.getRunExecutionData({ runId: parentRun.id }); - assertNonNullable(parentExecutionDataAfter); - expect(parentExecutionDataAfter.snapshot.executionStatus).toBe("EXECUTING"); - expect(parentExecutionDataAfter.completedWaitpoints?.length).toBe(1); - expect(parentExecutionDataAfter.completedWaitpoints![0].id).toBe(runWaitpoint.waitpointId); - expect(parentExecutionDataAfter.completedWaitpoints![0].completedByTaskRun?.id).toBe( - childRun.id - ); - expect(parentExecutionDataAfter.completedWaitpoints![0].output).toBe('{"foo":"bar"}'); - } finally { - engine.quit(); - } - } - ); - - containerTest( - "triggerAndWait reserves concurrency on the environment and the queue when triggering a child task on the same queue", - 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, - }, - tracer: trace.getTracer("test", "0.0.0"), - }); - - try { - await engine.runQueue.updateEnvConcurrencyLimits({ - ...authenticatedEnvironment, - maximumConcurrencyLimit: 1, - }); - - const parentTask = "parent-task"; - const childTask = "child-task"; - - //create background worker - await setupBackgroundWorker(prisma, authenticatedEnvironment, [parentTask, childTask]); - - //trigger the run - const parentRun = await engine.trigger( - { - number: 1, - friendlyId: "run_p1234", - environment: authenticatedEnvironment, - taskIdentifier: parentTask, - payload: "{}", - payloadType: "application/json", - context: {}, - traceContext: {}, - traceId: "t12345", - spanId: "s12345", - masterQueue: "main", - queueName: "shared-queue", - queue: { - concurrencyLimit: 1, - }, - isTest: false, - tags: [], - }, - prisma - ); - - //dequeue parent - const dequeued = await engine.dequeueFromMasterQueue({ - consumerId: "test_12345", - masterQueue: parentRun.masterQueue, - maxRunCount: 10, - }); - - expect(dequeued.length).toBe(1); - - //create an attempt - const initialExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); - assertNonNullable(initialExecutionData); - const attemptResult = await engine.startRunAttempt({ - runId: parentRun.id, - snapshotId: initialExecutionData.snapshot.id, - }); - - expect(attemptResult).toBeDefined(); - - const childRun = await engine.trigger( - { - number: 1, - friendlyId: "run_c1234", - environment: authenticatedEnvironment, - taskIdentifier: childTask, - payload: "{}", - payloadType: "application/json", - context: {}, - traceContext: {}, - traceId: "t12345", - spanId: "s12345", - masterQueue: "main", - queueName: "shared-queue", - queue: { - concurrencyLimit: 1, - }, - isTest: false, - tags: [], - resumeParentOnCompletion: true, - parentTaskRunId: parentRun.id, - }, - prisma - ); - - const childExecutionData = await engine.getRunExecutionData({ runId: childRun.id }); - assertNonNullable(childExecutionData); - expect(childExecutionData.snapshot.executionStatus).toBe("QUEUED"); - - const parentExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); - assertNonNullable(parentExecutionData); - expect(parentExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); - - //check the waitpoint blocking the parent run - const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: parentRun.id, - }, - include: { - waitpoint: true, - }, - }); - assertNonNullable(runWaitpoint); - expect(runWaitpoint.waitpoint.type).toBe("RUN"); - expect(runWaitpoint.waitpoint.completedByTaskRunId).toBe(childRun.id); - - //dequeue the child run - const dequeuedChild = await engine.dequeueFromMasterQueue({ - consumerId: "test_12345", - masterQueue: childRun.masterQueue, - maxRunCount: 10, - }); - - expect(dequeuedChild.length).toBe(1); - - //start the child run - const childAttempt = await engine.startRunAttempt({ - runId: childRun.id, - snapshotId: dequeuedChild[0].snapshot.id, - }); - - // complete the child run - await engine.completeRunAttempt({ - runId: childRun.id, - snapshotId: childAttempt.snapshot.id, - completion: { - id: childRun.id, - ok: true, - output: '{"foo":"bar"}', - outputType: "application/json", - }, - }); - - //child snapshot - const childExecutionDataAfter = await engine.getRunExecutionData({ runId: childRun.id }); - assertNonNullable(childExecutionDataAfter); - expect(childExecutionDataAfter.snapshot.executionStatus).toBe("FINISHED"); - - const waitpointAfter = await prisma.waitpoint.findFirst({ - where: { - id: runWaitpoint.waitpointId, - }, - }); - expect(waitpointAfter?.completedAt).not.toBeNull(); - expect(waitpointAfter?.status).toBe("COMPLETED"); - expect(waitpointAfter?.output).toBe('{"foo":"bar"}'); - - await setTimeout(500); - - const runWaitpointAfter = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: parentRun.id, - }, - include: { - waitpoint: true, - }, - }); - expect(runWaitpointAfter).toBeNull(); - - //parent snapshot - const parentExecutionDataAfter = await engine.getRunExecutionData({ runId: parentRun.id }); - assertNonNullable(parentExecutionDataAfter); - expect(parentExecutionDataAfter.snapshot.executionStatus).toBe("EXECUTING"); - expect(parentExecutionDataAfter.completedWaitpoints?.length).toBe(1); - expect(parentExecutionDataAfter.completedWaitpoints![0].id).toBe(runWaitpoint.waitpointId); - expect(parentExecutionDataAfter.completedWaitpoints![0].completedByTaskRun?.id).toBe( - childRun.id - ); - expect(parentExecutionDataAfter.completedWaitpoints![0].output).toBe('{"foo":"bar"}'); - } finally { - engine.quit(); - } - } - ); - - containerTest( - "triggerAndWait fails with recursive deadlock error when there is no more reserve concurrency left when triggering a child task on the same queue", - 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, - }, - tracer: trace.getTracer("test", "0.0.0"), - }); - - try { - await engine.runQueue.updateEnvConcurrencyLimits({ - ...authenticatedEnvironment, - maximumConcurrencyLimit: 1, - }); - - const parentTask = "parent-task"; - const childTask = "child-task"; - - //create background worker - await setupBackgroundWorker(prisma, authenticatedEnvironment, [parentTask, childTask]); - - //trigger the run - const parentRun = await engine.trigger( - { - number: 1, - friendlyId: "run_p1234", - environment: authenticatedEnvironment, - taskIdentifier: parentTask, - payload: "{}", - payloadType: "application/json", - context: {}, - traceContext: {}, - traceId: "t12345", - spanId: "s12345", - masterQueue: "main", - queueName: "shared-queue", - queue: { - concurrencyLimit: 1, - }, - isTest: false, - tags: [], - }, - prisma - ); - - //dequeue parent - const dequeued = await engine.dequeueFromMasterQueue({ - consumerId: "test_12345", - masterQueue: parentRun.masterQueue, - maxRunCount: 10, - }); - - expect(dequeued.length).toBe(1); - - //create an attempt - const initialExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); - assertNonNullable(initialExecutionData); - const attemptResult = await engine.startRunAttempt({ - runId: parentRun.id, - snapshotId: initialExecutionData.snapshot.id, - }); - - expect(attemptResult).toBeDefined(); - - const childRun = await engine.trigger( - { - number: 1, - friendlyId: "run_c1234", - environment: authenticatedEnvironment, - taskIdentifier: childTask, - payload: "{}", - payloadType: "application/json", - context: {}, - traceContext: {}, - traceId: "t12345", - spanId: "s12345", - masterQueue: "main", - queueName: "shared-queue", - queue: { - concurrencyLimit: 1, - }, - isTest: false, - tags: [], - resumeParentOnCompletion: true, - parentTaskRunId: parentRun.id, - }, - prisma - ); - - const childExecutionData = await engine.getRunExecutionData({ runId: childRun.id }); - assertNonNullable(childExecutionData); - expect(childExecutionData.snapshot.executionStatus).toBe("QUEUED"); - - const parentExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); - assertNonNullable(parentExecutionData); - expect(parentExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); - - //dequeue the child run - const dequeuedChild = await engine.dequeueFromMasterQueue({ - consumerId: "test_12345", - masterQueue: childRun.masterQueue, - maxRunCount: 10, - }); - - expect(dequeuedChild.length).toBe(1); - - // Now try and trigger another child run on the same queue - const childRun2 = await engine.trigger( - { - number: 1, - friendlyId: "run_c12345", - environment: authenticatedEnvironment, - taskIdentifier: childTask, - payload: "{}", - payloadType: "application/json", - context: {}, - traceContext: {}, - traceId: "t12345_2", - spanId: "s12345_2", - masterQueue: "main", - queueName: "shared-queue", - queue: { - concurrencyLimit: 1, - }, - isTest: false, - tags: [], - resumeParentOnCompletion: true, - parentTaskRunId: parentRun.id, - }, - prisma - ); - - expect(childRun2.status).toBe("COMPLETED_WITH_ERRORS"); - expect(childRun2.error).toEqual({ - type: "INTERNAL_ERROR", - code: TaskRunErrorCodes.RECURSIVE_WAIT_DEADLOCK, - message: expect.any(String), - }); - } finally { - engine.quit(); - } - } - ); -}); diff --git a/internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.ts b/internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.ts index eb65c41513..e46177ec0d 100644 --- a/internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.ts +++ b/internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.ts @@ -52,7 +52,6 @@ export type FairQueueSelectionStrategyOptions = { type FairQueueConcurrency = { current: number; limit: number; - reserve: number; }; type FairQueue = { id: string; age: number; org: string; env: string; project: string }; @@ -403,7 +402,7 @@ export class FairQueueSelectionStrategy implements RunQueueSelectionStrategy { ); const envsAtFullConcurrency = envs.filter( - (env) => env.concurrency.current >= env.concurrency.limit + env.concurrency.reserve + (env) => env.concurrency.current >= env.concurrency.limit ); const envIdsAtFullConcurrency = new Set(envsAtFullConcurrency.map((env) => env.id)); @@ -500,17 +499,15 @@ export class FairQueueSelectionStrategy implements RunQueueSelectionStrategy { span.setAttribute("org_id", env.orgId); span.setAttribute("project_id", env.projectId); - const [currentValue, limitValue, reserveValue] = await Promise.all([ + const [currentValue, limitValue] = await Promise.all([ this.#getEnvCurrentConcurrency(env), this.#getEnvConcurrencyLimit(env), - this.#getEnvReserveConcurrency(env), ]); span.setAttribute("current_value", currentValue); span.setAttribute("limit_value", limitValue); - span.setAttribute("reserve_value", reserveValue); - return { current: currentValue, limit: limitValue, reserve: reserveValue }; + return { current: currentValue, limit: limitValue }; }); } @@ -587,22 +584,6 @@ export class FairQueueSelectionStrategy implements RunQueueSelectionStrategy { }); } - async #getEnvReserveConcurrency(env: EnvDescriptor) { - return await startSpan(this.options.tracer, "getEnvReserveConcurrency", async (span) => { - span.setAttribute("env_id", env.envId); - span.setAttribute("org_id", env.orgId); - span.setAttribute("project_id", env.projectId); - - const key = this.options.keys.envReserveConcurrencyKey(env); - - const result = await this._redis.scard(key); - - span.setAttribute("current_value", result); - - return result; - }); - } - #envDescriptorFromFairQueue(queue: FairQueue): EnvDescriptor { return { envId: queue.env, diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index a4b9e98d73..7965454632 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -53,11 +53,6 @@ export type RunQueueOptions = { retryOptions?: RetryOptions; }; -export type RunQueueReserveConcurrencyOptions = { - messageId: string; - recursiveQueue: boolean; -}; - type DequeuedMessage = { messageId: string; messageScore: string; @@ -286,14 +281,6 @@ export class RunQueue { return this.redis.scard(this.keys.envCurrentConcurrencyKey(env)); } - public async reserveConcurrencyOfEnvironment(env: MinimalAuthenticatedEnvironment) { - return this.redis.scard(this.keys.envReserveConcurrencyKey(env)); - } - - public async reserveConcurrencyOfQueue(env: MinimalAuthenticatedEnvironment, queue: string) { - return this.redis.scard(this.keys.reserveConcurrencyKey(env, queue)); - } - public async messageExists(orgId: string, messageId: string) { return this.redis.exists(this.keys.messageKey(orgId, messageId)); } @@ -337,12 +324,10 @@ export class RunQueue { env, message, masterQueues, - reserveConcurrency, }: { env: MinimalAuthenticatedEnvironment; message: InputPayload; masterQueues: string | string[]; - reserveConcurrency?: RunQueueReserveConcurrencyOptions; }) { return await this.#trace( "enqueueMessage", @@ -370,7 +355,7 @@ export class RunQueue { attempt: 0, }; - return await this.#callEnqueueMessage(messagePayload, parentQueues, reserveConcurrency); + return await this.#callEnqueueMessage(messagePayload, parentQueues); }, { kind: SpanKind.PRODUCER, @@ -481,13 +466,7 @@ export class RunQueue { * This is done when the run is in a final state. * @param messageId */ - public async acknowledgeMessage( - orgId: string, - messageId: string, - reserveConcurrency?: { - messageId?: string; - } - ) { + public async acknowledgeMessage(orgId: string, messageId: string) { return this.#trace( "acknowledgeMessage", async (span) => { @@ -506,7 +485,6 @@ export class RunQueue { await this.#callAcknowledgeMessage({ message, - reserveConcurrency, }); }, { @@ -639,16 +617,12 @@ export class RunQueue { const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); - const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(message.queue); - const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(message.queue); const queueConcurrencyLimitKey = this.keys.concurrencyLimitKeyFromQueue(message.queue); const envConcurrencyLimitKey = this.keys.envConcurrencyLimitKeyFromQueue(message.queue); const result = await this.redis.reacquireConcurrency( queueCurrentConcurrencyKey, envCurrentConcurrencyKey, - queueReserveConcurrencyKey, - envReserveConcurrencyKey, queueConcurrencyLimitKey, envConcurrencyLimitKey, messageId, @@ -782,17 +756,11 @@ export class RunQueue { this.subscriber.on("message", this.handleRedriveMessage.bind(this)); } - async #callEnqueueMessage( - message: OutputPayload, - masterQueues: string[], - reserveConcurrency?: RunQueueReserveConcurrencyOptions - ) { + async #callEnqueueMessage(message: OutputPayload, masterQueues: string[]) { const queueKey = message.queue; const messageKey = this.keys.messageKey(message.orgId, message.runId); const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(message.queue); - const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(message.queue); const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(message.queue); - const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(message.queue); const envQueueKey = this.keys.envQueueKeyFromQueue(message.queue); const queueName = message.queue; @@ -802,127 +770,33 @@ export class RunQueue { const $masterQueues = JSON.stringify(masterQueues); const keyPrefix = this.options.redis.keyPrefix ?? ""; - if (!reserveConcurrency) { - this.logger.debug("Calling enqueueMessage", { - queueKey, - messageKey, - queueCurrentConcurrencyKey, - envCurrentConcurrencyKey, - envQueueKey, - queueName, - messageId, - messageData, - messageScore, - masterQueues: $masterQueues, - service: this.name, - }); - - await this.redis.enqueueMessage( - queueKey, - messageKey, - queueCurrentConcurrencyKey, - queueReserveConcurrencyKey, - envCurrentConcurrencyKey, - envReserveConcurrencyKey, - envQueueKey, - queueName, - messageId, - messageData, - messageScore, - $masterQueues, - keyPrefix - ); - - return true; - } - - const envConcurrencyLimitKey = this.keys.envConcurrencyLimitKeyFromQueue(message.queue); - const reserveMessageId = reserveConcurrency.messageId; - const defaultEnvConcurrencyLimit = String(this.options.defaultEnvConcurrency); - - if (!reserveConcurrency.recursiveQueue) { - this.logger.debug("Calling enqueueMessageWithReservingConcurrency", { - service: this.name, - queueKey, - messageKey, - queueCurrentConcurrencyKey, - queueReserveConcurrencyKey, - envCurrentConcurrencyKey, - envReserveConcurrencyKey, - envConcurrencyLimitKey, - envQueueKey, - queueName, - messageId, - messageData, - messageScore, - reserveMessageId, - defaultEnvConcurrencyLimit, - }); - - await this.redis.enqueueMessageWithReservingConcurrency( - queueKey, - messageKey, - queueCurrentConcurrencyKey, - queueReserveConcurrencyKey, - envCurrentConcurrencyKey, - envReserveConcurrencyKey, - envConcurrencyLimitKey, - envQueueKey, - queueName, - messageId, - messageData, - messageScore, - $masterQueues, - keyPrefix, - reserveMessageId, - defaultEnvConcurrencyLimit - ); - - return true; - } else { - const queueConcurrencyLimitKey = this.keys.concurrencyLimitKeyFromQueue(message.queue); - - this.logger.debug("Calling enqueueMessageWithReservingConcurrencyOnRecursiveQueue", { - service: this.name, - queueKey, - messageKey, - queueCurrentConcurrencyKey, - queueReserveConcurrencyKey, - queueConcurrencyLimitKey, - envCurrentConcurrencyKey, - envReserveConcurrencyKey, - envConcurrencyLimitKey, - envQueueKey, - queueName, - messageId, - messageData, - messageScore, - reserveMessageId, - defaultEnvConcurrencyLimit, - }); - - const result = await this.redis.enqueueMessageWithReservingConcurrencyOnRecursiveQueue( - queueKey, - messageKey, - queueCurrentConcurrencyKey, - queueReserveConcurrencyKey, - queueConcurrencyLimitKey, - envCurrentConcurrencyKey, - envReserveConcurrencyKey, - envConcurrencyLimitKey, - envQueueKey, - queueName, - messageId, - messageData, - messageScore, - $masterQueues, - keyPrefix, - reserveMessageId, - defaultEnvConcurrencyLimit - ); + this.logger.debug("Calling enqueueMessage", { + queueKey, + messageKey, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + envQueueKey, + queueName, + messageId, + messageData, + messageScore, + masterQueues: $masterQueues, + service: this.name, + }); - return !!result; - } + await this.redis.enqueueMessage( + queueKey, + messageKey, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + envQueueKey, + queueName, + messageId, + messageData, + messageScore, + $masterQueues, + keyPrefix + ); } async #callDequeueMessage({ @@ -932,10 +806,8 @@ export class RunQueue { }): Promise { const queueConcurrencyLimitKey = this.keys.concurrencyLimitKeyFromQueue(messageQueue); const queueCurrentConcurrencyKey = this.keys.currentConcurrencyKeyFromQueue(messageQueue); - const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(messageQueue); const envConcurrencyLimitKey = this.keys.envConcurrencyLimitKeyFromQueue(messageQueue); const envCurrentConcurrencyKey = this.keys.envCurrentConcurrencyKeyFromQueue(messageQueue); - const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(messageQueue); const messageKeyPrefix = this.keys.messageKeyPrefixFromQueue(messageQueue); const envQueueKey = this.keys.envQueueKeyFromQueue(messageQueue); @@ -944,9 +816,7 @@ export class RunQueue { queueConcurrencyLimitKey, envConcurrencyLimitKey, queueCurrentConcurrencyKey, - queueReserveConcurrencyKey, envCurrentConcurrencyKey, - envReserveConcurrencyKey, messageKeyPrefix, envQueueKey, }); @@ -957,9 +827,7 @@ export class RunQueue { queueConcurrencyLimitKey, envConcurrencyLimitKey, queueCurrentConcurrencyKey, - queueReserveConcurrencyKey, envCurrentConcurrencyKey, - envReserveConcurrencyKey, messageKeyPrefix, envQueueKey, //args @@ -1009,15 +877,7 @@ export class RunQueue { }; } - async #callAcknowledgeMessage({ - message, - reserveConcurrency, - }: { - message: OutputPayload; - reserveConcurrency?: { - messageId?: string; - }; - }) { + async #callAcknowledgeMessage({ message }: { message: OutputPayload }) { const messageId = message.runId; const messageKey = this.keys.messageKey(message.orgId, messageId); const messageQueue = message.queue; @@ -1037,37 +897,17 @@ export class RunQueue { service: this.name, }); - if (!reserveConcurrency?.messageId) { - return this.redis.acknowledgeMessage( - messageKey, - messageQueue, - queueCurrentConcurrencyKey, - envCurrentConcurrencyKey, - envQueueKey, - messageId, - messageQueue, - JSON.stringify(masterQueues), - this.options.redis.keyPrefix ?? "" - ); - } else { - const queueReserveConcurrencyKey = this.keys.reserveConcurrencyKeyFromQueue(messageQueue); - const envReserveConcurrencyKey = this.keys.envReserveConcurrencyKeyFromQueue(messageQueue); - - return this.redis.acknowledgeMessageWithReserveConcurrency( - messageKey, - messageQueue, - queueCurrentConcurrencyKey, - envCurrentConcurrencyKey, - envQueueKey, - envReserveConcurrencyKey, - queueReserveConcurrencyKey, - messageId, - messageQueue, - JSON.stringify(masterQueues), - this.options.redis.keyPrefix ?? "", - reserveConcurrency.messageId - ); - } + return this.redis.acknowledgeMessage( + messageKey, + messageQueue, + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + envQueueKey, + messageId, + messageQueue, + JSON.stringify(masterQueues), + this.options.redis.keyPrefix ?? "" + ); } async #callNackMessage({ message, retryAt }: { message: OutputPayload; retryAt?: number }) { @@ -1149,128 +989,13 @@ export class RunQueue { #registerCommands() { this.redis.defineCommand("enqueueMessage", { - numberOfKeys: 7, - lua: ` -local queueKey = KEYS[1] -local messageKey = KEYS[2] -local queueCurrentConcurrencyKey = KEYS[3] -local queueReserveConcurrencyKey = KEYS[4] -local envCurrentConcurrencyKey = KEYS[5] -local envReserveConcurrencyKey = KEYS[6] -local envQueueKey = KEYS[7] - -local queueName = ARGV[1] -local messageId = ARGV[2] -local messageData = ARGV[3] -local messageScore = ARGV[4] -local parentQueues = cjson.decode(ARGV[5]) -local keyPrefix = ARGV[6] - --- Write the message to the message key -redis.call('SET', messageKey, messageData) - --- Add the message to the queue -redis.call('ZADD', queueKey, messageScore, messageId) - --- Add the message to the env queue -redis.call('ZADD', envQueueKey, messageScore, messageId) - --- Rebalance the parent queues -local earliestMessage = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') - -for _, parentQueue in ipairs(parentQueues) do - local prefixedParentQueue = keyPrefix .. parentQueue - if #earliestMessage == 0 then - redis.call('ZREM', prefixedParentQueue, queueName) - else - redis.call('ZADD', prefixedParentQueue, earliestMessage[2], queueName) - end -end - --- Update the concurrency keys -redis.call('SREM', queueCurrentConcurrencyKey, messageId) -redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', envReserveConcurrencyKey, messageId) -redis.call('SREM', queueReserveConcurrencyKey, messageId) - -return true - `, - }); - - this.redis.defineCommand("enqueueMessageWithReservingConcurrency", { - numberOfKeys: 8, - lua: ` -local queueKey = KEYS[1] -local messageKey = KEYS[2] -local queueCurrentConcurrencyKey = KEYS[3] -local queueReserveConcurrencyKey = KEYS[4] -local envCurrentConcurrencyKey = KEYS[5] -local envReserveConcurrencyKey = KEYS[6] -local envConcurrencyLimitKey = KEYS[7] -local envQueueKey = KEYS[8] - -local queueName = ARGV[1] -local messageId = ARGV[2] -local messageData = ARGV[3] -local messageScore = ARGV[4] -local parentQueues = cjson.decode(ARGV[5]) -local keyPrefix = ARGV[6] -local reserveMessageId = ARGV[7] -local defaultEnvConcurrencyLimit = ARGV[8] - --- Write the message to the message key -redis.call('SET', messageKey, messageData) - --- Add the message to the queue -redis.call('ZADD', queueKey, messageScore, messageId) - --- Add the message to the env queue -redis.call('ZADD', envQueueKey, messageScore, messageId) - --- Rebalance the parent queues -local earliestMessage = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') - -for _, parentQueue in ipairs(parentQueues) do - local prefixedParentQueue = keyPrefix .. parentQueue - if #earliestMessage == 0 then - redis.call('ZREM', prefixedParentQueue, queueName) - else - redis.call('ZADD', prefixedParentQueue, earliestMessage[2], queueName) - end -end - --- Update the concurrency keys -redis.call('SREM', queueCurrentConcurrencyKey, messageId) -redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', envReserveConcurrencyKey, messageId) -redis.call('SREM', queueReserveConcurrencyKey, messageId) - --- Reserve the concurrency for the message -local envReserveConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) --- Count the number of messages in the reserve concurrency set -local envReserveConcurrency = tonumber(redis.call('SCARD', envReserveConcurrencyKey) or '0') - --- If there is space, add the messageId to the env reserve concurrency set -if envReserveConcurrency < envReserveConcurrencyLimit then - redis.call('SADD', envReserveConcurrencyKey, reserveMessageId) -end - -return true - `, - }); - - this.redis.defineCommand("enqueueMessageWithReservingConcurrencyOnRecursiveQueue", { - numberOfKeys: 9, + numberOfKeys: 5, lua: ` local queueKey = KEYS[1] local messageKey = KEYS[2] local queueCurrentConcurrencyKey = KEYS[3] -local queueReserveConcurrencyKey = KEYS[4] -local queueConcurrencyLimitKey = KEYS[5] -local envCurrentConcurrencyKey = KEYS[6] -local envReserveConcurrencyKey = KEYS[7] -local envConcurrencyLimitKey = KEYS[8] -local envQueueKey = KEYS[9] +local envCurrentConcurrencyKey = KEYS[4] +local envQueueKey = KEYS[5] local queueName = ARGV[1] local messageId = ARGV[2] @@ -1278,23 +1003,6 @@ local messageData = ARGV[3] local messageScore = ARGV[4] local parentQueues = cjson.decode(ARGV[5]) local keyPrefix = ARGV[6] -local reserveMessageId = ARGV[7] -local defaultEnvConcurrencyLimit = ARGV[8] - --- Get the env reserve concurrency limit because we need it to calculate the max reserve concurrency --- for the specific queue -local envReserveConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) - --- Count the number of messages in the queue reserve concurrency set -local queueReserveConcurrency = tonumber(redis.call('SCARD', queueReserveConcurrencyKey) or '0') -local queueConcurrencyLimit = tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000') - -local queueReserveConcurrencyLimit = math.min(queueConcurrencyLimit, envReserveConcurrencyLimit) - --- If we cannot add the reserve concurrency, then we have to return false -if queueReserveConcurrency >= queueReserveConcurrencyLimit then - return false -end -- Write the message to the message key redis.call('SET', messageKey, messageData) @@ -1320,35 +1028,19 @@ end -- Update the concurrency keys redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', envReserveConcurrencyKey, messageId) -redis.call('SREM', queueReserveConcurrencyKey, messageId) - --- Count the number of messages in the env reserve concurrency set -local envReserveConcurrency = tonumber(redis.call('SCARD', envReserveConcurrencyKey) or '0') - --- If there is space, add the messaageId to the env reserve concurrency set -if envReserveConcurrency < envReserveConcurrencyLimit then - redis.call('SADD', envReserveConcurrencyKey, reserveMessageId) -end - -redis.call('SADD', queueReserveConcurrencyKey, reserveMessageId) - -return true `, }); this.redis.defineCommand("dequeueMessage", { - numberOfKeys: 9, + numberOfKeys: 7, lua: ` local queueKey = KEYS[1] local queueConcurrencyLimitKey = KEYS[2] local envConcurrencyLimitKey = KEYS[3] local queueCurrentConcurrencyKey = KEYS[4] -local queueReserveConcurrencyKey = KEYS[5] -local envCurrentConcurrencyKey = KEYS[6] -local envReserveConcurrencyKey = KEYS[7] -local messageKeyPrefix = KEYS[8] -local envQueueKey = KEYS[9] +local envCurrentConcurrencyKey = KEYS[5] +local messageKeyPrefix = KEYS[6] +local envQueueKey = KEYS[7] local queueName = ARGV[1] local currentTime = tonumber(ARGV[2]) @@ -1358,8 +1050,7 @@ local keyPrefix = ARGV[4] -- Check current env concurrency against the limit local envCurrentConcurrency = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) -local envReserveConcurrency = tonumber(redis.call('SCARD', envReserveConcurrencyKey) or '0') -local totalEnvConcurrencyLimit = envConcurrencyLimit + envReserveConcurrency +local totalEnvConcurrencyLimit = envConcurrencyLimit if envCurrentConcurrency >= totalEnvConcurrencyLimit then return nil @@ -1368,8 +1059,7 @@ end -- Check current queue concurrency against the limit local queueCurrentConcurrency = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) -local queueReserveConcurrency = tonumber(redis.call('SCARD', queueReserveConcurrencyKey) or '0') -local totalQueueConcurrencyLimit = queueConcurrencyLimit + queueReserveConcurrency +local totalQueueConcurrencyLimit = queueConcurrencyLimit -- Check condition only if concurrencyLimit exists if queueCurrentConcurrency >= totalQueueConcurrencyLimit then @@ -1397,12 +1087,6 @@ redis.call('ZREM', envQueueKey, messageId) redis.call('SADD', queueCurrentConcurrencyKey, messageId) redis.call('SADD', envCurrentConcurrencyKey, messageId) --- Remove the message from the queue reserve concurrency set -redis.call('SREM', queueReserveConcurrencyKey, messageId) - --- Remove the message from the env reserve concurrency set -redis.call('SREM', envReserveConcurrencyKey, messageId) - -- Rebalance the parent queues local earliestMessage = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') for _, parentQueue in ipairs(decodedPayload.masterQueues) do @@ -1458,53 +1142,6 @@ redis.call('SREM', envCurrentConcurrencyKey, messageId) `, }); - this.redis.defineCommand("acknowledgeMessageWithReserveConcurrency", { - numberOfKeys: 7, - lua: ` --- Keys: -local messageKey = KEYS[1] -local messageQueueKey = KEYS[2] -local queueCurrentConcurrencyKey = KEYS[3] -local envCurrentConcurrencyKey = KEYS[4] -local envQueueKey = KEYS[5] -local queueReserveConcurrencyKey = KEYS[6] -local envReserveConcurrencyKey = KEYS[7] - --- Args: -local messageId = ARGV[1] -local messageQueueName = ARGV[2] -local parentQueues = cjson.decode(ARGV[3]) -local keyPrefix = ARGV[4] -local reserveMessageId = ARGV[5] - --- Remove the message from the message key -redis.call('DEL', messageKey) - --- Remove the message from the queue -redis.call('ZREM', messageQueueKey, messageId) -redis.call('ZREM', envQueueKey, messageId) - --- Rebalance the parent queues -local earliestMessage = redis.call('ZRANGE', messageQueueKey, 0, 0, 'WITHSCORES') -for _, parentQueue in ipairs(parentQueues) do - local prefixedParentQueue = keyPrefix .. parentQueue - if #earliestMessage == 0 then - redis.call('ZREM', prefixedParentQueue, messageQueueName) - else - redis.call('ZADD', prefixedParentQueue, earliestMessage[2], messageQueueName) - end -end - --- Update the concurrency keys -redis.call('SREM', queueCurrentConcurrencyKey, messageId) -redis.call('SREM', envCurrentConcurrencyKey, messageId) - --- Clear reserve concurrency -redis.call('SREM', queueReserveConcurrencyKey, reserveMessageId) -redis.call('SREM', envReserveConcurrencyKey, reserveMessageId) -`, - }); - this.redis.defineCommand("nackMessage", { numberOfKeys: 5, lua: ` @@ -1605,15 +1242,13 @@ redis.call('SREM', envCurrentConcurrencyKey, messageId) }); this.redis.defineCommand("reacquireConcurrency", { - numberOfKeys: 6, + numberOfKeys: 4, lua: ` -- Keys: local queueCurrentConcurrencyKey = KEYS[1] local envCurrentConcurrencyKey = KEYS[2] -local queueReserveConcurrencyKey = KEYS[3] -local envReserveConcurrencyKey = KEYS[4] -local queueConcurrencyLimitKey = KEYS[5] -local envConcurrencyLimitKey = KEYS[6] +local queueConcurrencyLimitKey = KEYS[3] +local envConcurrencyLimitKey = KEYS[4] -- Args: local messageId = ARGV[1] @@ -1631,8 +1266,7 @@ end -- Check current env concurrency against the limit local envCurrentConcurrency = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) -local envReserveConcurrency = tonumber(redis.call('SCARD', envReserveConcurrencyKey) or '0') -local totalEnvConcurrencyLimit = envConcurrencyLimit + envReserveConcurrency +local totalEnvConcurrencyLimit = envConcurrencyLimit if envCurrentConcurrency >= totalEnvConcurrencyLimit then return false @@ -1641,8 +1275,7 @@ end -- Check current queue concurrency against the limit local queueCurrentConcurrency = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) -local queueReserveConcurrency = tonumber(redis.call('SCARD', queueReserveConcurrencyKey) or '0') -local totalQueueConcurrencyLimit = queueConcurrencyLimit + queueReserveConcurrency +local totalQueueConcurrencyLimit = queueConcurrencyLimit if queueCurrentConcurrency >= totalQueueConcurrencyLimit then return false @@ -1678,29 +1311,7 @@ declare module "@internal/redis" { queue: string, messageKey: string, queueCurrentConcurrencyKey: string, - queueReserveConcurrencyKey: string, - envCurrentConcurrencyKey: string, - envReserveConcurrencyKey: string, - envQueueKey: string, - //args - queueName: string, - messageId: string, - messageData: string, - messageScore: string, - parentQueues: string, - keyPrefix: string, - callback?: Callback - ): Result; - - enqueueMessageWithReservingConcurrency( - //keys - queue: string, - messageKey: string, - queueCurrentConcurrencyKey: string, - queueReserveConcurrencyKey: string, envCurrentConcurrencyKey: string, - envReserveConcurrencyKey: string, - envConcurrencyLimitKey: string, envQueueKey: string, //args queueName: string, @@ -1709,43 +1320,16 @@ declare module "@internal/redis" { messageScore: string, parentQueues: string, keyPrefix: string, - reserveMessageId: string, - defaultEnvConcurrencyLimit: string, callback?: Callback ): Result; - enqueueMessageWithReservingConcurrencyOnRecursiveQueue( - //keys - queue: string, - messageKey: string, - queueCurrentConcurrencyKey: string, - queueReserveConcurrencyKey: string, - queueConcurrencyLimitKey: string, - envCurrentConcurrencyKey: string, - envReserveConcurrencyKey: string, - envConcurrencyLimitKey: string, - envQueueKey: string, - //args - queueName: string, - messageId: string, - messageData: string, - messageScore: string, - parentQueues: string, - keyPrefix: string, - reserveMessageId: string, - defaultEnvConcurrencyLimit: string, - callback?: Callback - ): Result; - dequeueMessage( //keys childQueue: string, queueConcurrencyLimitKey: string, envConcurrencyLimitKey: string, queueCurrentConcurrencyKey: string, - queueReserveConcurrencyKey: string, envCurrentConcurrencyKey: string, - envReserveConcurrencyKey: string, messageKeyPrefix: string, envQueueKey: string, //args @@ -1769,22 +1353,6 @@ declare module "@internal/redis" { callback?: Callback ): Result; - acknowledgeMessageWithReserveConcurrency( - messageKey: string, - messageQueue: string, - concurrencyKey: string, - envConcurrencyKey: string, - envQueueKey: string, - envReserveConcurrencyKey: string, - queueReserveConcurrencyKey: string, - messageId: string, - messageQueueName: string, - masterQueues: string, - keyPrefix: string, - reserveMessageId: string, - callback?: Callback - ): Result; - nackMessage( messageKey: string, messageQueue: string, @@ -1824,8 +1392,6 @@ declare module "@internal/redis" { reacquireConcurrency( queueCurrentConcurrencyKey: string, envCurrentConcurrencyKey: string, - queueReserveConcurrencyKey: string, - envReserveConcurrencyKey: string, queueConcurrencyLimitKey: string, envConcurrencyLimitKey: string, messageId: string, diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.ts b/internal-packages/run-engine/src/run-queue/keyProducer.ts index 4c4fd9dbbd..6c840bd212 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.ts @@ -12,7 +12,6 @@ const constants = { CONCURRENCY_KEY_PART: "ck", TASK_PART: "task", MESSAGE_PART: "message", - RESERVE_CONCURRENCY_PART: "reserveConcurrency", DEAD_LETTER_QUEUE_PART: "deadLetter", } as const; @@ -105,10 +104,6 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { ); } - reserveConcurrencyKey(env: MinimalAuthenticatedEnvironment, queue: string) { - return [this.queueKey(env, queue), constants.RESERVE_CONCURRENCY_PART].join(":"); - } - disabledConcurrencyLimitKeyFromQueue(queue: string) { const { orgId } = this.descriptorFromQueue(queue); return `{${constants.ORG_PART}:${orgId}}:${constants.DISABLED_CONCURRENCY_LIMIT_PART}`; @@ -156,28 +151,6 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { } } - envReserveConcurrencyKey(env: EnvDescriptor): string; - envReserveConcurrencyKey(env: MinimalAuthenticatedEnvironment): string; - envReserveConcurrencyKey( - envOrDescriptor: EnvDescriptor | MinimalAuthenticatedEnvironment - ): string { - if ("id" in envOrDescriptor) { - return [ - this.orgKeySection(envOrDescriptor.organization.id), - this.projKeySection(envOrDescriptor.project.id), - this.envKeySection(envOrDescriptor.id), - constants.RESERVE_CONCURRENCY_PART, - ].join(":"); - } else { - return [ - this.orgKeySection(envOrDescriptor.orgId), - this.projKeySection(envOrDescriptor.projectId), - this.envKeySection(envOrDescriptor.envId), - constants.RESERVE_CONCURRENCY_PART, - ].join(":"); - } - } - messageKeyPrefixFromQueue(queue: string) { const { orgId } = this.descriptorFromQueue(queue); return `${this.orgKeySection(orgId)}:${constants.MESSAGE_PART}:`; @@ -201,17 +174,6 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { return this.descriptorFromQueue(queue).projectId; } - reserveConcurrencyKeyFromQueue(queue: string) { - const descriptor = this.descriptorFromQueue(queue); - - return this.queueReserveConcurrencyKeyFromDescriptor(descriptor); - } - - envReserveConcurrencyKeyFromQueue(queue: string) { - const descriptor = this.descriptorFromQueue(queue); - - return this.envReserveConcurrencyKey(descriptor); - } deadLetterQueueKey(env: MinimalAuthenticatedEnvironment): string; deadLetterQueueKey(env: EnvDescriptor): string; deadLetterQueueKey(envOrDescriptor: EnvDescriptor | MinimalAuthenticatedEnvironment): string { @@ -237,13 +199,6 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { return this.deadLetterQueueKey(descriptor); } - private queueReserveConcurrencyKeyFromDescriptor(descriptor: QueueDescriptor) { - return [ - this.queueKey(descriptor.orgId, descriptor.projectId, descriptor.envId, descriptor.queue), - constants.RESERVE_CONCURRENCY_PART, - ].join(":"); - } - descriptorFromQueue(queue: string): QueueDescriptor { const parts = queue.split(":"); return { diff --git a/internal-packages/run-engine/src/run-queue/tests/ack.test.ts b/internal-packages/run-engine/src/run-queue/tests/ack.test.ts index 6805984d96..8fc6da7dd7 100644 --- a/internal-packages/run-engine/src/run-queue/tests/ack.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/ack.test.ts @@ -104,112 +104,6 @@ describe("RunQueue.acknowledgeMessage", () => { } }); - redisTest( - "acknowledging a message with reserve concurrency clears both current and reserve concurrency", - async ({ redisContainer }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - }); - - try { - const envMasterQueue = `env:${authenticatedEnvDev.id}`; - - // Enqueue message with reserve concurrency - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: messageDev, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: messageDev.runId, - recursiveQueue: true, - }, - }); - - // Verify reserve concurrency is set - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(1); - - const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueReserveConcurrency).toBe(1); - - // Dequeue the message - const dequeued = await queue.dequeueMessageFromMasterQueue( - "test_12345", - envMasterQueue, - 10 - ); - expect(dequeued.length).toBe(1); - - // Verify current concurrency is set and reserve is cleared - const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); - expect(envConcurrency).toBe(1); - - const queueConcurrency = await queue.currentConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueConcurrency).toBe(1); - - const envReserveConcurrencyAfterDequeue = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrencyAfterDequeue).toBe(0); - - const queueReserveConcurrencyAfterDequeue = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueReserveConcurrencyAfterDequeue).toBe(0); - - // Acknowledge the message - await queue.acknowledgeMessage(messageDev.orgId, messageDev.runId); - - // Verify all concurrency is cleared - const queueConcurrencyAfter = await queue.currentConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueConcurrencyAfter).toBe(0); - - const envConcurrencyAfter = await queue.currentConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envConcurrencyAfter).toBe(0); - - const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrencyAfter).toBe(0); - - const queueReserveConcurrencyAfter = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueReserveConcurrencyAfter).toBe(0); - } finally { - await queue.quit(); - } - } - ); - redisTest("acknowledging a message removes it from the queue", async ({ redisContainer }) => { const queue = new RunQueue({ ...testOptions, @@ -272,96 +166,4 @@ describe("RunQueue.acknowledgeMessage", () => { await queue.quit(); } }); - - redisTest( - "acknowledging a message clears env reserve concurrency when recursive queue is false", - async ({ redisContainer }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - }); - - try { - const envMasterQueue = `env:${authenticatedEnvDev.id}`; - - // Enqueue message with reserve concurrency - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: messageDev, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: "r1235", - recursiveQueue: false, - }, - }); - - // Verify reserve concurrency is set - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(1); - - const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueReserveConcurrency).toBe(0); - - // Verify message is in queue before acknowledging - const queueLengthBefore = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); - expect(queueLengthBefore).toBe(1); - - const envQueueLengthBefore = await queue.lengthOfEnvQueue(authenticatedEnvDev); - expect(envQueueLengthBefore).toBe(1); - - // Acknowledge the message before dequeuing - await queue.acknowledgeMessage(messageDev.orgId, messageDev.runId, { - messageId: "r1235", - }); - - // Verify reserve concurrency is cleared - const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrencyAfter).toBe(0); - - const queueReserveConcurrencyAfter = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueReserveConcurrencyAfter).toBe(0); - - // Verify message is removed from queue - const queueLength = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); - expect(queueLength).toBe(0); - - const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); - expect(envQueueLength).toBe(0); - - // Verify no current concurrency was set - const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); - expect(envConcurrency).toBe(0); - - const queueConcurrency = await queue.currentConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueConcurrency).toBe(0); - } finally { - await queue.quit(); - } - } - ); }); diff --git a/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts b/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts index 59ac23b5de..cb23b0dd39 100644 --- a/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/dequeueMessageFromMasterQueue.test.ts @@ -78,14 +78,12 @@ describe("RunQueue.dequeueMessageFromMasterQueue", () => { const envMasterQueue = `env:${authenticatedEnvDev.id}`; //enqueue message - const enqueueResult = await queue.enqueueMessage({ + await queue.enqueueMessage({ env: authenticatedEnvDev, message: messageDev, masterQueues: ["main", envMasterQueue], }); - expect(enqueueResult).toBe(true); - //queue length const result2 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); expect(result2).toBe(1); @@ -107,11 +105,6 @@ describe("RunQueue.dequeueMessageFromMasterQueue", () => { const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); expect(envConcurrency).toBe(0); - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(0); - const dequeued = await queue.dequeueMessageFromMasterQueue("test_12345", envMasterQueue, 10); expect(dequeued.length).toBe(1); expect(dequeued[0].messageId).toEqual(messageDev.runId); @@ -129,11 +122,6 @@ describe("RunQueue.dequeueMessageFromMasterQueue", () => { const envConcurrencyAfter = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); expect(envConcurrencyAfter).toBe(1); - const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrencyAfter).toBe(0); - //queue length const result3 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); expect(result3).toBe(0); @@ -276,172 +264,4 @@ describe("RunQueue.dequeueMessageFromMasterQueue", () => { } } ); - - redisTest( - "should consider reserve concurrency when checking limits", - async ({ redisContainer }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - }); - - try { - // Set env concurrency limit to 1 - await queue.updateEnvConcurrencyLimits({ - ...authenticatedEnvDev, - maximumConcurrencyLimit: 1, - }); - - const envMasterQueue = `env:${authenticatedEnvDev.id}`; - - // First enqueue and dequeue a message to occupy the concurrency - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: messageDev, - masterQueues: ["main", envMasterQueue], - }); - - const dequeued1 = await queue.dequeueMessageFromMasterQueue( - "test_12345", - envMasterQueue, - 10 - ); - expect(dequeued1.length).toBe(1); - - // Verify current concurrency is at limit - const envConcurrency1 = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); - expect(envConcurrency1).toBe(1); - - // Now enqueue a message with reserve concurrency - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: { ...messageDev, runId: "r4322" }, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: "r1234", - recursiveQueue: false, - }, - }); - - // Verify reserve concurrency is set - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(1); - - // Try to dequeue another message - should fail because current concurrency is at limit - const dequeued2 = await queue.dequeueMessageFromMasterQueue( - "test_12345", - envMasterQueue, - 10 - ); - expect(dequeued2.length).toBe(0); - - // Verify concurrency counts - const envConcurrency2 = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); - expect(envConcurrency2).toBe(1); - - // Reserve concurrency should still be set - const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrencyAfter).toBe(1); - } finally { - await queue.quit(); - } - } - ); - - redisTest( - "should clear reserve concurrency when dequeuing reserved message", - async ({ redisContainer }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - }); - - try { - const envMasterQueue = `env:${authenticatedEnvDev.id}`; - - // Enqueue message with reserve concurrency - await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: messageDev, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: messageDev.runId, - recursiveQueue: true, - }, - }); - - // Verify reserve concurrency is set - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(1); - - const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueReserveConcurrency).toBe(1); - - // Dequeue the reserved message - const dequeued = await queue.dequeueMessageFromMasterQueue( - "test_12345", - envMasterQueue, - 10 - ); - expect(dequeued.length).toBe(1); - expect(dequeued[0].messageId).toBe(messageDev.runId); - - // Verify reserve concurrency is cleared and current concurrency is set - const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrencyAfter).toBe(0); - - const queueReserveConcurrencyAfter = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueReserveConcurrencyAfter).toBe(0); - - const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); - expect(envConcurrency).toBe(1); - - const queueConcurrency = await queue.currentConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueConcurrency).toBe(1); - } finally { - await queue.quit(); - } - } - ); }); diff --git a/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts b/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts index c4e1fadb44..573ac1485b 100644 --- a/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/enqueueMessage.test.ts @@ -46,7 +46,7 @@ const messageDev: InputPayload = { vi.setConfig({ testTimeout: 60_000 }); describe("RunQueue.enqueueMessage", () => { - redisTest("enqueueMessage with no reserved concurrency", async ({ redisContainer }) => { + redisTest("should add the message to the queue", async ({ redisContainer }) => { const queue = new RunQueue({ ...testOptions, queueSelectionStrategy: new FairQueueSelectionStrategy({ @@ -84,7 +84,7 @@ describe("RunQueue.enqueueMessage", () => { masterQueues: ["main", envMasterQueue], }); - expect(enqueueResult).toBe(true); + expect(enqueueResult).toBe(undefined); //queue length const result2 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); @@ -106,421 +106,8 @@ describe("RunQueue.enqueueMessage", () => { const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); expect(envConcurrency).toBe(0); - - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(0); } finally { await queue.quit(); } }); - - redisTest( - "enqueueMessage with non-recursive reserved concurrency adds to the environment's reserved concurrency", - async ({ redisContainer }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - }); - - try { - //initial queue length - const result = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); - expect(result).toBe(0); - const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); - expect(envQueueLength).toBe(0); - - //initial oldest message - const oldestScore = await queue.oldestMessageInQueue(authenticatedEnvDev, messageDev.queue); - expect(oldestScore).toBe(undefined); - - const envMasterQueue = `env:${authenticatedEnvDev.id}`; - - //enqueue message - const enqueueResult = await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: messageDev, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: "r1234", - recursiveQueue: false, - }, - }); - - expect(enqueueResult).toBe(true); - - //queue length - const result2 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); - expect(result2).toBe(1); - - const envQueueLength2 = await queue.lengthOfEnvQueue(authenticatedEnvDev); - expect(envQueueLength2).toBe(1); - - //oldest message - const oldestScore2 = await queue.oldestMessageInQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(oldestScore2).toBe(messageDev.timestamp); - - //concurrencies - const queueConcurrency = await queue.currentConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueConcurrency).toBe(0); - - const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); - expect(envConcurrency).toBe(0); - - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(1); - } finally { - await queue.quit(); - } - } - ); - - redisTest( - "enqueueMessage with recursive reserved concurrency adds to the environment and queue reserved concurrency", - async ({ redisContainer }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - }); - - try { - //initial queue length - const result = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); - expect(result).toBe(0); - const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); - expect(envQueueLength).toBe(0); - - //initial oldest message - const oldestScore = await queue.oldestMessageInQueue(authenticatedEnvDev, messageDev.queue); - expect(oldestScore).toBe(undefined); - - const envMasterQueue = `env:${authenticatedEnvDev.id}`; - - //enqueue message - const enqueueResult = await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: messageDev, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: "r1234", - recursiveQueue: true, - }, - }); - - expect(enqueueResult).toBe(true); - - //queue length - const result2 = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); - expect(result2).toBe(1); - - const envQueueLength2 = await queue.lengthOfEnvQueue(authenticatedEnvDev); - expect(envQueueLength2).toBe(1); - - //oldest message - const oldestScore2 = await queue.oldestMessageInQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(oldestScore2).toBe(messageDev.timestamp); - - //concurrencies - const queueConcurrency = await queue.currentConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueConcurrency).toBe(0); - - const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueReserveConcurrency).toBe(1); - - const envConcurrency = await queue.currentConcurrencyOfEnvironment(authenticatedEnvDev); - expect(envConcurrency).toBe(0); - - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(1); - } finally { - await queue.quit(); - } - } - ); - - redisTest( - "enqueueMessage of a reserved message should clear the reserved concurrency", - async ({ redisContainer }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - }); - - try { - //initial queue length - const result = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); - expect(result).toBe(0); - const envQueueLength = await queue.lengthOfEnvQueue(authenticatedEnvDev); - expect(envQueueLength).toBe(0); - - //initial oldest message - const oldestScore = await queue.oldestMessageInQueue(authenticatedEnvDev, messageDev.queue); - expect(oldestScore).toBe(undefined); - - const envMasterQueue = `env:${authenticatedEnvDev.id}`; - - //enqueue message - const enqueueResult = await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: messageDev, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: "r1234", - recursiveQueue: false, - }, - }); - - expect(enqueueResult).toBe(true); - - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(1); - - // enqueue reserve message - const enqueueResult2 = await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: { - ...messageDev, - runId: "r1234", - }, - masterQueues: ["main", envMasterQueue], - }); - - expect(enqueueResult2).toBe(true); - - const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrencyAfter).toBe(0); - } finally { - await queue.quit(); - } - } - ); - - redisTest( - "enqueueMessage with non-recursive reserved concurrency cannot exceed the environment's maximum concurrency limit", - async ({ redisContainer }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - }); - - try { - const envMasterQueue = `env:${authenticatedEnvDev.id}`; - - await queue.updateEnvConcurrencyLimits({ - ...authenticatedEnvDev, - maximumConcurrencyLimit: 1, - }); - - const envConcurrencyLimit = await queue.getEnvConcurrencyLimit(authenticatedEnvDev); - expect(envConcurrencyLimit).toBe(1); - - //enqueue message - const enqueueResult = await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: messageDev, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: "r1234", - recursiveQueue: false, - }, - }); - - expect(enqueueResult).toBe(true); - - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(1); - - // enqueue another message with a non-recursive reserved concurrency - const enqueueResult2 = await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: { - ...messageDev, - runId: "rabc123", - }, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: "r12345678", - recursiveQueue: false, - }, - }); - - expect(enqueueResult2).toBe(true); - - const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrencyAfter).toBe(1); - } finally { - await queue.quit(); - } - } - ); - - redisTest( - "enqueueMessage with recursive reserved concurrency should fail if queue reserve concurrency will exceed the queue concurrency limit", - async ({ redisContainer }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - }); - - try { - const envMasterQueue = `env:${authenticatedEnvDev.id}`; - - await queue.updateQueueConcurrencyLimits(authenticatedEnvDev, messageDev.queue, 1); - - const envConcurrencyLimit = await queue.getQueueConcurrencyLimit( - authenticatedEnvDev, - messageDev.queue - ); - expect(envConcurrencyLimit).toBe(1); - - //enqueue message - const enqueueResult = await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: messageDev, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: "r1234", - recursiveQueue: true, - }, - }); - - expect(enqueueResult).toBe(true); - - const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueReserveConcurrency).toBe(1); - - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(1); - - // enqueue another message with a non-recursive reserved concurrency - const enqueueResult2 = await queue.enqueueMessage({ - env: authenticatedEnvDev, - message: { - ...messageDev, - runId: "rabc123", - }, - masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: "r12345678", - recursiveQueue: true, - }, - }); - - expect(enqueueResult2).toBe(false); - - const queueReserveConcurrencyAfter = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - - expect(queueReserveConcurrencyAfter).toBe(1); - - const envReserveConcurrencyAfter = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrencyAfter).toBe(1); - - const lengthOfQueue = await queue.lengthOfQueue(authenticatedEnvDev, messageDev.queue); - expect(lengthOfQueue).toBe(1); - - const lengthOfEnvQueue = await queue.lengthOfEnvQueue(authenticatedEnvDev); - expect(lengthOfEnvQueue).toBe(1); - } finally { - await queue.quit(); - } - } - ); }); diff --git a/internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.test.ts b/internal-packages/run-engine/src/run-queue/tests/fairQueueSelectionStrategy.test.ts similarity index 94% rename from internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.test.ts rename to internal-packages/run-engine/src/run-queue/tests/fairQueueSelectionStrategy.test.ts index b07a47db88..1461cf43d1 100644 --- a/internal-packages/run-engine/src/run-queue/fairQueueSelectionStrategy.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/fairQueueSelectionStrategy.test.ts @@ -1,10 +1,10 @@ -import { createRedisClient, RedisOptions } from "@internal/redis"; import { redisTest } from "@internal/testcontainers"; import { describe, expect, vi } from "vitest"; -import { RUN_QUEUE_RESUME_PRIORITY_TIMESTAMP_OFFSET } from "./constants.js"; -import { FairQueueSelectionStrategy } from "./fairQueueSelectionStrategy.js"; -import { RunQueueFullKeyProducer } from "./keyProducer.js"; -import { EnvQueues, RunQueueKeyProducer } from "./types.js"; +import { RUN_QUEUE_RESUME_PRIORITY_TIMESTAMP_OFFSET } from "../constants.js"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import { EnvQueues, RunQueueKeyProducer } from "../types.js"; +import { createRedisClient, RedisOptions } from "@internal/redis"; vi.setConfig({ testTimeout: 60_000 }); // 30 seconds timeout @@ -76,54 +76,6 @@ describe("FairDequeuingStrategy", () => { expect(result).toHaveLength(0); }); - redisTest( - "should give extra concurrency when the env has reserve concurrency", - async ({ redisOptions: redis }) => { - const keyProducer = new RunQueueFullKeyProducer(); - const strategy = new FairQueueSelectionStrategy({ - redis, - keys: keyProducer, - defaultEnvConcurrencyLimit: 2, - parentQueueLimit: 100, - seed: "test-seed-3", - }); - - await setupQueue({ - redis, - keyProducer, - parentQueue: "parent-queue", - score: Date.now() - 1000, - queueId: "queue-1", - orgId: "org-1", - projectId: "proj-1", - envId: "env-1", - }); - - await setupConcurrency({ - redis, - keyProducer, - env: { - envId: "env-1", - projectId: "proj-1", - orgId: "org-1", - currentConcurrency: 2, - limit: 2, - reserveConcurrency: 1, - }, - }); - - const result = await strategy.distributeFairQueuesFromParentQueue( - "parent-queue", - "consumer-1" - ); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - envId: "env-1", - queues: [keyProducer.queueKey("org-1", "proj-1", "env-1", "queue-1")], - }); - } - ); - redisTest("should respect parentQueueLimit", async ({ redisOptions: redis }) => { const keyProducer = new RunQueueFullKeyProducer(); const strategy = new FairQueueSelectionStrategy({ @@ -274,7 +226,7 @@ describe("FairDequeuingStrategy", () => { console.log("Third distribution took", distribute3Duration, "ms"); // Make sure the third call is more than 4 times the second - expect(distribute3Duration).toBeGreaterThan(distribute2Duration * 4); + expect(distribute3Duration).toBeGreaterThan(distribute2Duration * 2); } ); @@ -1119,7 +1071,6 @@ type SetupConcurrencyOptions = { orgId: string; currentConcurrency: number; limit?: number; - reserveConcurrency?: number; }; }; @@ -1145,19 +1096,6 @@ async function setupConcurrency({ redis, keyProducer, env }: SetupConcurrencyOpt await $redis.sadd(envCurrentKey, ...dummyJobs); } - - if (env.reserveConcurrency && env.reserveConcurrency > 0) { - // Set reserved concurrency by adding dummy members to the set - const envReservedKey = keyProducer.envReserveConcurrencyKey(env); - - // Add dummy reserved job IDs to simulate reserved concurrency - const dummyJobs = Array.from( - { length: env.reserveConcurrency }, - (_, i) => `dummy-reserved-job-${i}-${Date.now()}` - ); - - await $redis.sadd(envReservedKey, ...dummyJobs); - } } /** diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.test.ts b/internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts similarity index 99% rename from internal-packages/run-engine/src/run-queue/keyProducer.test.ts rename to internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts index 77ec4f50a6..88bbd55177 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts @@ -1,6 +1,6 @@ import { describe } from "node:test"; import { expect, it } from "vitest"; -import { RunQueueFullKeyProducer } from "./keyProducer.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; describe("KeyProducer", () => { it("queueConcurrencyLimitKey", () => { diff --git a/internal-packages/run-engine/src/run-queue/tests/nack.test.ts b/internal-packages/run-engine/src/run-queue/tests/nack.test.ts index e57c5acec2..f7b8aa1449 100644 --- a/internal-packages/run-engine/src/run-queue/tests/nack.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/nack.test.ts @@ -73,24 +73,8 @@ describe("RunQueue.nackMessage", () => { env: authenticatedEnvDev, message: messageDev, masterQueues: ["main", envMasterQueue], - reserveConcurrency: { - messageId: messageDev.runId, - recursiveQueue: true, - }, }); - // Verify reserve concurrency is set - const queueReserveConcurrency = await queue.reserveConcurrencyOfQueue( - authenticatedEnvDev, - messageDev.queue - ); - expect(queueReserveConcurrency).toBe(1); - - const envReserveConcurrency = await queue.reserveConcurrencyOfEnvironment( - authenticatedEnvDev - ); - expect(envReserveConcurrency).toBe(1); - // Dequeue message const dequeued = await queue.dequeueMessageFromMasterQueue("test_12345", envMasterQueue, 10); expect(dequeued.length).toBe(1); diff --git a/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts b/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts index 4f776ce7aa..6261e48780 100644 --- a/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts @@ -225,111 +225,6 @@ describe("RunQueue.reacquireConcurrency", () => { } ); - redisTest( - "It should return true and remove the run from the reserve concurrency set if it's already in the current concurrency set", - async ({ redisContainer }) => { - const queue = new RunQueue({ - ...testOptions, - queueSelectionStrategy: new FairQueueSelectionStrategy({ - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - keys: testOptions.keys, - }), - redis: { - keyPrefix: "runqueue:test:", - host: redisContainer.getHost(), - port: redisContainer.getPort(), - }, - }); - - try { - await queue.updateEnvConcurrencyLimits({ - ...authenticatedEnvProd, - maximumConcurrencyLimit: 1, - }); - - await queue.enqueueMessage({ - env: authenticatedEnvProd, - message: messageProd, - masterQueues: "main", - }); - - const messages = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 1); - expect(messages.length).toBe(1); - expect(messages[0].message.runId).toBe(messageProd.runId); - - //concurrencies - expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 1 - ); - expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); - - // Now we need to enqueue a second message message, adding the first to the reserve concurrency set - await queue.enqueueMessage({ - env: authenticatedEnvProd, - message: { - ...messageProd, - runId: "r1235", - queue: "task/my-task-2", - }, - masterQueues: "main", - reserveConcurrency: { - messageId: messageProd.runId, - recursiveQueue: false, // It will only be in the env reserve concurrency set - }, - }); - - expect(await queue.reserveConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); - expect(await queue.reserveConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 0 - ); - - // Now we can dequeue the second message - const message2 = await queue.dequeueMessageFromMasterQueue("test_12345", "main", 1); - expect(message2.length).toBe(1); - expect(message2[0].message.runId).toBe("r1235"); - - // Now lets assert the concurrencies - expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 1 - ); - expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, "task/my-task-2")).toBe( - 1 - ); - expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(2); - expect(await queue.reserveConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); - expect(await queue.reserveConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 0 - ); - - //reacquire the concurrency - const result = await queue.reacquireConcurrency( - authenticatedEnvProd.organization.id, - messageProd.runId - ); - expect(result).toBe(true); - - //concurrencies - expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 1 - ); - expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, "task/my-task-2")).toBe( - 1 - ); - expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(2); - expect(await queue.reserveConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); - expect(await queue.reserveConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 0 - ); - } finally { - await queue.quit(); - } - } - ); - redisTest( "It should false if the run is not in the current concurrency set and there is no capacity in the environment", async ({ redisContainer }) => { @@ -398,10 +293,6 @@ describe("RunQueue.reacquireConcurrency", () => { 0 ); expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); - expect(await queue.reserveConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(0); - expect(await queue.reserveConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( - 0 - ); } finally { await queue.quit(); } diff --git a/internal-packages/run-engine/src/run-queue/types.ts b/internal-packages/run-engine/src/run-queue/types.ts index d74bf5e382..12627f375e 100644 --- a/internal-packages/run-engine/src/run-queue/types.ts +++ b/internal-packages/run-engine/src/run-queue/types.ts @@ -57,7 +57,6 @@ export interface RunQueueKeyProducer { queue: string, concurrencyKey?: string ): string; - reserveConcurrencyKey(env: MinimalAuthenticatedEnvironment, queue: string): string; disabledConcurrencyLimitKeyFromQueue(queue: string): string; //env oncurrency envCurrentConcurrencyKey(env: EnvDescriptor): string; @@ -66,9 +65,6 @@ export interface RunQueueKeyProducer { envConcurrencyLimitKey(env: EnvDescriptor): string; envConcurrencyLimitKey(env: MinimalAuthenticatedEnvironment): string; - envReserveConcurrencyKey(env: EnvDescriptor): string; - envReserveConcurrencyKey(env: MinimalAuthenticatedEnvironment): string; - envConcurrencyLimitKeyFromQueue(queue: string): string; envCurrentConcurrencyKeyFromQueue(queue: string): string; //message payload @@ -80,9 +76,6 @@ export interface RunQueueKeyProducer { projectIdFromQueue(queue: string): string; descriptorFromQueue(queue: string): QueueDescriptor; - reserveConcurrencyKeyFromQueue(queue: string): string; - envReserveConcurrencyKeyFromQueue(queue: string): string; - deadLetterQueueKey(env: MinimalAuthenticatedEnvironment): string; deadLetterQueueKey(env: EnvDescriptor): string; deadLetterQueueKeyFromQueue(queue: string): string; diff --git a/references/test-tasks/src/utils.ts b/references/test-tasks/src/utils.ts index 6bbbc38ab8..5fa43866d7 100644 --- a/references/test-tasks/src/utils.ts +++ b/references/test-tasks/src/utils.ts @@ -74,9 +74,7 @@ const EnvironmentStatsResponseBody = z.object({ id: z.string(), concurrencyLimit: z.number(), currentConcurrency: z.number(), - reserveConcurrency: z.number(), queueConcurrency: z.number().optional(), - queueReserveConcurrency: z.number().optional(), queueCurrentConcurrency: z.number().optional(), }); From 7d11e827d9448accb4c823f4db0f9b0c60bdcff9 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 14:15:23 +0000 Subject: [PATCH 22/38] Remove reserve concurrency system from run engine --- internal-packages/redis/src/index.ts | 2 +- .../run-engine/src/engine/index.ts | 133 +++---------- .../src/engine/tests/delays.test.ts | 175 ------------------ internal-packages/run-engine/vitest.config.ts | 1 - 4 files changed, 30 insertions(+), 281 deletions(-) diff --git a/internal-packages/redis/src/index.ts b/internal-packages/redis/src/index.ts index 13264773b9..546e1c1b29 100644 --- a/internal-packages/redis/src/index.ts +++ b/internal-packages/redis/src/index.ts @@ -8,7 +8,7 @@ const defaultOptions: Partial = { const delay = Math.min(times * 50, 1000); return delay; }, - maxRetriesPerRequest: 20, + maxRetriesPerRequest: process.env.VITEST ? 1 : 20, }; const logger = new Logger("Redis", "debug"); diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 0284c2459e..ee7cbda636 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -51,7 +51,7 @@ import { nanoid } from "nanoid"; import { EventEmitter } from "node:events"; import { z } from "zod"; import { FairQueueSelectionStrategy } from "../run-queue/fairQueueSelectionStrategy.js"; -import { RunQueue, RunQueueReserveConcurrencyOptions } from "../run-queue/index.js"; +import { RunQueue } from "../run-queue/index.js"; import { RunQueueFullKeyProducer } from "../run-queue/keyProducer.js"; import { MinimalAuthenticatedEnvironment } from "../shared/index.js"; import { MAX_TASK_RUN_ATTEMPTS } from "./consts.js"; @@ -482,8 +482,6 @@ export class RunEngine { completedByTaskRunId: taskRun.id, }); - let reserveConcurrencyOptions: RunQueueReserveConcurrencyOptions | undefined; - //triggerAndWait or batchTriggerAndWait if (resumeParentOnCompletion && parentTaskRunId) { //this will block the parent run from continuing until this waitpoint is completed (and removed) @@ -497,23 +495,8 @@ export class RunEngine { workerId, runnerId, tx: prisma, + releaseConcurrency: true, // TODO: This needs to use the release concurrency system }); - - const parentRun = await prisma.taskRun.findFirst({ - select: { - queue: true, - }, - where: { - id: parentTaskRunId, - }, - }); - - if (parentRun) { - reserveConcurrencyOptions = { - messageId: parentTaskRunId, - recursiveQueue: parentRun?.queue === taskRun.queue, - }; - } } //Make sure lock extension succeeded @@ -588,29 +571,16 @@ export class RunEngine { availableAt: taskRun.delayUntil, }); } else { - const { wasEnqueued, error } = await this.#enqueueRun({ + await this.#enqueueRun({ run: taskRun, env: environment, timestamp: Date.now() - taskRun.priorityMs, workerId, runnerId, tx: prisma, - reserveConcurrency: reserveConcurrencyOptions, }); - if (error) { - // Fail the run immediately - taskRun = await prisma.taskRun.update({ - where: { id: taskRun.id }, - data: { - status: runStatusFromError(error), - completedAt: new Date(), - error, - }, - }); - } - - if (wasEnqueued && taskRun.ttl) { + if (taskRun.ttl) { const expireAt = parseNaturalLanguageDuration(taskRun.ttl); if (expireAt) { @@ -1560,9 +1530,7 @@ export class RunEngine { }); //remove it from the queue and release concurrency - await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId, { - messageId: run.parentTaskRunId ?? undefined, - }); + await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); //if executing, we need to message the worker to cancel the run and put it into `PENDING_CANCEL` status if (isExecuting(latestSnapshot.executionStatus)) { @@ -2665,6 +2633,7 @@ export class RunEngine { async quit() { try { //stop the run queue + await this.releaseConcurrencyQueue.quit(); await this.runQueue.quit(); await this.worker.stop(); await this.runLock.quit(); @@ -2797,9 +2766,7 @@ export class RunEngine { }, }); - await this.runQueue.acknowledgeMessage(updatedRun.runtimeEnvironment.organizationId, runId, { - messageId: updatedRun.parentTaskRunId ?? undefined, - }); + await this.runQueue.acknowledgeMessage(updatedRun.runtimeEnvironment.organizationId, runId); if (!updatedRun.associatedWaitpoint) { throw new ServiceValidationError("No associated waitpoint found", 400); @@ -2942,9 +2909,7 @@ export class RunEngine { }); const newSnapshot = await getLatestExecutionSnapshot(prisma, runId); - await this.runQueue.acknowledgeMessage(run.project.organizationId, runId, { - messageId: run.parentTaskRunId ?? undefined, - }); + await this.runQueue.acknowledgeMessage(run.project.organizationId, runId); // We need to manually emit this as we created the final snapshot as part of the task run update this.eventBus.emit("executionSnapshotCreated", { @@ -3293,9 +3258,7 @@ export class RunEngine { throw new ServiceValidationError("No associated waitpoint found", 400); } - await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId, { - messageId: run.parentTaskRunId ?? undefined, - }); + await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); await this.completeWaitpoint({ id: run.associatedWaitpoint.id, @@ -3339,7 +3302,6 @@ export class RunEngine { completedWaitpoints, workerId, runnerId, - reserveConcurrency, }: { run: TaskRun; env: MinimalAuthenticatedEnvironment; @@ -3357,11 +3319,10 @@ export class RunEngine { }[]; workerId?: string; runnerId?: string; - reserveConcurrency?: RunQueueReserveConcurrencyOptions; - }): Promise<{ wasEnqueued: boolean; error?: TaskRunError }> { + }): Promise { const prisma = tx ?? this.prisma; - return await this.runLock.lock([run.id], 5000, async (signal) => { + await this.runLock.lock([run.id], 5000, async (signal) => { const newSnapshot = await this.#createExecutionSnapshot(prisma, { run, snapshot: { @@ -3382,7 +3343,7 @@ export class RunEngine { masterQueues.push(run.secondaryMasterQueue); } - const wasEnqueued = await this.runQueue.enqueueMessage({ + await this.runQueue.enqueueMessage({ env, masterQueues, message: { @@ -3397,21 +3358,7 @@ export class RunEngine { timestamp, attempt: 0, }, - reserveConcurrency, }); - - if (!wasEnqueued) { - return { - wasEnqueued: false, - error: { - type: "INTERNAL_ERROR", - code: TaskRunErrorCodes.RECURSIVE_WAIT_DEADLOCK, - message: `This run will never execute because it was triggered recursively and the task has no remaining concurrency available`, - } satisfies TaskRunError, - }; - } - - return { wasEnqueued }; }); } @@ -3626,54 +3573,32 @@ export class RunEngine { throw new Error(`#enqueueDelayedRun: run not found: ${runId}`); } - let reserveConcurrency: RunQueueReserveConcurrencyOptions | undefined; - - if (run.parentTaskRunId) { - const parentRun = await this.prisma.taskRun.findFirst({ - where: { id: run.parentTaskRunId }, - }); - - if (parentRun) { - reserveConcurrency = { - messageId: parentRun.id, - recursiveQueue: parentRun.queue === run.queue, - }; - } - } - // Now we need to enqueue the run into the RunQueue - const { wasEnqueued, error } = await this.#enqueueRun({ + await this.#enqueueRun({ run, env: run.runtimeEnvironment, timestamp: run.createdAt.getTime() - run.priorityMs, - reserveConcurrency, batchId: run.batchId ?? undefined, }); - if (error) { - await this.#permanentlyFailRun({ runId, error, failedAt: new Date() }); - } - - if (wasEnqueued) { - await this.prisma.taskRun.update({ - where: { id: runId }, - data: { - status: "PENDING", - queuedAt: new Date(), - }, - }); + await this.prisma.taskRun.update({ + where: { id: runId }, + data: { + status: "PENDING", + queuedAt: new Date(), + }, + }); - if (run.ttl) { - const expireAt = parseNaturalLanguageDuration(run.ttl); + if (run.ttl) { + const expireAt = parseNaturalLanguageDuration(run.ttl); - if (expireAt) { - await this.worker.enqueue({ - id: `expireRun:${runId}`, - job: "expireRun", - payload: { runId }, - availableAt: expireAt, - }); - } + if (expireAt) { + await this.worker.enqueue({ + id: `expireRun:${runId}`, + job: "expireRun", + payload: { runId }, + availableAt: expireAt, + }); } } } diff --git a/internal-packages/run-engine/src/engine/tests/delays.test.ts b/internal-packages/run-engine/src/engine/tests/delays.test.ts index 8dd24bf7e7..655b01941d 100644 --- a/internal-packages/run-engine/src/engine/tests/delays.test.ts +++ b/internal-packages/run-engine/src/engine/tests/delays.test.ts @@ -295,179 +295,4 @@ describe("RunEngine delays", () => { engine.quit(); } }); - - containerTest( - "Delayed run that fails to enqueue because of a recursive deadlock issue", - 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, - }, - tracer: trace.getTracer("test", "0.0.0"), - }); - - try { - const parentTask = "parent-task"; - const childTask = "child-task"; - - //create background worker - await setupBackgroundWorker(prisma, authenticatedEnvironment, [parentTask, childTask]); - - //trigger the run - const parentRun = await engine.trigger( - { - number: 1, - friendlyId: "run_p1234", - environment: authenticatedEnvironment, - taskIdentifier: parentTask, - payload: "{}", - payloadType: "application/json", - context: {}, - traceContext: {}, - traceId: "t12345", - spanId: "s12345", - masterQueue: "main", - queueName: "shared-queue", - queue: { - concurrencyLimit: 1, - }, - isTest: false, - tags: [], - }, - prisma - ); - - const dequeued = await engine.dequeueFromMasterQueue({ - consumerId: "test_12345", - masterQueue: parentRun.masterQueue, - maxRunCount: 10, - }); - - expect(dequeued.length).toBe(1); - - const initialExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); - assertNonNullable(initialExecutionData); - const attemptResult = await engine.startRunAttempt({ - runId: parentRun.id, - snapshotId: initialExecutionData.snapshot.id, - }); - - expect(attemptResult).toBeDefined(); - - const childRun = await engine.trigger( - { - number: 1, - friendlyId: "run_c1234", - environment: authenticatedEnvironment, - taskIdentifier: childTask, - payload: "{}", - payloadType: "application/json", - context: {}, - traceContext: {}, - traceId: "t12345", - spanId: "s12345", - masterQueue: "main", - queueName: "shared-queue", - queue: { - concurrencyLimit: 1, - }, - isTest: false, - tags: [], - resumeParentOnCompletion: true, - parentTaskRunId: parentRun.id, - }, - prisma - ); - - const childExecutionData = await engine.getRunExecutionData({ runId: childRun.id }); - assertNonNullable(childExecutionData); - expect(childExecutionData.snapshot.executionStatus).toBe("QUEUED"); - - const parentExecutionData = await engine.getRunExecutionData({ runId: parentRun.id }); - assertNonNullable(parentExecutionData); - expect(parentExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); - - //dequeue the child run - const dequeuedChild = await engine.dequeueFromMasterQueue({ - consumerId: "test_12345", - masterQueue: childRun.masterQueue, - maxRunCount: 10, - }); - - expect(dequeuedChild.length).toBe(1); - - // Now try and trigger another child run on the same queue - const childRun2 = await engine.trigger( - { - number: 1, - friendlyId: "run_c12345", - environment: authenticatedEnvironment, - taskIdentifier: childTask, - payload: "{}", - payloadType: "application/json", - context: {}, - traceContext: {}, - traceId: "t12345_2", - spanId: "s12345_2", - masterQueue: "main", - queueName: "shared-queue", - queue: { - concurrencyLimit: 1, - }, - isTest: false, - tags: [], - resumeParentOnCompletion: true, - parentTaskRunId: parentRun.id, - delayUntil: new Date(Date.now() + 1000), - }, - prisma - ); - - const executionData = await engine.getRunExecutionData({ runId: childRun2.id }); - assertNonNullable(executionData); - expect(executionData.snapshot.executionStatus).toBe("RUN_CREATED"); - - await setTimeout(1_500); - - // Now the run should be failed - const run2 = await prisma.taskRun.findFirstOrThrow({ - where: { id: childRun2.id }, - }); - - expect(run2.status).toBe("COMPLETED_WITH_ERRORS"); - expect(run2.error).toEqual({ - type: "INTERNAL_ERROR", - code: TaskRunErrorCodes.RECURSIVE_WAIT_DEADLOCK, - message: expect.any(String), - }); - } finally { - engine.quit(); - } - } - ); }); diff --git a/internal-packages/run-engine/vitest.config.ts b/internal-packages/run-engine/vitest.config.ts index 0c2cb6798f..1d779c0957 100644 --- a/internal-packages/run-engine/vitest.config.ts +++ b/internal-packages/run-engine/vitest.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - reporters: process.env.GITHUB_ACTIONS ? ["verbose", "github-actions"] : ["verbose"], include: ["**/*.test.ts"], globals: true, isolate: true, From 1b61b95c573da16112ea04a305c90a7265a777bd Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 15:59:20 +0000 Subject: [PATCH 23/38] WIP run engine systems --- internal-packages/run-engine/README.md | 121 +- .../run-engine/src/engine/eventBus.ts | 31 + .../src/engine/executionSnapshots.ts | 131 - .../run-engine/src/engine/index.ts | 2356 +++-------------- .../src/engine/systems/batchSystem.ts | 94 + .../src/engine/systems/dequeueSystem.ts | 583 ++++ .../engine/systems/executionSnapshotSystem.ts | 335 +++ .../src/engine/systems/runAttemptSystem.ts | 897 +++++++ .../src/engine/systems/waitpointSystem.ts | 150 ++ .../run-engine/src/engine/types.ts | 9 +- .../run-engine/src/engine/workerCatalog.ts | 56 + 11 files changed, 2698 insertions(+), 2065 deletions(-) delete mode 100644 internal-packages/run-engine/src/engine/executionSnapshots.ts create mode 100644 internal-packages/run-engine/src/engine/systems/batchSystem.ts create mode 100644 internal-packages/run-engine/src/engine/systems/dequeueSystem.ts create mode 100644 internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts create mode 100644 internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts create mode 100644 internal-packages/run-engine/src/engine/systems/waitpointSystem.ts create mode 100644 internal-packages/run-engine/src/engine/workerCatalog.ts diff --git a/internal-packages/run-engine/README.md b/internal-packages/run-engine/README.md index a2ca8fda22..766ad082b3 100644 --- a/internal-packages/run-engine/README.md +++ b/internal-packages/run-engine/README.md @@ -24,6 +24,7 @@ It is responsible for: Many operations on the run are "atomic" in the sense that only a single operation can mutate them at a time. We use RedLock to create a distributed lock to ensure this. Postgres locking is not enough on its own because we have multiple API instances and Redis is used for the queue. There are race conditions we need to deal with: + - When checkpointing the run continues to execute until the checkpoint has been stored. At the same time the run continues and the checkpoint can become irrelevant if the waitpoint is completed. Both can happen at the same time, so we must lock the run and protect against outdated checkpoints. ## Run execution @@ -41,6 +42,7 @@ We can also store invalid states by setting an error. These invalid states are p ## Workers A worker is a server that runs tasks. There are two types of workers: + - Hosted workers (serverless, managed and cloud-only) - Self-hosted workers @@ -67,6 +69,7 @@ If there's only a `workerGroup`, we can just `dequeueFromMasterQueue()` to get r This is a fair multi-tenant queue. It is designed to fairly select runs, respect concurrency limits, and have high throughput. It provides visibility into the current concurrency for the env, org, etc. It has built-in reliability features: + - When nacking we increment the `attempt` and if it continually fails we will move it to a Dead Letter Queue (DLQ). - If a run is in the DLQ you can redrive it. @@ -87,23 +90,26 @@ A single Waitpoint can block many runs, the same waitpoint can only block a run They can have output data associated with them, e.g. the finished run payload. That includes an error, e.g. a failed run. There are currently three types: - - `RUN` which gets completed when the associated run completes. Every run has an `associatedWaitpoint` that matches the lifetime of the run. - - `DATETIME` which gets completed when the datetime is reached. - - `MANUAL` which gets completed when that event occurs. + +- `RUN` which gets completed when the associated run completes. Every run has an `associatedWaitpoint` that matches the lifetime of the run. +- `DATETIME` which gets completed when the datetime is reached. +- `MANUAL` which gets completed when that event occurs. Waitpoints can have an idempotencyKey which allows stops them from being created multiple times. This is especially useful for event waitpoints, where you don't want to create a new waitpoint for the same event twice. ### `wait.for()` or `wait.until()` + Wait for a future time, then continue. We should add the option to pass an `idempotencyKey` so a second attempt doesn't wait again. By default it would wait again. ```ts //Note if the idempotency key is a string, it will get prefixed with the run id. //you can explicitly pass in an idempotency key created with the the global scope. -await wait.until(new Date('2022-01-01T00:00:00Z'), { idempotencyKey: "first-wait" }); -await wait.until(new Date('2022-01-01T00:00:00Z'), { idempotencyKey: "second-wait" }); +await wait.until(new Date("2022-01-01T00:00:00Z"), { idempotencyKey: "first-wait" }); +await wait.until(new Date("2022-01-01T00:00:00Z"), { idempotencyKey: "second-wait" }); ``` ### `triggerAndWait()` or `batchTriggerAndWait()` + Trigger and then wait for run(s) to finish. If the run fails it will still continue but with the errors so the developer can decide what to do. ### The `trigger` `delay` option @@ -111,6 +117,7 @@ Trigger and then wait for run(s) to finish. If the run fails it will still conti When triggering a run and passing the `delay` option, we use a `DATETIME` waitpoint to block the run from starting. ### `wait.forRequest()` + Wait until a request has been received at the URL that you are given. This is useful for pausing a run and then continuing it again when some external event occurs on another service. For example, Replicate have an API where they will callback when their work is complete. ### `wait.forWaitpoint(waitpointId)` @@ -155,6 +162,7 @@ When `trigger` is called the run is added to the queue. We only dequeue when the When `trigger` is called, we check if the rate limit has been exceeded. If it has then we ignore the trigger. The run is thrown away and an appropriate error is returned. This is useful: + - To prevent abuse. - To control how many executions a user can do (using a `key` with rate limiting). @@ -163,6 +171,7 @@ This is useful: When `trigger` is called, we prevent too many runs happening in a period by collapsing into a single run. This is done by discarding some runs in a period. This is useful: + - To prevent too many runs happening in a short period. We should mark the run as `"DELAYED"` with the correct `delayUntil` time. This will allow the user to see that the run is delayed and why. @@ -172,6 +181,7 @@ We should mark the run as `"DELAYED"` with the correct `delayUntil` time. This w When `trigger` is called the run is added to the queue. We only run them when they don't exceed the limit in that time period, by controlling the timing of when they are dequeued. This is useful: + - To prevent too many runs happening in a short period. - To control how many executions a user can do (using a `key` with throttling). - When you need to execute every run but not too many in a short period, e.g. avoiding rate limits. @@ -181,9 +191,110 @@ This is useful: When `trigger` is called, we batch the runs together. This means the payload of the run is an array of items, each being a single payload. This is useful: + - For performance, as it reduces the number of runs in the system. - It can be useful when using 3rd party APIs that support batching. ## Emitting events The Run Engine emits events using its `eventBus`. This is used for runs completing, failing, or things that any workers should be aware of. + +# RunEngine System Architecture + +The RunEngine is composed of several specialized systems that handle different aspects of task execution and management. Below is a diagram showing the relationships between these systems. + +```mermaid +graph TD + RE[RunEngine] + DS[DequeueSystem] + RAS[RunAttemptSystem] + ESS[ExecutionSnapshotSystem] + WS[WaitpointSystem] + BS[BatchSystem] + + %% Core Dependencies + RE --> DS + RE --> RAS + RE --> ESS + RE --> WS + RE --> BS + + %% System Dependencies + DS --> ESS + DS --> RAS + + RAS --> ESS + RAS --> WS + RAS --> BS + + %% Shared Resources + subgraph Resources + PRI[(Prisma)] + LOG[Logger] + TRC[Tracer] + RQ[RunQueue] + RL[RunLocker] + EB[EventBus] + WRK[Worker] + end + + %% Resource Dependencies + RE -.-> Resources + DS -.-> PRI & LOG & TRC & RQ & RL + RAS -.-> PRI & LOG & TRC & RL & EB & RQ & WRK + ESS -.-> PRI & LOG & TRC & WRK & EB + WS -.-> PRI & LOG & TRC & WRK & EB + BS -.-> PRI & LOG & TRC & WRK +``` + +## System Responsibilities + +### DequeueSystem + +- Handles dequeuing of tasks from master queues +- Manages resource allocation and constraints +- Handles task deployment verification + +### RunAttemptSystem + +- Manages run attempt lifecycle +- Handles success/failure scenarios +- Manages retries and cancellations +- Coordinates with other systems for run completion + +### ExecutionSnapshotSystem + +- Creates and manages execution snapshots +- Tracks run state and progress +- Manages heartbeats for active runs +- Maintains execution history + +### WaitpointSystem + +- Manages waitpoints for task synchronization +- Handles waitpoint completion +- Coordinates blocked runs + +### BatchSystem + +- Manages batch operations +- Handles batch completion +- Coordinates batch-related task runs + +## Shared Resources + +- **Prisma**: Database access +- **Logger**: Logging functionality +- **Tracer**: Tracing and monitoring +- **RunQueue**: Task queue management +- **RunLocker**: Run locking mechanism +- **EventBus**: Event communication +- **Worker**: Background task execution + +## Key Interactions + +1. **RunEngine** orchestrates all systems and holds shared resources +2. **DequeueSystem** works closely with **RunAttemptSystem** for task execution +3. **RunAttemptSystem** coordinates with **WaitpointSystem** and **BatchSystem** for run completion +4. **ExecutionSnapshotSystem** is used by all other systems to track state +5. All systems share common resources but have specific responsibilities diff --git a/internal-packages/run-engine/src/engine/eventBus.ts b/internal-packages/run-engine/src/engine/eventBus.ts index 0ad687f27c..c64d0b2c11 100644 --- a/internal-packages/run-engine/src/engine/eventBus.ts +++ b/internal-packages/run-engine/src/engine/eventBus.ts @@ -1,6 +1,7 @@ import { TaskRunExecutionStatus, TaskRunStatus } from "@trigger.dev/database"; import { AuthenticatedEnvironment } from "../shared/index.js"; import { FlushedRunMetadata, TaskRunError } from "@trigger.dev/core/v3"; +import { EventEmitter } from "events"; export type EventBusEvents = { runAttemptStarted: [ @@ -178,3 +179,33 @@ export type EventBusEvents = { }; export type EventBusEventArgs = EventBusEvents[T]; + +export type EventBus = EventEmitter; + +/** + * Sends a notification that a run has changed and we need to fetch the latest run state. + * The worker will call `getRunExecutionData` via the API and act accordingly. + */ +export async function sendNotificationToWorker({ + runId, + snapshot, + eventBus, +}: { + runId: string; + snapshot: { + id: string; + executionStatus: TaskRunExecutionStatus; + }; + eventBus: EventBus; +}) { + eventBus.emit("workerNotification", { + time: new Date(), + run: { + id: runId, + }, + snapshot: { + id: snapshot.id, + executionStatus: snapshot.executionStatus, + }, + }); +} diff --git a/internal-packages/run-engine/src/engine/executionSnapshots.ts b/internal-packages/run-engine/src/engine/executionSnapshots.ts deleted file mode 100644 index 3f2cec09a3..0000000000 --- a/internal-packages/run-engine/src/engine/executionSnapshots.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { CompletedWaitpoint, ExecutionResult } from "@trigger.dev/core/v3"; -import { BatchId, RunId, SnapshotId } from "@trigger.dev/core/v3/isomorphic"; -import { - PrismaClientOrTransaction, - TaskRunCheckpoint, - TaskRunExecutionSnapshot, -} from "@trigger.dev/database"; - -interface LatestExecutionSnapshot extends TaskRunExecutionSnapshot { - friendlyId: string; - runFriendlyId: string; - checkpoint: TaskRunCheckpoint | null; - completedWaitpoints: CompletedWaitpoint[]; -} - -/* Gets the most recent valid snapshot for a run */ -export async function getLatestExecutionSnapshot( - prisma: PrismaClientOrTransaction, - runId: string -): Promise { - const snapshot = await prisma.taskRunExecutionSnapshot.findFirst({ - where: { runId, isValid: true }, - include: { - completedWaitpoints: true, - checkpoint: true, - }, - orderBy: { createdAt: "desc" }, - }); - - if (!snapshot) { - throw new Error(`No execution snapshot found for TaskRun ${runId}`); - } - - return { - ...snapshot, - friendlyId: SnapshotId.toFriendlyId(snapshot.id), - runFriendlyId: RunId.toFriendlyId(snapshot.runId), - completedWaitpoints: snapshot.completedWaitpoints.flatMap((w) => { - //get all indexes of the waitpoint in the completedWaitpointOrder - //we do this because the same run can be in a batch multiple times (i.e. same idempotencyKey) - let indexes: (number | undefined)[] = []; - for (let i = 0; i < snapshot.completedWaitpointOrder.length; i++) { - if (snapshot.completedWaitpointOrder[i] === w.id) { - indexes.push(i); - } - } - - if (indexes.length === 0) { - indexes.push(undefined); - } - - return indexes.map((index) => { - return { - id: w.id, - index: index === -1 ? undefined : index, - friendlyId: w.friendlyId, - type: w.type, - completedAt: w.completedAt ?? new Date(), - idempotencyKey: - w.userProvidedIdempotencyKey && !w.inactiveIdempotencyKey - ? w.idempotencyKey - : undefined, - completedByTaskRun: w.completedByTaskRunId - ? { - id: w.completedByTaskRunId, - friendlyId: RunId.toFriendlyId(w.completedByTaskRunId), - batch: snapshot.batchId - ? { - id: snapshot.batchId, - friendlyId: BatchId.toFriendlyId(snapshot.batchId), - } - : undefined, - } - : undefined, - completedAfter: w.completedAfter ?? undefined, - completedByBatch: w.completedByBatchId - ? { - id: w.completedByBatchId, - friendlyId: BatchId.toFriendlyId(w.completedByBatchId), - } - : undefined, - output: w.output ?? undefined, - outputType: w.outputType, - outputIsError: w.outputIsError, - } satisfies CompletedWaitpoint; - }); - }), - }; -} - -export async function getExecutionSnapshotCompletedWaitpoints( - prisma: PrismaClientOrTransaction, - snapshotId: string -) { - const waitpoints = await prisma.taskRunExecutionSnapshot.findFirst({ - where: { id: snapshotId }, - include: { - completedWaitpoints: true, - }, - }); - - //deduplicate waitpoints - const waitpointIds = new Set(); - return ( - waitpoints?.completedWaitpoints.filter((waitpoint) => { - if (waitpointIds.has(waitpoint.id)) { - return false; - } else { - waitpointIds.add(waitpoint.id); - return true; - } - }) ?? [] - ); -} - -export function executionResultFromSnapshot(snapshot: TaskRunExecutionSnapshot): ExecutionResult { - return { - snapshot: { - id: snapshot.id, - friendlyId: SnapshotId.toFriendlyId(snapshot.id), - executionStatus: snapshot.executionStatus, - description: snapshot.description, - }, - run: { - id: snapshot.runId, - friendlyId: RunId.toFriendlyId(snapshot.runId), - status: snapshot.runStatus, - attemptNumber: snapshot.attemptNumber, - }, - }; -} diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index ee7cbda636..7ce4b2b869 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -1,7 +1,6 @@ import { createRedisClient, Redis } from "@internal/redis"; import { Worker } from "@internal/redis-worker"; -import { Attributes, Span, SpanKind, trace, Tracer } from "@internal/tracing"; -import { assertExhaustive } from "@trigger.dev/core"; +import { startSpan, trace, Tracer } from "@internal/tracing"; import { Logger } from "@trigger.dev/core/logger"; import { CheckpointInput, @@ -11,27 +10,19 @@ import { ExecutionResult, MachineResources, parsePacket, - RetryOptions, RunExecutionData, StartRunAttemptResult, TaskRunError, - TaskRunErrorCodes, TaskRunExecution, TaskRunExecutionResult, - TaskRunFailedExecutionResult, - TaskRunInternalError, - TaskRunSuccessfulExecutionResult, timeoutError, } from "@trigger.dev/core/v3"; import { BatchId, CheckpointId, - getMaxDuration, parseNaturalLanguageDuration, QueueId, RunId, - sanitizeQueueName, - SnapshotId, WaitpointId, } from "@trigger.dev/core/v3/isomorphic"; import { @@ -39,96 +30,40 @@ import { Prisma, PrismaClient, PrismaClientOrTransaction, - RuntimeEnvironmentType, TaskRun, TaskRunExecutionSnapshot, TaskRunExecutionStatus, - TaskRunStatus, Waitpoint, } from "@trigger.dev/database"; import { assertNever } from "assert-never"; import { nanoid } from "nanoid"; import { EventEmitter } from "node:events"; -import { z } from "zod"; import { FairQueueSelectionStrategy } from "../run-queue/fairQueueSelectionStrategy.js"; import { RunQueue } from "../run-queue/index.js"; import { RunQueueFullKeyProducer } from "../run-queue/keyProducer.js"; import { MinimalAuthenticatedEnvironment } from "../shared/index.js"; import { MAX_TASK_RUN_ATTEMPTS } from "./consts.js"; -import { getRunWithBackgroundWorkerTasks } from "./db/worker.js"; -import { runStatusFromError } from "./errors.js"; -import { EventBusEvents } from "./eventBus.js"; -import { executionResultFromSnapshot, getLatestExecutionSnapshot } from "./executionSnapshots.js"; +import { EventBus, EventBusEvents, sendNotificationToWorker } from "./eventBus.js"; import { RunLocker } from "./locking.js"; import { getMachinePreset } from "./machinePresets.js"; -import { retryOutcomeFromCompletion } from "./retrying.js"; +import { ReleaseConcurrencyTokenBucketQueue } from "./releaseConcurrencyTokenBucketQueue.js"; import { canReleaseConcurrency, isCheckpointable, - isDequeueableExecutionStatus, isExecuting, - isFinalRunStatus, isPendingExecuting, } from "./statuses.js"; -import { HeartbeatTimeouts, RunEngineOptions, TriggerParams } from "./types.js"; -import { ReleaseConcurrencyTokenBucketQueue } from "./releaseConcurrencyTokenBucketQueue.js"; - -const workerCatalog = { - finishWaitpoint: { - schema: z.object({ - waitpointId: z.string(), - error: z.string().optional(), - }), - visibilityTimeoutMs: 5000, - }, - heartbeatSnapshot: { - schema: z.object({ - runId: z.string(), - snapshotId: z.string(), - }), - visibilityTimeoutMs: 5000, - }, - expireRun: { - schema: z.object({ - runId: z.string(), - }), - visibilityTimeoutMs: 5000, - }, - cancelRun: { - schema: z.object({ - runId: z.string(), - completedAt: z.coerce.date(), - reason: z.string().optional(), - }), - visibilityTimeoutMs: 5000, - }, - queueRunsWaitingForWorker: { - schema: z.object({ - backgroundWorkerId: z.string(), - }), - visibilityTimeoutMs: 5000, - }, - tryCompleteBatch: { - schema: z.object({ - batchId: z.string(), - }), - visibilityTimeoutMs: 10_000, - }, - continueRunIfUnblocked: { - schema: z.object({ - runId: z.string(), - }), - visibilityTimeoutMs: 10_000, - }, - enqueueDelayedRun: { - schema: z.object({ - runId: z.string(), - }), - visibilityTimeoutMs: 10_000, - }, -}; - -type EngineWorker = Worker; +import { BatchSystem } from "./systems/batchSystem.js"; +import { DequeueSystem } from "./systems/dequeueSystem.js"; +import { + executionResultFromSnapshot, + ExecutionSnapshotSystem, + getLatestExecutionSnapshot, +} from "./systems/executionSnapshotSystem.js"; +import { RunAttemptSystem } from "./systems/runAttemptSystem.js"; +import { WaitpointSystem } from "./systems/waitpointSystem.js"; +import { EngineWorker, HeartbeatTimeouts, RunEngineOptions, TriggerParams } from "./types.js"; +import { workerCatalog } from "./workerCatalog.js"; export class RunEngine { private runLockRedis: Redis; @@ -144,7 +79,12 @@ export class RunEngine { projectId: string; envId: string; }>; - eventBus = new EventEmitter(); + eventBus: EventBus = new EventEmitter(); + executionSnapshotSystem: ExecutionSnapshotSystem; + runAttemptSystem: RunAttemptSystem; + dequeueSystem: DequeueSystem; + waitpointSystem: WaitpointSystem; + batchSystem: BatchSystem; constructor(private readonly options: RunEngineOptions) { this.prisma = options.prisma; @@ -211,7 +151,7 @@ export class RunEngine { await this.#expireRun({ runId: payload.runId }); }, cancelRun: async ({ payload }) => { - await this.cancelRun({ + await this.runAttemptSystem.cancelRun({ runId: payload.runId, completedAt: payload.completedAt, reason: payload.reason, @@ -221,7 +161,7 @@ export class RunEngine { await this.#queueRunsWaitingForWorker({ backgroundWorkerId: payload.backgroundWorkerId }); }, tryCompleteBatch: async ({ payload }) => { - await this.#tryCompleteBatch({ batchId: payload.batchId }); + await this.batchSystem.performCompleteBatch({ batchId: payload.batchId }); }, continueRunIfUnblocked: async ({ payload }) => { await this.#continueRunIfUnblocked({ @@ -291,6 +231,54 @@ export class RunEngine { }, tracer: this.tracer, }); + + this.executionSnapshotSystem = new ExecutionSnapshotSystem({ + worker: this.worker, + eventBus: this.eventBus, + heartbeatTimeouts: this.heartbeatTimeouts, + prisma: this.prisma, + logger: this.logger, + tracer: this.tracer, + }); + + this.waitpointSystem = new WaitpointSystem({ + prisma: this.prisma, + worker: this.worker, + eventBus: this.eventBus, + logger: this.logger, + tracer: this.tracer, + }); + + this.batchSystem = new BatchSystem({ + prisma: this.prisma, + logger: this.logger, + tracer: this.tracer, + worker: this.worker, + }); + + this.runAttemptSystem = new RunAttemptSystem({ + prisma: this.prisma, + logger: this.logger, + tracer: this.tracer, + runLock: this.runLock, + eventBus: this.eventBus, + runQueue: this.runQueue, + worker: this.worker, + executionSnapshotSystem: this.executionSnapshotSystem, + batchSystem: this.batchSystem, + waitpointSystem: this.waitpointSystem, + }); + + this.dequeueSystem = new DequeueSystem({ + prisma: this.prisma, + queue: this.runQueue, + runLock: this.runLock, + logger: this.logger, + machines: this.options.machines, + tracer: this.tracer, + executionSnapshotSystem: this.executionSnapshotSystem, + runAttemptSystem: this.runAttemptSystem, + }); } //MARK: - Run functions @@ -346,14 +334,9 @@ export class RunEngine { ): Promise { const prisma = tx ?? this.prisma; - return this.#trace( + return startSpan( + this.tracer, "trigger", - { - friendlyId, - environmentId: environment.id, - projectId: environment.project.id, - taskIdentifier, - }, async (span) => { const status = delayUntil ? "DELAYED" : "PENDING"; @@ -596,6 +579,14 @@ export class RunEngine { }); return taskRun; + }, + { + attributes: { + friendlyId, + environmentId: environment.id, + projectId: environment.project.id, + taskIdentifier, + }, } ); } @@ -625,437 +616,15 @@ export class RunEngine { runnerId?: string; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; - return this.#trace("dequeueFromMasterQueue", { consumerId, masterQueue }, async (span) => { - //gets multiple runs from the queue - const messages = await this.runQueue.dequeueMessageFromMasterQueue( - consumerId, - masterQueue, - maxRunCount - ); - if (messages.length === 0) { - return []; - } - - //we can't send more than the max resources - const consumedResources: MachineResources = { - cpu: 0, - memory: 0, - }; - - const dequeuedRuns: DequeuedMessage[] = []; - - for (const message of messages) { - const orgId = message.message.orgId; - const runId = message.messageId; - - span.setAttribute("runId", runId); - - //lock the run so nothing else can modify it - try { - const dequeuedRun = await this.runLock.lock([runId], 5000, async (signal) => { - const snapshot = await getLatestExecutionSnapshot(prisma, runId); - - if (!isDequeueableExecutionStatus(snapshot.executionStatus)) { - //create a failed snapshot - await this.#createExecutionSnapshot(prisma, { - run: { - id: snapshot.runId, - status: snapshot.runStatus, - }, - snapshot: { - executionStatus: snapshot.executionStatus, - description: - "Tried to dequeue a run that is not in a valid state to be dequeued.", - }, - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - checkpointId: snapshot.checkpointId ?? undefined, - completedWaitpoints: snapshot.completedWaitpoints, - error: `Tried to dequeue a run that is not in a valid state to be dequeued.`, - workerId, - runnerId, - }); - - //todo is there a way to recover this, so the run can be retried? - //for example should we update the status to a dequeuable status and nack it? - //then at least it has a chance of succeeding and we have the error log above - await this.#systemFailure({ - runId, - error: { - type: "INTERNAL_ERROR", - code: "TASK_DEQUEUED_INVALID_STATE", - message: `Task was in the ${snapshot.executionStatus} state when it was dequeued for execution.`, - }, - tx: prisma, - }); - this.logger.error( - `RunEngine.dequeueFromMasterQueue(): Run is not in a valid state to be dequeued: ${runId}\n ${snapshot.id}:${snapshot.executionStatus}` - ); - return null; - } - - const result = await getRunWithBackgroundWorkerTasks(prisma, runId, backgroundWorkerId); - - if (!result.success) { - switch (result.code) { - case "NO_RUN": { - //this should not happen, the run is unrecoverable so we'll ack it - this.logger.error("RunEngine.dequeueFromMasterQueue(): No run found", { - runId, - latestSnapshot: snapshot.id, - }); - await this.runQueue.acknowledgeMessage(orgId, runId); - return null; - } - case "NO_WORKER": - case "TASK_NEVER_REGISTERED": - case "TASK_NOT_IN_LATEST": { - this.logger.warn(`RunEngine.dequeueFromMasterQueue(): ${result.code}`, { - runId, - latestSnapshot: snapshot.id, - result, - }); - - //not deployed yet, so we'll wait for the deploy - await this.#waitingForDeploy({ - orgId, - runId, - reason: result.message, - tx: prisma, - }); - return null; - } - case "BACKGROUND_WORKER_MISMATCH": { - this.logger.warn( - "RunEngine.dequeueFromMasterQueue(): Background worker mismatch", - { - runId, - latestSnapshot: snapshot.id, - result, - } - ); - - //worker mismatch so put it back in the queue - await this.runQueue.nackMessage({ orgId, messageId: runId }); - - return null; - } - default: { - assertExhaustive(result); - } - } - } - - //check for a valid deployment if it's not a development environment - if (result.run.runtimeEnvironment.type !== "DEVELOPMENT") { - if (!result.deployment || !result.deployment.imageReference) { - this.logger.warn("RunEngine.dequeueFromMasterQueue(): No deployment found", { - runId, - latestSnapshot: snapshot.id, - result, - }); - //not deployed yet, so we'll wait for the deploy - await this.#waitingForDeploy({ - orgId, - runId, - reason: "No deployment or deployment image reference found for deployed run", - tx: prisma, - }); - - return null; - } - } - - const machinePreset = getMachinePreset({ - machines: this.options.machines.machines, - defaultMachine: this.options.machines.defaultMachine, - config: result.task.machineConfig ?? {}, - run: result.run, - }); - - //increment the consumed resources - consumedResources.cpu += machinePreset.cpu; - consumedResources.memory += machinePreset.memory; - - //are we under the limit? - if (maxResources) { - if ( - consumedResources.cpu > maxResources.cpu || - consumedResources.memory > maxResources.memory - ) { - this.logger.debug( - "RunEngine.dequeueFromMasterQueue(): Consumed resources over limit, nacking", - { - runId, - consumedResources, - maxResources, - } - ); - - //put it back in the queue where it was - await this.runQueue.nackMessage({ - orgId, - messageId: runId, - incrementAttemptCount: false, - retryAt: result.run.createdAt.getTime() - result.run.priorityMs, - }); - return null; - } - } - - // Check max attempts that can optionally be set when triggering a run - let maxAttempts: number | null | undefined = result.run.maxAttempts; - - // If it's not set, we'll grab it from the task's retry config - if (!maxAttempts) { - const retryConfig = result.task.retryConfig; - - this.logger.debug( - "RunEngine.dequeueFromMasterQueue(): maxAttempts not set, using task's retry config", - { - runId, - task: result.task.id, - rawRetryConfig: retryConfig, - } - ); - - const parsedConfig = RetryOptions.nullable().safeParse(retryConfig); - - if (!parsedConfig.success) { - this.logger.error("RunEngine.dequeueFromMasterQueue(): Invalid retry config", { - runId, - task: result.task.id, - rawRetryConfig: retryConfig, - }); - - await this.#systemFailure({ - runId, - error: { - type: "INTERNAL_ERROR", - code: "TASK_DEQUEUED_INVALID_RETRY_CONFIG", - message: `Invalid retry config: ${retryConfig}`, - }, - tx: prisma, - }); - - return null; - } - - if (!parsedConfig.data) { - this.logger.error("RunEngine.dequeueFromMasterQueue(): No retry config", { - runId, - task: result.task.id, - rawRetryConfig: retryConfig, - }); - - await this.#systemFailure({ - runId, - error: { - type: "INTERNAL_ERROR", - code: "TASK_DEQUEUED_NO_RETRY_CONFIG", - message: `No retry config found`, - }, - tx: prisma, - }); - - return null; - } - - maxAttempts = parsedConfig.data.maxAttempts; - } - - //update the run - const lockedTaskRun = await prisma.taskRun.update({ - where: { - id: runId, - }, - data: { - lockedAt: new Date(), - lockedById: result.task.id, - lockedToVersionId: result.worker.id, - startedAt: result.run.startedAt ?? new Date(), - baseCostInCents: this.options.machines.baseCostInCents, - machinePreset: machinePreset.name, - taskVersion: result.worker.version, - sdkVersion: result.worker.sdkVersion, - cliVersion: result.worker.cliVersion, - maxDurationInSeconds: getMaxDuration( - result.run.maxDurationInSeconds, - result.task.maxDurationInSeconds - ), - maxAttempts: maxAttempts ?? undefined, - }, - include: { - runtimeEnvironment: true, - tags: true, - }, - }); - - if (!lockedTaskRun) { - this.logger.error("RunEngine.dequeueFromMasterQueue(): Failed to lock task run", { - taskRun: result.run.id, - taskIdentifier: result.run.taskIdentifier, - deployment: result.deployment?.id, - worker: result.worker.id, - task: result.task.id, - runId, - }); - - await this.runQueue.acknowledgeMessage(orgId, runId); - return null; - } - - const queue = await prisma.taskQueue.findUnique({ - where: { - runtimeEnvironmentId_name: { - runtimeEnvironmentId: lockedTaskRun.runtimeEnvironmentId, - name: sanitizeQueueName(lockedTaskRun.queue), - }, - }, - }); - - if (!queue) { - this.logger.debug( - "RunEngine.dequeueFromMasterQueue(): queue not found, so nacking message", - { - queueMessage: message, - taskRunQueue: lockedTaskRun.queue, - runtimeEnvironmentId: lockedTaskRun.runtimeEnvironmentId, - } - ); - - //will auto-retry - const gotRequeued = await this.runQueue.nackMessage({ orgId, messageId: runId }); - if (!gotRequeued) { - await this.#systemFailure({ - runId, - error: { - type: "INTERNAL_ERROR", - code: "TASK_DEQUEUED_QUEUE_NOT_FOUND", - message: `Tried to dequeue the run but the queue doesn't exist: ${lockedTaskRun.queue}`, - }, - tx: prisma, - }); - } - - return null; - } - - const currentAttemptNumber = lockedTaskRun.attemptNumber ?? 0; - const nextAttemptNumber = currentAttemptNumber + 1; - - const newSnapshot = await this.#createExecutionSnapshot(prisma, { - run: { - id: runId, - status: snapshot.runStatus, - attemptNumber: lockedTaskRun.attemptNumber, - }, - snapshot: { - executionStatus: "PENDING_EXECUTING", - description: "Run was dequeued for execution", - }, - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - checkpointId: snapshot.checkpointId ?? undefined, - completedWaitpoints: snapshot.completedWaitpoints, - workerId, - runnerId, - }); - - return { - version: "1" as const, - dequeuedAt: new Date(), - snapshot: { - id: newSnapshot.id, - friendlyId: newSnapshot.friendlyId, - executionStatus: newSnapshot.executionStatus, - description: newSnapshot.description, - }, - image: result.deployment?.imageReference ?? undefined, - checkpoint: newSnapshot.checkpoint ?? undefined, - completedWaitpoints: snapshot.completedWaitpoints, - backgroundWorker: { - id: result.worker.id, - friendlyId: result.worker.friendlyId, - version: result.worker.version, - }, - deployment: { - id: result.deployment?.id, - friendlyId: result.deployment?.friendlyId, - }, - run: { - id: lockedTaskRun.id, - friendlyId: lockedTaskRun.friendlyId, - isTest: lockedTaskRun.isTest, - machine: machinePreset, - attemptNumber: nextAttemptNumber, - masterQueue: lockedTaskRun.masterQueue, - traceContext: lockedTaskRun.traceContext as Record, - }, - environment: { - id: lockedTaskRun.runtimeEnvironment.id, - type: lockedTaskRun.runtimeEnvironment.type, - }, - organization: { - id: orgId, - }, - project: { - id: lockedTaskRun.projectId, - }, - } satisfies DequeuedMessage; - }); - - if (dequeuedRun !== null) { - dequeuedRuns.push(dequeuedRun); - } - } catch (error) { - this.logger.error( - "RunEngine.dequeueFromMasterQueue(): Thrown error while preparing run to be run", - { - error, - runId, - } - ); - - const run = await prisma.taskRun.findFirst({ - where: { id: runId }, - include: { - runtimeEnvironment: true, - }, - }); - - if (!run) { - //this isn't ideal because we're not creating a snapshot… but we can't do much else - this.logger.error( - "RunEngine.dequeueFromMasterQueue(): Thrown error, then run not found. Nacking.", - { - runId, - orgId, - } - ); - await this.runQueue.nackMessage({ orgId, messageId: runId }); - continue; - } - - //this is an unknown error, we'll reattempt (with auto-backoff and eventually DLQ) - const gotRequeued = await this.#tryNackAndRequeue({ - run, - environment: run.runtimeEnvironment, - orgId, - error: { - type: "INTERNAL_ERROR", - code: "TASK_RUN_DEQUEUED_MAX_RETRIES", - message: `We tried to dequeue the run the maximum number of times but it wouldn't start executing`, - }, - tx: prisma, - }); - //we don't need this, but it makes it clear we're in a loop here - continue; - } - } - - return dequeuedRuns; + return this.dequeueSystem.dequeueFromMasterQueue({ + consumerId, + masterQueue, + maxRunCount, + maxResources, + backgroundWorkerId, + workerId, + runnerId, + tx, }); } @@ -1136,266 +705,273 @@ export class RunEngine { }): Promise { const prisma = tx ?? this.prisma; - return this.#trace("startRunAttempt", { runId, snapshotId }, async (span) => { - return this.runLock.lock([runId], 5000, async (signal) => { - const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); - - if (latestSnapshot.id !== snapshotId) { - //if there is a big delay between the snapshot and the attempt, the snapshot might have changed - //we just want to log because elsewhere it should have been put back into a state where it can be attempted - this.logger.warn( - "RunEngine.createRunAttempt(): snapshot has changed since the attempt was created, ignoring." - ); - throw new ServiceValidationError("Snapshot changed", 409); - } + return startSpan( + this.tracer, + "startRunAttempt", + async (span) => { + return this.runLock.lock([runId], 5000, async () => { + const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); + + if (latestSnapshot.id !== snapshotId) { + //if there is a big delay between the snapshot and the attempt, the snapshot might have changed + //we just want to log because elsewhere it should have been put back into a state where it can be attempted + this.logger.warn( + "RunEngine.createRunAttempt(): snapshot has changed since the attempt was created, ignoring." + ); + throw new ServiceValidationError("Snapshot changed", 409); + } - const environment = await this.#getAuthenticatedEnvironmentFromRun(runId, prisma); - if (!environment) { - throw new ServiceValidationError("Environment not found", 404); - } + const environment = await this.#getAuthenticatedEnvironmentFromRun(runId, prisma); + if (!environment) { + throw new ServiceValidationError("Environment not found", 404); + } - const taskRun = await prisma.taskRun.findFirst({ - where: { - id: runId, - }, - include: { - tags: true, - lockedBy: { - include: { - worker: { - select: { - id: true, - version: true, - sdkVersion: true, - cliVersion: true, - supportsLazyAttempts: true, + const taskRun = await prisma.taskRun.findFirst({ + where: { + id: runId, + }, + include: { + tags: true, + lockedBy: { + include: { + worker: { + select: { + id: true, + version: true, + sdkVersion: true, + cliVersion: true, + supportsLazyAttempts: true, + }, }, }, }, - }, - batchItems: { - include: { - batchTaskRun: true, + batchItems: { + include: { + batchTaskRun: true, + }, }, }, - }, - }); - - this.logger.debug("Creating a task run attempt", { taskRun }); - - if (!taskRun) { - throw new ServiceValidationError("Task run not found", 404); - } - - span.setAttribute("projectId", taskRun.projectId); - span.setAttribute("environmentId", taskRun.runtimeEnvironmentId); - span.setAttribute("taskRunId", taskRun.id); - span.setAttribute("taskRunFriendlyId", taskRun.friendlyId); + }); - if (taskRun.status === "CANCELED") { - throw new ServiceValidationError("Task run is cancelled", 400); - } + this.logger.debug("Creating a task run attempt", { taskRun }); - if (!taskRun.lockedBy) { - throw new ServiceValidationError("Task run is not locked", 400); - } + if (!taskRun) { + throw new ServiceValidationError("Task run not found", 404); + } - const queue = await prisma.taskQueue.findUnique({ - where: { - runtimeEnvironmentId_name: { - runtimeEnvironmentId: environment.id, - name: taskRun.queue, - }, - }, - }); + span.setAttribute("projectId", taskRun.projectId); + span.setAttribute("environmentId", taskRun.runtimeEnvironmentId); + span.setAttribute("taskRunId", taskRun.id); + span.setAttribute("taskRunFriendlyId", taskRun.friendlyId); - if (!queue) { - throw new ServiceValidationError("Queue not found", 404); - } + if (taskRun.status === "CANCELED") { + throw new ServiceValidationError("Task run is cancelled", 400); + } - //increment the attempt number (start at 1) - const nextAttemptNumber = (taskRun.attemptNumber ?? 0) + 1; + if (!taskRun.lockedBy) { + throw new ServiceValidationError("Task run is not locked", 400); + } - if (nextAttemptNumber > MAX_TASK_RUN_ATTEMPTS) { - await this.#attemptFailed({ - runId: taskRun.id, - snapshotId, - completion: { - ok: false, - id: taskRun.id, - error: { - type: "INTERNAL_ERROR", - code: "TASK_RUN_CRASHED", - message: "Max attempts reached.", + const queue = await prisma.taskQueue.findUnique({ + where: { + runtimeEnvironmentId_name: { + runtimeEnvironmentId: environment.id, + name: taskRun.queue, }, }, - tx: prisma, }); - throw new ServiceValidationError("Max attempts reached", 400); - } - this.eventBus.emit("runAttemptStarted", { - time: new Date(), - run: { - id: taskRun.id, - attemptNumber: nextAttemptNumber, - baseCostInCents: taskRun.baseCostInCents, - }, - organization: { - id: environment.organization.id, - }, - }); + if (!queue) { + throw new ServiceValidationError("Queue not found", 404); + } - const result = await $transaction( - prisma, - async (tx) => { - const run = await tx.taskRun.update({ - where: { + //increment the attempt number (start at 1) + const nextAttemptNumber = (taskRun.attemptNumber ?? 0) + 1; + + if (nextAttemptNumber > MAX_TASK_RUN_ATTEMPTS) { + await this.runAttemptSystem.attemptFailed({ + runId: taskRun.id, + snapshotId, + completion: { + ok: false, id: taskRun.id, - }, - data: { - status: "EXECUTING", - attemptNumber: nextAttemptNumber, - executedAt: taskRun.attemptNumber === null ? new Date() : undefined, - }, - include: { - tags: true, - lockedBy: { - include: { worker: true }, + error: { + type: "INTERNAL_ERROR", + code: "TASK_RUN_CRASHED", + message: "Max attempts reached.", }, }, + tx: prisma, }); + throw new ServiceValidationError("Max attempts reached", 400); + } - const newSnapshot = await this.#createExecutionSnapshot(tx, { - run, - snapshot: { - executionStatus: "EXECUTING", - description: `Attempt created, starting execution${ - isWarmStart ? " (warm start)" : "" - }`, - }, - environmentId: latestSnapshot.environmentId, - environmentType: latestSnapshot.environmentType, - workerId, - runnerId, - }); - - if (taskRun.ttl) { - //don't expire the run, it's going to execute - await this.worker.ack(`expireRun:${taskRun.id}`); - } + this.eventBus.emit("runAttemptStarted", { + time: new Date(), + run: { + id: taskRun.id, + attemptNumber: nextAttemptNumber, + baseCostInCents: taskRun.baseCostInCents, + }, + organization: { + id: environment.organization.id, + }, + }); - return { run, snapshot: newSnapshot }; - }, - (error) => { - this.logger.error("RunEngine.createRunAttempt(): prisma.$transaction error", { - code: error.code, - meta: error.meta, - stack: error.stack, - message: error.message, - name: error.name, + const result = await $transaction( + prisma, + async (tx) => { + const run = await tx.taskRun.update({ + where: { + id: taskRun.id, + }, + data: { + status: "EXECUTING", + attemptNumber: nextAttemptNumber, + executedAt: taskRun.attemptNumber === null ? new Date() : undefined, + }, + include: { + tags: true, + lockedBy: { + include: { worker: true }, + }, + }, + }); + + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(tx, { + run, + snapshot: { + executionStatus: "EXECUTING", + description: `Attempt created, starting execution${ + isWarmStart ? " (warm start)" : "" + }`, + }, + environmentId: latestSnapshot.environmentId, + environmentType: latestSnapshot.environmentType, + workerId, + runnerId, + }); + + if (taskRun.ttl) { + //don't expire the run, it's going to execute + await this.worker.ack(`expireRun:${taskRun.id}`); + } + + return { run, snapshot: newSnapshot }; + }, + (error) => { + this.logger.error("RunEngine.createRunAttempt(): prisma.$transaction error", { + code: error.code, + meta: error.meta, + stack: error.stack, + message: error.message, + name: error.name, + }); + throw new ServiceValidationError( + "Failed to update task run and execution snapshot", + 500 + ); + } + ); + + if (!result) { + this.logger.error("RunEngine.createRunAttempt(): failed to create task run attempt", { + runId: taskRun.id, + nextAttemptNumber, }); - throw new ServiceValidationError( - "Failed to update task run and execution snapshot", - 500 - ); + throw new ServiceValidationError("Failed to create task run attempt", 500); } - ); - if (!result) { - this.logger.error("RunEngine.createRunAttempt(): failed to create task run attempt", { - runId: taskRun.id, - nextAttemptNumber, - }); - throw new ServiceValidationError("Failed to create task run attempt", 500); - } + const { run, snapshot } = result; - const { run, snapshot } = result; + const machinePreset = getMachinePreset({ + machines: this.options.machines.machines, + defaultMachine: this.options.machines.defaultMachine, + config: taskRun.lockedBy.machineConfig ?? {}, + run: taskRun, + }); - const machinePreset = getMachinePreset({ - machines: this.options.machines.machines, - defaultMachine: this.options.machines.defaultMachine, - config: taskRun.lockedBy.machineConfig ?? {}, - run: taskRun, - }); + const metadata = await parsePacket({ + data: taskRun.metadata ?? undefined, + dataType: taskRun.metadataType, + }); - const metadata = await parsePacket({ - data: taskRun.metadata ?? undefined, - dataType: taskRun.metadataType, + const execution: TaskRunExecution = { + task: { + id: run.lockedBy!.slug, + filePath: run.lockedBy!.filePath, + exportName: run.lockedBy!.exportName, + }, + attempt: { + number: nextAttemptNumber, + startedAt: latestSnapshot.updatedAt, + /** @deprecated */ + id: "deprecated", + /** @deprecated */ + backgroundWorkerId: "deprecated", + /** @deprecated */ + backgroundWorkerTaskId: "deprecated", + /** @deprecated */ + status: "deprecated", + }, + run: { + id: run.friendlyId, + payload: run.payload, + payloadType: run.payloadType, + createdAt: run.createdAt, + tags: run.tags.map((tag) => tag.name), + isTest: run.isTest, + idempotencyKey: run.idempotencyKey ?? undefined, + startedAt: run.startedAt ?? run.createdAt, + maxAttempts: run.maxAttempts ?? undefined, + version: run.lockedBy!.worker.version, + metadata, + maxDuration: run.maxDurationInSeconds ?? undefined, + /** @deprecated */ + context: undefined, + /** @deprecated */ + durationMs: run.usageDurationMs, + /** @deprecated */ + costInCents: run.costInCents, + /** @deprecated */ + baseCostInCents: run.baseCostInCents, + traceContext: run.traceContext as Record, + priority: run.priorityMs === 0 ? undefined : run.priorityMs / 1_000, + }, + queue: { + id: queue.friendlyId, + name: queue.name, + }, + environment: { + id: environment.id, + slug: environment.slug, + type: environment.type, + }, + organization: { + id: environment.organization.id, + slug: environment.organization.slug, + name: environment.organization.title, + }, + project: { + id: environment.project.id, + ref: environment.project.externalRef, + slug: environment.project.slug, + name: environment.project.name, + }, + batch: + taskRun.batchItems[0] && taskRun.batchItems[0].batchTaskRun + ? { id: taskRun.batchItems[0].batchTaskRun.friendlyId } + : undefined, + machine: machinePreset, + }; + + return { run, snapshot, execution }; }); - - const execution: TaskRunExecution = { - task: { - id: run.lockedBy!.slug, - filePath: run.lockedBy!.filePath, - exportName: run.lockedBy!.exportName, - }, - attempt: { - number: nextAttemptNumber, - startedAt: latestSnapshot.updatedAt, - /** @deprecated */ - id: "deprecated", - /** @deprecated */ - backgroundWorkerId: "deprecated", - /** @deprecated */ - backgroundWorkerTaskId: "deprecated", - /** @deprecated */ - status: "deprecated", - }, - run: { - id: run.friendlyId, - payload: run.payload, - payloadType: run.payloadType, - createdAt: run.createdAt, - tags: run.tags.map((tag) => tag.name), - isTest: run.isTest, - idempotencyKey: run.idempotencyKey ?? undefined, - startedAt: run.startedAt ?? run.createdAt, - maxAttempts: run.maxAttempts ?? undefined, - version: run.lockedBy!.worker.version, - metadata, - maxDuration: run.maxDurationInSeconds ?? undefined, - /** @deprecated */ - context: undefined, - /** @deprecated */ - durationMs: run.usageDurationMs, - /** @deprecated */ - costInCents: run.costInCents, - /** @deprecated */ - baseCostInCents: run.baseCostInCents, - traceContext: run.traceContext as Record, - priority: run.priorityMs === 0 ? undefined : run.priorityMs / 1_000, - }, - queue: { - id: queue.friendlyId, - name: queue.name, - }, - environment: { - id: environment.id, - slug: environment.slug, - type: environment.type, - }, - organization: { - id: environment.organization.id, - slug: environment.organization.slug, - name: environment.organization.title, - }, - project: { - id: environment.project.id, - ref: environment.project.externalRef, - slug: environment.project.slug, - name: environment.project.name, - }, - batch: - taskRun.batchItems[0] && taskRun.batchItems[0].batchTaskRun - ? { id: taskRun.batchItems[0].batchTaskRun.friendlyId } - : undefined, - machine: machinePreset, - }; - - return { run, snapshot, execution }; - }); - }); + }, + { + attributes: { runId, snapshotId }, + } + ); } /** How a run is completed */ @@ -1412,38 +988,13 @@ export class RunEngine { workerId?: string; runnerId?: string; }): Promise { - if (completion.metadata) { - this.eventBus.emit("runMetadataUpdated", { - time: new Date(), - run: { - id: runId, - metadata: completion.metadata, - }, - }); - } - - switch (completion.ok) { - case true: { - return this.#attemptSucceeded({ - runId, - snapshotId, - completion, - tx: this.prisma, - workerId, - runnerId, - }); - } - case false: { - return this.#attemptFailed({ - runId, - snapshotId, - completion, - tx: this.prisma, - workerId, - runnerId, - }); - } - } + return this.runAttemptSystem.completeRunAttempt({ + runId, + snapshotId, + completion, + workerId, + runnerId, + }); } /** @@ -1469,139 +1020,14 @@ export class RunEngine { finalizeRun?: boolean; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; - reason = reason ?? "Cancelled by user"; - - return this.#trace("cancelRun", { runId }, async (span) => { - return this.runLock.lock([runId], 5_000, async (signal) => { - const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); - - //already finished, do nothing - if (latestSnapshot.executionStatus === "FINISHED") { - return executionResultFromSnapshot(latestSnapshot); - } - - //is pending cancellation and we're not finalizing, alert the worker again - if (latestSnapshot.executionStatus === "PENDING_CANCEL" && !finalizeRun) { - await this.#sendNotificationToWorker({ runId, snapshot: latestSnapshot }); - return executionResultFromSnapshot(latestSnapshot); - } - - //set the run to cancelled immediately - const error: TaskRunError = { - type: "STRING_ERROR", - raw: reason, - }; - - const run = await prisma.taskRun.update({ - where: { id: runId }, - data: { - status: "CANCELED", - completedAt: finalizeRun ? completedAt ?? new Date() : completedAt, - error, - }, - select: { - id: true, - friendlyId: true, - status: true, - attemptNumber: true, - spanId: true, - batchId: true, - createdAt: true, - completedAt: true, - taskEventStore: true, - parentTaskRunId: true, - runtimeEnvironment: { - select: { - organizationId: true, - }, - }, - associatedWaitpoint: { - select: { - id: true, - }, - }, - childRuns: { - select: { - id: true, - }, - }, - }, - }); - - //remove it from the queue and release concurrency - await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); - - //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.#createExecutionSnapshot(prisma, { - run, - snapshot: { - executionStatus: "PENDING_CANCEL", - description: "Run was cancelled", - }, - environmentId: latestSnapshot.environmentId, - environmentType: latestSnapshot.environmentType, - workerId, - runnerId, - }); - - //the worker needs to be notified so it can kill the run and complete the attempt - await this.#sendNotificationToWorker({ runId, snapshot: newSnapshot }); - return executionResultFromSnapshot(newSnapshot); - } - - //not executing, so we will actually finish the run - const newSnapshot = await this.#createExecutionSnapshot(prisma, { - run, - snapshot: { - executionStatus: "FINISHED", - description: "Run was cancelled, not finished", - }, - environmentId: latestSnapshot.environmentId, - environmentType: latestSnapshot.environmentType, - workerId, - runnerId, - }); - - if (!run.associatedWaitpoint) { - throw new ServiceValidationError("No associated waitpoint found", 400); - } - - //complete the waitpoint so the parent run can continue - await this.completeWaitpoint({ - id: run.associatedWaitpoint.id, - output: { value: JSON.stringify(error), isError: true }, - }); - - this.eventBus.emit("runCancelled", { - time: new Date(), - run: { - id: run.id, - friendlyId: run.friendlyId, - spanId: run.spanId, - taskEventStore: run.taskEventStore, - createdAt: run.createdAt, - completedAt: run.completedAt, - error, - }, - }); - - //schedule the cancellation of all the child runs - //it will call this function for each child, - //which will recursively cancel all children if they need to be - if (run.childRuns.length > 0) { - for (const childRun of run.childRuns) { - await this.worker.enqueue({ - id: `cancelRun:${childRun.id}`, - job: "cancelRun", - payload: { runId: childRun.id, completedAt: run.completedAt ?? new Date(), reason }, - }); - } - } - - return executionResultFromSnapshot(newSnapshot); - }); + return this.runAttemptSystem.cancelRun({ + runId, + workerId, + runnerId, + completedAt, + reason, + finalizeRun, + tx, }); } @@ -1630,39 +1056,46 @@ export class RunEngine { tx?: PrismaClientOrTransaction; }): Promise { const prisma = tx ?? this.prisma; - return this.#trace("rescheduleRun", { runId }, async (span) => { - return await this.runLock.lock([runId], 5_000, async (signal) => { - const snapshot = await getLatestExecutionSnapshot(prisma, runId); + return startSpan( + this.tracer, + "rescheduleRun", + async (span) => { + return await this.runLock.lock([runId], 5_000, async () => { + const snapshot = await getLatestExecutionSnapshot(prisma, runId); - //if the run isn't just created then we can't reschedule it - if (snapshot.executionStatus !== "RUN_CREATED") { - throw new ServiceValidationError("Cannot reschedule a run that is not delayed"); - } + //if the run isn't just created then we can't reschedule it + if (snapshot.executionStatus !== "RUN_CREATED") { + throw new ServiceValidationError("Cannot reschedule a run that is not delayed"); + } - const updatedRun = await prisma.taskRun.update({ - where: { - id: runId, - }, - data: { - delayUntil: delayUntil, - executionSnapshots: { - create: { - engine: "V2", - executionStatus: "RUN_CREATED", - description: "Delayed run was rescheduled to a future date", - runStatus: "EXPIRED", - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, + const updatedRun = await prisma.taskRun.update({ + where: { + id: runId, + }, + data: { + delayUntil: delayUntil, + executionSnapshots: { + create: { + engine: "V2", + executionStatus: "RUN_CREATED", + description: "Delayed run was rescheduled to a future date", + runStatus: "EXPIRED", + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + }, }, }, - }, - }); + }); - await this.worker.reschedule(`enqueueDelayedRun:${updatedRun.id}`, delayUntil); + await this.worker.reschedule(`enqueueDelayedRun:${updatedRun.id}`, delayUntil); - return updatedRun; - }); - }); + return updatedRun; + }); + }, + { + attributes: { runId }, + } + ); } async lengthOfEnvQueue(environment: MinimalAuthenticatedEnvironment): Promise { @@ -1922,8 +1355,6 @@ export class RunEngine { async unblockRunForCreatedBatch({ runId, batchId, - environmentId, - projectId, tx, }: { runId: string; @@ -1955,20 +1386,12 @@ export class RunEngine { } async tryCompleteBatch({ batchId }: { batchId: string }): Promise { - await this.worker.enqueue({ - //this will debounce the call - id: `tryCompleteBatch:${batchId}`, - job: "tryCompleteBatch", - payload: { batchId: batchId }, - //2s in the future - availableAt: new Date(Date.now() + 2_000), - }); + return this.batchSystem.scheduleCompleteBatch({ batchId }); } async getWaitpoint({ waitpointId, environmentId, - projectId, }: { environmentId: string; projectId: string; @@ -2029,7 +1452,7 @@ export class RunEngine { let $waitpoints = typeof waitpoints === "string" ? [waitpoints] : waitpoints; - return await this.runLock.lock([runId], 5000, async (signal) => { + return await this.runLock.lock([runId], 5000, async () => { let snapshot: TaskRunExecutionSnapshot = await getLatestExecutionSnapshot(prisma, runId); //block the run with the waitpoints, returning how many waitpoints are pending @@ -2068,7 +1491,7 @@ export class RunEngine { //if the state has changed, create a new snapshot if (newStatus !== snapshot.executionStatus) { - snapshot = await this.#createExecutionSnapshot(prisma, { + snapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { run: { id: snapshot.runId, status: snapshot.runStatus, @@ -2086,7 +1509,7 @@ export class RunEngine { }); // Let the worker know immediately, so it can suspend the run - await this.#sendNotificationToWorker({ runId, snapshot }); + await sendNotificationToWorker({ runId, snapshot, eventBus: this.eventBus }); } if (timeout) { @@ -2179,7 +1602,7 @@ export class RunEngine { // - 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([runId], 5_000, async (signal) => { + await this.runLock.lock([runId], 5_000, async () => { const snapshot = await getLatestExecutionSnapshot(this.prisma, runId); if (!canReleaseConcurrency(snapshot.executionStatus)) { @@ -2314,7 +1737,7 @@ export class RunEngine { }): Promise { const prisma = tx ?? this.prisma; - return await this.runLock.lock([runId], 5_000, async (signal) => { + return await this.runLock.lock([runId], 5_000, async () => { const snapshot = await getLatestExecutionSnapshot(prisma, runId); if (snapshot.id !== snapshotId) { this.eventBus.emit("incomingCheckpointDiscarded", { @@ -2408,7 +1831,7 @@ export class RunEngine { }); //create a new execution snapshot, with the checkpoint - const newSnapshot = await this.#createExecutionSnapshot(prisma, { + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { run, snapshot: { executionStatus: "SUSPENDED", @@ -2457,7 +1880,7 @@ export class RunEngine { }): Promise { const prisma = tx ?? this.prisma; - return await this.runLock.lock([runId], 5_000, async (signal) => { + return await this.runLock.lock([runId], 5_000, async () => { const snapshot = await getLatestExecutionSnapshot(prisma, runId); if (snapshot.id !== snapshotId) { @@ -2491,7 +1914,7 @@ export class RunEngine { throw new ServiceValidationError("Run not found", 404); } - const newSnapshot = await this.#createExecutionSnapshot(prisma, { + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { run, snapshot: { executionStatus: "EXECUTING", @@ -2505,7 +1928,7 @@ export class RunEngine { }); // Let worker know about the new snapshot so it can continue the run - await this.#sendNotificationToWorker({ runId, snapshot: newSnapshot }); + await sendNotificationToWorker({ runId, snapshot: newSnapshot, eventBus: this.eventBus }); return { ...executionResultFromSnapshot(newSnapshot), @@ -2532,48 +1955,13 @@ export class RunEngine { runnerId?: string; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; - - //we don't need to acquire a run lock for any of this, it's not critical if it happens on an older version - const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); - if (latestSnapshot.id !== snapshotId) { - this.logger.log("heartbeatRun: no longer the latest snapshot, stopping the heartbeat.", { - runId, - snapshotId, - latestSnapshot, - workerId, - runnerId, - }); - - await this.worker.ack(`heartbeatSnapshot.${runId}`); - return executionResultFromSnapshot(latestSnapshot); - } - - if (latestSnapshot.workerId !== workerId) { - this.logger.debug("heartbeatRun: worker ID does not match the latest snapshot", { - runId, - snapshotId, - latestSnapshot, - workerId, - runnerId, - }); - } - - //update the snapshot heartbeat time - await prisma.taskRunExecutionSnapshot.update({ - where: { id: latestSnapshot.id }, - data: { - lastHeartbeatAt: new Date(), - }, + return this.executionSnapshotSystem.heartbeatRun({ + runId, + snapshotId, + workerId, + runnerId, + tx, }); - - //extending the heartbeat - const intervalMs = this.#getHeartbeatIntervalMs(latestSnapshot.executionStatus); - if (intervalMs !== null) { - await this.worker.reschedule(`heartbeatSnapshot.${runId}`, new Date(Date.now() + intervalMs)); - } - - return executionResultFromSnapshot(latestSnapshot); } /** Get required data to execute the run */ @@ -2645,52 +2033,9 @@ export class RunEngine { } } - async #systemFailure({ - runId, - error, - tx, - }: { - runId: string; - error: TaskRunInternalError; - tx?: PrismaClientOrTransaction; - }): Promise { - const prisma = tx ?? this.prisma; - return this.#trace("#systemFailure", { runId }, async (span) => { - const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); - - //already finished - if (latestSnapshot.executionStatus === "FINISHED") { - //todo check run is in the correct state - return { - attemptStatus: "RUN_FINISHED", - snapshot: latestSnapshot, - run: { - id: runId, - friendlyId: latestSnapshot.runFriendlyId, - status: latestSnapshot.runStatus, - attemptNumber: latestSnapshot.attemptNumber, - }, - }; - } - - const result = await this.#attemptFailed({ - runId, - snapshotId: latestSnapshot.id, - completion: { - ok: false, - id: runId, - error, - }, - tx: prisma, - }); - - return result; - }); - } - async #expireRun({ runId, tx }: { runId: string; tx?: PrismaClientOrTransaction }) { const prisma = tx ?? this.prisma; - await this.runLock.lock([runId], 5_000, async (signal) => { + await this.runLock.lock([runId], 5_000, async () => { const snapshot = await getLatestExecutionSnapshot(prisma, runId); //if we're executing then we won't expire the run @@ -2781,525 +2126,17 @@ export class RunEngine { }); } - async #waitingForDeploy({ - orgId, - runId, - workerId, - runnerId, - reason, - tx, - }: { - orgId: string; - runId: string; - workerId?: string; - runnerId?: string; - reason?: string; - tx?: PrismaClientOrTransaction; - }) { - const prisma = tx ?? this.prisma; - - return this.#trace("#waitingForDeploy", { runId }, async (span) => { - return this.runLock.lock([runId], 5_000, async (signal) => { - //mark run as waiting for deploy - const run = await prisma.taskRun.update({ - where: { id: runId }, - data: { - status: "WAITING_FOR_DEPLOY", - }, - select: { - id: true, - status: true, - attemptNumber: true, - runtimeEnvironment: { - select: { id: true, type: true }, - }, - }, - }); - - await this.#createExecutionSnapshot(prisma, { - run, - snapshot: { - executionStatus: "RUN_CREATED", - description: - reason ?? - "The run doesn't have a background worker, so we're going to ack it for now.", - }, - environmentId: run.runtimeEnvironment.id, - environmentType: run.runtimeEnvironment.type, - workerId, - runnerId, - }); - - //we ack because when it's deployed it will be requeued - await this.runQueue.acknowledgeMessage(orgId, runId); - }); - }); - } - - async #attemptSucceeded({ - runId, - snapshotId, - completion, - tx, - workerId, - runnerId, - }: { - runId: string; - snapshotId: string; - completion: TaskRunSuccessfulExecutionResult; - tx: PrismaClientOrTransaction; - workerId?: string; - runnerId?: string; - }): Promise { - const prisma = tx ?? this.prisma; - return this.#trace("#completeRunAttemptSuccess", { runId, snapshotId }, async (span) => { - return this.runLock.lock([runId], 5_000, async (signal) => { - const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); - - if (latestSnapshot.id !== snapshotId) { - throw new ServiceValidationError("Snapshot ID doesn't match the latest snapshot", 400); - } - - span.setAttribute("completionStatus", completion.ok); - - const completedAt = new Date(); - - const run = await prisma.taskRun.update({ - where: { id: runId }, - data: { - status: "COMPLETED_SUCCESSFULLY", - completedAt, - output: completion.output, - outputType: completion.outputType, - executionSnapshots: { - create: { - executionStatus: "FINISHED", - description: "Task completed successfully", - runStatus: "COMPLETED_SUCCESSFULLY", - attemptNumber: latestSnapshot.attemptNumber, - environmentId: latestSnapshot.environmentId, - environmentType: latestSnapshot.environmentType, - workerId, - runnerId, - }, - }, - }, - select: { - id: true, - friendlyId: true, - status: true, - attemptNumber: true, - spanId: true, - associatedWaitpoint: { - select: { - id: true, - }, - }, - project: { - select: { - organizationId: true, - }, - }, - batchId: true, - createdAt: true, - completedAt: true, - taskEventStore: true, - parentTaskRunId: true, - }, - }); - const newSnapshot = await getLatestExecutionSnapshot(prisma, runId); - - await this.runQueue.acknowledgeMessage(run.project.organizationId, runId); - - // We need to manually emit this as we created the final snapshot as part of the task run update - this.eventBus.emit("executionSnapshotCreated", { - time: newSnapshot.createdAt, - run: { - id: newSnapshot.runId, - }, - snapshot: { - ...newSnapshot, - completedWaitpointIds: newSnapshot.completedWaitpoints.map((wp) => wp.id), - }, - }); - - if (!run.associatedWaitpoint) { - throw new ServiceValidationError("No associated waitpoint found", 400); - } - - await this.completeWaitpoint({ - id: run.associatedWaitpoint.id, - output: completion.output - ? { value: completion.output, type: completion.outputType, isError: false } - : undefined, - }); - - this.eventBus.emit("runSucceeded", { - time: completedAt, - run: { - id: runId, - spanId: run.spanId, - output: completion.output, - outputType: completion.outputType, - createdAt: run.createdAt, - completedAt: run.completedAt, - taskEventStore: run.taskEventStore, - }, - }); - - await this.#finalizeRun(run); - - return { - attemptStatus: "RUN_FINISHED", - snapshot: newSnapshot, - run, - }; - }); - }); - } - - async #attemptFailed({ - runId, - snapshotId, - workerId, - runnerId, - completion, - forceRequeue, - tx, - }: { - runId: string; - snapshotId: string; - workerId?: string; - runnerId?: string; - completion: TaskRunFailedExecutionResult; - forceRequeue?: boolean; - tx: PrismaClientOrTransaction; - }): Promise { - const prisma = this.prisma; - - return this.#trace("completeRunAttemptFailure", { runId, snapshotId }, async (span) => { - return this.runLock.lock([runId], 5_000, async (signal) => { - const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); - - if (latestSnapshot.id !== snapshotId) { - throw new ServiceValidationError("Snapshot ID doesn't match the latest snapshot", 400); - } - - span.setAttribute("completionStatus", completion.ok); - - //remove waitpoints blocking the run - const deletedCount = await this.#clearBlockingWaitpoints({ runId, tx }); - if (deletedCount > 0) { - this.logger.debug("Cleared blocking waitpoints", { runId, deletedCount }); - } - - const failedAt = new Date(); - - const retryResult = await retryOutcomeFromCompletion(prisma, { - runId, - error: completion.error, - retryUsingQueue: forceRequeue ?? false, - retrySettings: completion.retry, - attemptNumber: latestSnapshot.attemptNumber, - }); - - // Force requeue means it was crashed so the attempt span needs to be closed - if (forceRequeue) { - const minimalRun = await prisma.taskRun.findFirst({ - where: { - id: runId, - }, - select: { - status: true, - spanId: true, - maxAttempts: true, - runtimeEnvironment: { - select: { - organizationId: true, - }, - }, - taskEventStore: true, - createdAt: true, - completedAt: true, - }, - }); - - if (!minimalRun) { - throw new ServiceValidationError("Run not found", 404); - } - - this.eventBus.emit("runAttemptFailed", { - time: failedAt, - run: { - id: runId, - status: minimalRun.status, - spanId: minimalRun.spanId, - error: completion.error, - attemptNumber: latestSnapshot.attemptNumber ?? 0, - createdAt: minimalRun.createdAt, - completedAt: minimalRun.completedAt, - taskEventStore: minimalRun.taskEventStore, - }, - }); - } - - switch (retryResult.outcome) { - case "cancel_run": { - const result = await this.cancelRun({ - runId, - completedAt: failedAt, - reason: retryResult.reason, - finalizeRun: true, - tx: prisma, - }); - return { - attemptStatus: - result.snapshot.executionStatus === "PENDING_CANCEL" - ? "RUN_PENDING_CANCEL" - : "RUN_FINISHED", - ...result, - }; - } - case "fail_run": { - return await this.#permanentlyFailRun({ - runId, - snapshotId, - failedAt, - error: retryResult.sanitizedError, - workerId, - runnerId, - }); - } - case "retry": { - const retryAt = new Date(retryResult.settings.timestamp); - - const run = await prisma.taskRun.update({ - where: { - id: runId, - }, - data: { - status: "RETRYING_AFTER_FAILURE", - machinePreset: retryResult.machine, - }, - include: { - runtimeEnvironment: { - include: { - project: true, - organization: true, - orgMember: true, - }, - }, - }, - }); - - const nextAttemptNumber = - latestSnapshot.attemptNumber === null ? 1 : latestSnapshot.attemptNumber + 1; - - if (retryResult.wasOOMError) { - this.eventBus.emit("runAttemptFailed", { - time: failedAt, - run: { - id: runId, - status: run.status, - spanId: run.spanId, - error: completion.error, - attemptNumber: latestSnapshot.attemptNumber ?? 0, - createdAt: run.createdAt, - completedAt: run.completedAt, - taskEventStore: run.taskEventStore, - }, - }); - } - - this.eventBus.emit("runRetryScheduled", { - time: failedAt, - run: { - id: run.id, - friendlyId: run.friendlyId, - attemptNumber: nextAttemptNumber, - queue: run.queue, - taskIdentifier: run.taskIdentifier, - traceContext: run.traceContext as Record, - baseCostInCents: run.baseCostInCents, - spanId: run.spanId, - }, - organization: { - id: run.runtimeEnvironment.organizationId, - }, - environment: run.runtimeEnvironment, - retryAt, - }); - - //if it's a long delay and we support checkpointing, put it back in the queue - if ( - forceRequeue || - retryResult.method === "queue" || - (this.options.retryWarmStartThresholdMs !== undefined && - retryResult.settings.delay >= this.options.retryWarmStartThresholdMs) - ) { - //we nack the message, requeuing it for later - const nackResult = await this.#tryNackAndRequeue({ - run, - environment: run.runtimeEnvironment, - orgId: run.runtimeEnvironment.organizationId, - timestamp: retryAt.getTime(), - error: { - type: "INTERNAL_ERROR", - code: "TASK_RUN_DEQUEUED_MAX_RETRIES", - message: `We tried to dequeue the run the maximum number of times but it wouldn't start executing`, - }, - tx: prisma, - }); - - if (!nackResult.wasRequeued) { - return { - attemptStatus: "RUN_FINISHED", - ...nackResult, - }; - } else { - return { attemptStatus: "RETRY_QUEUED", ...nackResult }; - } - } - - //it will continue running because the retry delay is short - const newSnapshot = await this.#createExecutionSnapshot(prisma, { - run, - snapshot: { - executionStatus: "PENDING_EXECUTING", - description: "Attempt failed with a short delay, starting a new attempt", - }, - environmentId: latestSnapshot.environmentId, - environmentType: latestSnapshot.environmentType, - workerId, - runnerId, - }); - //the worker can fetch the latest snapshot and should create a new attempt - await this.#sendNotificationToWorker({ runId, snapshot: newSnapshot }); - - return { - attemptStatus: "RETRY_IMMEDIATELY", - ...executionResultFromSnapshot(newSnapshot), - }; - } - } - }); - }); - } - - async #permanentlyFailRun({ - runId, - snapshotId, - failedAt, - error, - workerId, - runnerId, - }: { - runId: string; - snapshotId?: string; - failedAt: Date; - error: TaskRunError; - workerId?: string; - runnerId?: string; - }): Promise { - const prisma = this.prisma; - - return this.#trace("permanentlyFailRun", { runId, snapshotId }, async (span) => { - const status = runStatusFromError(error); - - //run permanently failed - const run = await prisma.taskRun.update({ - where: { - id: runId, - }, - data: { - status, - completedAt: failedAt, - error, - }, - select: { - id: true, - friendlyId: true, - status: true, - attemptNumber: true, - spanId: true, - batchId: true, - parentTaskRunId: true, - associatedWaitpoint: { - select: { - id: true, - }, - }, - runtimeEnvironment: { - select: { - id: true, - type: true, - organizationId: true, - }, - }, - taskEventStore: true, - createdAt: true, - completedAt: true, - }, - }); - - const newSnapshot = await this.#createExecutionSnapshot(prisma, { - run, - snapshot: { - executionStatus: "FINISHED", - description: "Run failed", - }, - environmentId: run.runtimeEnvironment.id, - environmentType: run.runtimeEnvironment.type, - workerId, - runnerId, - }); - - if (!run.associatedWaitpoint) { - throw new ServiceValidationError("No associated waitpoint found", 400); - } - - await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); - - await this.completeWaitpoint({ - id: run.associatedWaitpoint.id, - output: { value: JSON.stringify(error), isError: true }, - }); - - this.eventBus.emit("runFailed", { - time: failedAt, - run: { - id: runId, - status: run.status, - spanId: run.spanId, - error, - taskEventStore: run.taskEventStore, - createdAt: run.createdAt, - completedAt: run.completedAt, - }, - }); - - await this.#finalizeRun(run); - - return { - attemptStatus: "RUN_FINISHED", - snapshot: newSnapshot, - run, - }; - }); - } - - //MARK: RunQueue - - /** The run can be added to the queue. When it's pulled from the queue it will be executed. */ - async #enqueueRun({ - run, - env, - timestamp, - tx, - snapshot, - batchId, - checkpointId, - completedWaitpoints, + //MARK: RunQueue + /** The run can be added to the queue. When it's pulled from the queue it will be executed. */ + async #enqueueRun({ + run, + env, + timestamp, + tx, + snapshot, + batchId, + checkpointId, + completedWaitpoints, workerId, runnerId, }: { @@ -3322,9 +2159,9 @@ export class RunEngine { }): Promise { const prisma = tx ?? this.prisma; - await this.runLock.lock([run.id], 5000, async (signal) => { - const newSnapshot = await this.#createExecutionSnapshot(prisma, { - run, + await this.runLock.lock([run.id], 5000, async () => { + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run: run, snapshot: { executionStatus: snapshot?.status ?? "QUEUED", description: snapshot?.description ?? "Run was QUEUED", @@ -3362,77 +2199,6 @@ export class RunEngine { }); } - async #tryNackAndRequeue({ - run, - environment, - orgId, - timestamp, - error, - workerId, - runnerId, - tx, - }: { - run: TaskRun; - environment: { - id: string; - type: RuntimeEnvironmentType; - }; - orgId: string; - timestamp?: number; - error: TaskRunInternalError; - workerId?: string; - runnerId?: string; - tx?: PrismaClientOrTransaction; - }): Promise<{ wasRequeued: boolean } & ExecutionResult> { - const prisma = tx ?? this.prisma; - - return await this.runLock.lock([run.id], 5000, async (signal) => { - //we nack the message, this allows another work to pick up the run - const gotRequeued = await this.runQueue.nackMessage({ - orgId, - messageId: run.id, - retryAt: timestamp, - }); - - if (!gotRequeued) { - const result = await this.#systemFailure({ - runId: run.id, - error, - tx: prisma, - }); - return { wasRequeued: false, ...result }; - } - - const newSnapshot = await this.#createExecutionSnapshot(prisma, { - run: run, - snapshot: { - executionStatus: "QUEUED", - description: "Requeued the run after a failure", - }, - environmentId: environment.id, - environmentType: environment.type, - workerId, - runnerId, - }); - - return { - wasRequeued: true, - snapshot: { - id: newSnapshot.id, - friendlyId: newSnapshot.friendlyId, - executionStatus: newSnapshot.executionStatus, - description: newSnapshot.description, - }, - run: { - id: newSnapshot.runId, - friendlyId: newSnapshot.runFriendlyId, - status: newSnapshot.runStatus, - attemptNumber: newSnapshot.attemptNumber, - }, - }; - }); - } - async #continueRunIfUnblocked({ runId }: { runId: string }) { // 1. Get the any blocking waitpoints const blockingWaitpoints = await this.prisma.taskRunWaitpoint.findMany({ @@ -3474,7 +2240,7 @@ export class RunEngine { } //4. Continue the run whether it's executing or not - await this.runLock.lock([runId], 5000, async (signal) => { + await this.runLock.lock([runId], 5000, async () => { const snapshot = await getLatestExecutionSnapshot(this.prisma, runId); //run is still executing, send a message to the worker @@ -3485,26 +2251,29 @@ export class RunEngine { ); if (result) { - const newSnapshot = await this.#createExecutionSnapshot(this.prisma, { - run: { - id: runId, - status: snapshot.runStatus, - attemptNumber: snapshot.attemptNumber, - }, - snapshot: { - executionStatus: "EXECUTING", - description: "Run was continued, whilst still executing.", - }, - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - batchId: snapshot.batchId ?? undefined, - completedWaitpoints: blockingWaitpoints.map((b) => ({ - id: b.waitpoint.id, - index: b.batchIndex ?? undefined, - })), - }); + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot( + this.prisma, + { + run: { + id: runId, + status: snapshot.runStatus, + attemptNumber: snapshot.attemptNumber, + }, + snapshot: { + executionStatus: "EXECUTING", + description: "Run was continued, whilst still executing.", + }, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + batchId: snapshot.batchId ?? undefined, + completedWaitpoints: blockingWaitpoints.map((b) => ({ + id: b.waitpoint.id, + index: b.batchIndex ?? undefined, + })), + } + ); - await this.#sendNotificationToWorker({ runId, snapshot: newSnapshot }); + await sendNotificationToWorker({ runId, snapshot: newSnapshot, eventBus: this.eventBus }); } else { // Because we cannot reacquire the concurrency, we need to enqueue the run again // and because the run is still executing, we need to set the status to QUEUED_EXECUTING @@ -3698,130 +2467,6 @@ export class RunEngine { }); } - async #clearBlockingWaitpoints({ runId, tx }: { runId: string; tx?: PrismaClientOrTransaction }) { - const prisma = tx ?? this.prisma; - const deleted = await prisma.taskRunWaitpoint.deleteMany({ - where: { - taskRunId: runId, - }, - }); - - return deleted.count; - } - - //#region TaskRunExecutionSnapshots - async #createExecutionSnapshot( - prisma: PrismaClientOrTransaction, - { - run, - snapshot, - batchId, - environmentId, - environmentType, - checkpointId, - workerId, - runnerId, - completedWaitpoints, - error, - }: { - run: { id: string; status: TaskRunStatus; attemptNumber?: number | null }; - snapshot: { - executionStatus: TaskRunExecutionStatus; - description: string; - }; - batchId?: string; - environmentId: string; - environmentType: RuntimeEnvironmentType; - checkpointId?: string; - workerId?: string; - runnerId?: string; - completedWaitpoints?: { - id: string; - index?: number; - }[]; - error?: string; - } - ) { - const newSnapshot = await prisma.taskRunExecutionSnapshot.create({ - data: { - engine: "V2", - executionStatus: snapshot.executionStatus, - description: snapshot.description, - runId: run.id, - runStatus: run.status, - attemptNumber: run.attemptNumber ?? undefined, - batchId, - environmentId, - environmentType, - checkpointId, - workerId, - runnerId, - completedWaitpoints: { - connect: completedWaitpoints?.map((w) => ({ id: w.id })), - }, - completedWaitpointOrder: completedWaitpoints - ?.filter((c) => c.index !== undefined) - .sort((a, b) => a.index! - b.index!) - .map((w) => w.id), - isValid: error ? false : true, - error, - }, - include: { - checkpoint: true, - }, - }); - - if (!error) { - //set heartbeat (if relevant) - const intervalMs = this.#getHeartbeatIntervalMs(newSnapshot.executionStatus); - if (intervalMs !== null) { - await this.worker.enqueue({ - id: `heartbeatSnapshot.${run.id}`, - job: "heartbeatSnapshot", - payload: { snapshotId: newSnapshot.id, runId: run.id }, - availableAt: new Date(Date.now() + intervalMs), - }); - } - } - - this.eventBus.emit("executionSnapshotCreated", { - time: newSnapshot.createdAt, - run: { - id: newSnapshot.runId, - }, - snapshot: { - ...newSnapshot, - completedWaitpointIds: completedWaitpoints?.map((w) => w.id) ?? [], - }, - }); - - return { - ...newSnapshot, - friendlyId: SnapshotId.toFriendlyId(newSnapshot.id), - runFriendlyId: RunId.toFriendlyId(newSnapshot.runId), - }; - } - - #getHeartbeatIntervalMs(status: TaskRunExecutionStatus): number | null { - switch (status) { - case "PENDING_EXECUTING": { - return this.heartbeatTimeouts.PENDING_EXECUTING; - } - case "PENDING_CANCEL": { - return this.heartbeatTimeouts.PENDING_CANCEL; - } - case "EXECUTING": { - return this.heartbeatTimeouts.EXECUTING; - } - case "EXECUTING_WITH_WAITPOINTS": { - return this.heartbeatTimeouts.EXECUTING_WITH_WAITPOINTS; - } - default: { - return null; - } - } - } - //#endregion //#region Heartbeat @@ -3835,7 +2480,7 @@ export class RunEngine { tx?: PrismaClientOrTransaction; }) { const prisma = tx ?? this.prisma; - return await this.runLock.lock([runId], 5_000, async (signal) => { + return await this.runLock.lock([runId], 5_000, async () => { const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); if (latestSnapshot.id !== snapshotId) { this.logger.log( @@ -3909,20 +2554,6 @@ export class RunEngine { } //it will automatically be requeued X times depending on the queue retry settings - const gotRequeued = await this.#tryNackAndRequeue({ - run, - environment: { - id: latestSnapshot.environmentId, - type: latestSnapshot.environmentType, - }, - orgId: run.runtimeEnvironment.organizationId, - error: { - type: "INTERNAL_ERROR", - code: "TASK_RUN_DEQUEUED_MAX_RETRIES", - message: `Trying to create an attempt failed multiple times, exceeding how many times we retry.`, - }, - tx: prisma, - }); break; } case "EXECUTING": @@ -3930,7 +2561,7 @@ export class RunEngine { const retryDelay = 250; //todo call attemptFailed and force requeuing - await this.#attemptFailed({ + await this.runAttemptSystem.attemptFailed({ runId, snapshotId: latestSnapshot.id, completion: { @@ -3982,103 +2613,6 @@ export class RunEngine { //#endregion - /** - * Sends a notification that a run has changed and we need to fetch the latest run state. - * The worker will call `getRunExecutionData` via the API and act accordingly. - */ - async #sendNotificationToWorker({ - runId, - snapshot, - }: { - runId: string; - snapshot: { - id: string; - executionStatus: TaskRunExecutionStatus; - }; - }) { - this.eventBus.emit("workerNotification", { - time: new Date(), - run: { - id: runId, - }, - snapshot: { - id: snapshot.id, - executionStatus: snapshot.executionStatus, - }, - }); - } - - /* - * Whether the run succeeds, fails, is cancelled… we need to run these operations - */ - async #finalizeRun({ id, batchId }: { id: string; batchId: string | null }) { - if (batchId) { - await this.tryCompleteBatch({ batchId }); - } - - //cancel the heartbeats - await this.worker.ack(`heartbeatSnapshot.${id}`); - } - - /** - * Checks to see if all runs for a BatchTaskRun are completed, if they are then update the status. - * This isn't used operationally, but it's used for the Batches dashboard page. - */ - async #tryCompleteBatch({ batchId }: { batchId: string }) { - return this.#trace( - "#tryCompleteBatch", - { - batchId, - }, - async (span) => { - const batch = await this.prisma.batchTaskRun.findUnique({ - select: { - status: true, - runtimeEnvironmentId: true, - }, - where: { - id: batchId, - }, - }); - - if (!batch) { - this.logger.error("#tryCompleteBatch batch doesn't exist", { batchId }); - return; - } - - if (batch.status === "COMPLETED") { - this.logger.debug("#tryCompleteBatch: Batch already completed", { batchId }); - return; - } - - const runs = await this.prisma.taskRun.findMany({ - select: { - id: true, - status: true, - }, - where: { - batchId, - runtimeEnvironmentId: batch.runtimeEnvironmentId, - }, - }); - - if (runs.every((r) => isFinalRunStatus(r.status))) { - this.logger.debug("#tryCompleteBatch: All runs are completed", { batchId }); - await this.prisma.batchTaskRun.update({ - where: { - id: batchId, - }, - data: { - status: "COMPLETED", - }, - }); - } else { - this.logger.debug("#tryCompleteBatch: Not all runs are completed", { batchId }); - } - } - ); - } - async #getAuthenticatedEnvironmentFromRun(runId: string, tx?: PrismaClientOrTransaction) { const prisma = tx ?? this.prisma; const taskRun = await prisma.taskRun.findUnique({ @@ -4109,36 +2643,6 @@ export class RunEngine { #backgroundWorkerQueueKey(backgroundWorkerId: string) { return `master-background-worker:${backgroundWorkerId}`; } - - async #trace( - trace: string, - attributes: Attributes | undefined, - fn: (span: Span) => Promise - ): Promise { - return this.tracer.startActiveSpan( - `${this.constructor.name}.${trace}`, - { attributes, kind: SpanKind.SERVER }, - async (span) => { - try { - return await fn(span); - } catch (e) { - if (e instanceof ServiceValidationError) { - throw e; - } - - if (e instanceof Error) { - span.recordException(e); - } else { - span.recordException(new Error(String(e))); - } - - throw e; - } finally { - span.end(); - } - } - ); - } } export class ServiceValidationError extends Error { diff --git a/internal-packages/run-engine/src/engine/systems/batchSystem.ts b/internal-packages/run-engine/src/engine/systems/batchSystem.ts new file mode 100644 index 0000000000..65d45ce687 --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/batchSystem.ts @@ -0,0 +1,94 @@ +import { Tracer, startSpan } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { PrismaClient } from "@trigger.dev/database"; +import { isFinalRunStatus } from "../statuses.js"; +import { EngineWorker } from "../types.js"; + +export type BatchSystemOptions = { + prisma: PrismaClient; + logger: Logger; + tracer: Tracer; + worker: EngineWorker; +}; + +export class BatchSystem { + private readonly prisma: PrismaClient; + private readonly logger: Logger; + private readonly tracer: Tracer; + private readonly worker: EngineWorker; + + constructor(private readonly options: BatchSystemOptions) { + this.prisma = options.prisma; + this.logger = options.logger; + this.tracer = options.tracer; + this.worker = options.worker; + } + + public async scheduleCompleteBatch({ batchId }: { batchId: string }): Promise { + await this.worker.enqueue({ + //this will debounce the call + id: `tryCompleteBatch:${batchId}`, + job: "tryCompleteBatch", + payload: { batchId: batchId }, + //2s in the future + availableAt: new Date(Date.now() + 2_000), + }); + } + + public async performCompleteBatch({ batchId }: { batchId: string }): Promise { + await this.#tryCompleteBatch({ batchId }); + } + + /** + * Checks to see if all runs for a BatchTaskRun are completed, if they are then update the status. + * This isn't used operationally, but it's used for the Batches dashboard page. + */ + async #tryCompleteBatch({ batchId }: { batchId: string }) { + return startSpan(this.tracer, "#tryCompleteBatch", async (span) => { + const batch = await this.prisma.batchTaskRun.findUnique({ + select: { + status: true, + runtimeEnvironmentId: true, + }, + where: { + id: batchId, + }, + }); + + if (!batch) { + this.logger.error("#tryCompleteBatch batch doesn't exist", { batchId }); + return; + } + + if (batch.status === "COMPLETED") { + this.logger.debug("#tryCompleteBatch: Batch already completed", { batchId }); + return; + } + + const runs = await this.prisma.taskRun.findMany({ + select: { + id: true, + status: true, + }, + where: { + batchId, + runtimeEnvironmentId: batch.runtimeEnvironmentId, + }, + }); + + if (runs.every((r) => isFinalRunStatus(r.status))) { + this.logger.debug("#tryCompleteBatch: All runs are completed", { batchId }); + await this.prisma.batchTaskRun.update({ + where: { + id: batchId, + }, + data: { + status: "COMPLETED", + }, + }); + } else { + this.logger.debug("#tryCompleteBatch: Not all runs are completed", { batchId }); + } + }); + } +} diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts new file mode 100644 index 0000000000..5469297776 --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -0,0 +1,583 @@ +import { PrismaClient, PrismaClientOrTransaction } from "@trigger.dev/database"; +import { RunQueue } from "../../run-queue/index.js"; +import { DequeuedMessage, MachineResources, RetryOptions } from "@trigger.dev/core/v3"; +import { Logger } from "@trigger.dev/core/logger"; +import { RunLocker } from "../locking.js"; +import { isDequeueableExecutionStatus } from "../statuses.js"; +import { getRunWithBackgroundWorkerTasks } from "../db/worker.js"; +import { assertExhaustive } from "@trigger.dev/core"; +import { getMachinePreset } from "../machinePresets.js"; +import { RunEngineOptions } from "../types.js"; +import { getMaxDuration, sanitizeQueueName } from "@trigger.dev/core/v3/isomorphic"; +import { ExecutionSnapshotSystem, getLatestExecutionSnapshot } from "./executionSnapshotSystem.js"; +import { Tracer, startSpan } from "@internal/tracing"; +import { RunAttemptSystem } from "./runAttemptSystem.js"; + +export type DequeueSystemOptions = { + prisma: PrismaClient; + queue: RunQueue; + runLock: RunLocker; + logger: Logger; + machines: RunEngineOptions["machines"]; + tracer: Tracer; + executionSnapshotSystem: ExecutionSnapshotSystem; + runAttemptSystem: RunAttemptSystem; +}; + +export class DequeueSystem { + private readonly prisma: PrismaClient; + private readonly runQueue: RunQueue; + private readonly runLock: RunLocker; + private readonly logger: Logger; + private readonly executionSnapshotSystem: ExecutionSnapshotSystem; + private readonly tracer: Tracer; + private readonly runAttemptSystem: RunAttemptSystem; + + constructor(private readonly options: DequeueSystemOptions) { + this.prisma = options.prisma; + this.runQueue = options.queue; + this.runLock = options.runLock; + this.logger = options.logger; + this.executionSnapshotSystem = options.executionSnapshotSystem; + this.tracer = options.tracer; + this.runAttemptSystem = options.runAttemptSystem; + } + + /** + * Gets a fairly selected run from the specified master queue, returning the information required to run it. + * @param consumerId: The consumer that is pulling, allows multiple consumers to pull from the same queue + * @param masterQueue: The shared queue to pull from, can be an individual environment (for dev) + * @returns + */ + async dequeueFromMasterQueue({ + consumerId, + masterQueue, + maxRunCount, + maxResources, + backgroundWorkerId, + workerId, + runnerId, + tx, + }: { + consumerId: string; + masterQueue: string; + maxRunCount: number; + maxResources?: MachineResources; + backgroundWorkerId?: string; + workerId?: string; + runnerId?: string; + tx?: PrismaClientOrTransaction; + }): Promise { + const prisma = tx ?? this.prisma; + + return startSpan( + this.tracer, + "dequeueFromMasterQueue", + async (span) => { + //gets multiple runs from the queue + const messages = await this.runQueue.dequeueMessageFromMasterQueue( + consumerId, + masterQueue, + maxRunCount + ); + if (messages.length === 0) { + return []; + } + + //we can't send more than the max resources + const consumedResources: MachineResources = { + cpu: 0, + memory: 0, + }; + + const dequeuedRuns: DequeuedMessage[] = []; + + for (const message of messages) { + const orgId = message.message.orgId; + const runId = message.messageId; + + span.setAttribute("runId", runId); + + //lock the run so nothing else can modify it + try { + const dequeuedRun = await this.runLock.lock([runId], 5000, async (signal) => { + const snapshot = await getLatestExecutionSnapshot(prisma, runId); + + if (!isDequeueableExecutionStatus(snapshot.executionStatus)) { + //create a failed snapshot + await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run: { + id: snapshot.runId, + status: snapshot.runStatus, + }, + snapshot: { + executionStatus: snapshot.executionStatus, + description: + "Tried to dequeue a run that is not in a valid state to be dequeued.", + }, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + checkpointId: snapshot.checkpointId ?? undefined, + completedWaitpoints: snapshot.completedWaitpoints, + error: `Tried to dequeue a run that is not in a valid state to be dequeued.`, + workerId, + runnerId, + }); + + //todo is there a way to recover this, so the run can be retried? + //for example should we update the status to a dequeuable status and nack it? + //then at least it has a chance of succeeding and we have the error log above + await this.runAttemptSystem.systemFailure({ + runId, + error: { + type: "INTERNAL_ERROR", + code: "TASK_DEQUEUED_INVALID_STATE", + message: `Task was in the ${snapshot.executionStatus} state when it was dequeued for execution.`, + }, + tx: prisma, + }); + this.logger.error( + `RunEngine.dequeueFromMasterQueue(): Run is not in a valid state to be dequeued: ${runId}\n ${snapshot.id}:${snapshot.executionStatus}` + ); + return null; + } + + const result = await getRunWithBackgroundWorkerTasks( + prisma, + runId, + backgroundWorkerId + ); + + if (!result.success) { + switch (result.code) { + case "NO_RUN": { + //this should not happen, the run is unrecoverable so we'll ack it + this.logger.error("RunEngine.dequeueFromMasterQueue(): No run found", { + runId, + latestSnapshot: snapshot.id, + }); + await this.runQueue.acknowledgeMessage(orgId, runId); + return null; + } + case "NO_WORKER": + case "TASK_NEVER_REGISTERED": + case "TASK_NOT_IN_LATEST": { + this.logger.warn(`RunEngine.dequeueFromMasterQueue(): ${result.code}`, { + runId, + latestSnapshot: snapshot.id, + result, + }); + + //not deployed yet, so we'll wait for the deploy + await this.#waitingForDeploy({ + orgId, + runId, + reason: result.message, + tx: prisma, + }); + return null; + } + case "BACKGROUND_WORKER_MISMATCH": { + this.logger.warn( + "RunEngine.dequeueFromMasterQueue(): Background worker mismatch", + { + runId, + latestSnapshot: snapshot.id, + result, + } + ); + + //worker mismatch so put it back in the queue + await this.runQueue.nackMessage({ orgId, messageId: runId }); + + return null; + } + default: { + assertExhaustive(result); + } + } + } + + //check for a valid deployment if it's not a development environment + if (result.run.runtimeEnvironment.type !== "DEVELOPMENT") { + if (!result.deployment || !result.deployment.imageReference) { + this.logger.warn("RunEngine.dequeueFromMasterQueue(): No deployment found", { + runId, + latestSnapshot: snapshot.id, + result, + }); + //not deployed yet, so we'll wait for the deploy + await this.#waitingForDeploy({ + orgId, + runId, + reason: "No deployment or deployment image reference found for deployed run", + tx: prisma, + }); + + return null; + } + } + + const machinePreset = getMachinePreset({ + machines: this.options.machines.machines, + defaultMachine: this.options.machines.defaultMachine, + config: result.task.machineConfig ?? {}, + run: result.run, + }); + + //increment the consumed resources + consumedResources.cpu += machinePreset.cpu; + consumedResources.memory += machinePreset.memory; + + //are we under the limit? + if (maxResources) { + if ( + consumedResources.cpu > maxResources.cpu || + consumedResources.memory > maxResources.memory + ) { + this.logger.debug( + "RunEngine.dequeueFromMasterQueue(): Consumed resources over limit, nacking", + { + runId, + consumedResources, + maxResources, + } + ); + + //put it back in the queue where it was + await this.runQueue.nackMessage({ + orgId, + messageId: runId, + incrementAttemptCount: false, + retryAt: result.run.createdAt.getTime() - result.run.priorityMs, + }); + return null; + } + } + + // Check max attempts that can optionally be set when triggering a run + let maxAttempts: number | null | undefined = result.run.maxAttempts; + + // If it's not set, we'll grab it from the task's retry config + if (!maxAttempts) { + const retryConfig = result.task.retryConfig; + + this.logger.debug( + "RunEngine.dequeueFromMasterQueue(): maxAttempts not set, using task's retry config", + { + runId, + task: result.task.id, + rawRetryConfig: retryConfig, + } + ); + + const parsedConfig = RetryOptions.nullable().safeParse(retryConfig); + + if (!parsedConfig.success) { + this.logger.error("RunEngine.dequeueFromMasterQueue(): Invalid retry config", { + runId, + task: result.task.id, + rawRetryConfig: retryConfig, + }); + + await this.runAttemptSystem.systemFailure({ + runId, + error: { + type: "INTERNAL_ERROR", + code: "TASK_DEQUEUED_INVALID_RETRY_CONFIG", + message: `Invalid retry config: ${retryConfig}`, + }, + tx: prisma, + }); + + return null; + } + + if (!parsedConfig.data) { + this.logger.error("RunEngine.dequeueFromMasterQueue(): No retry config", { + runId, + task: result.task.id, + rawRetryConfig: retryConfig, + }); + + await this.runAttemptSystem.systemFailure({ + runId, + error: { + type: "INTERNAL_ERROR", + code: "TASK_DEQUEUED_NO_RETRY_CONFIG", + message: `No retry config found`, + }, + tx: prisma, + }); + + return null; + } + + maxAttempts = parsedConfig.data.maxAttempts; + } + + //update the run + const lockedTaskRun = await prisma.taskRun.update({ + where: { + id: runId, + }, + data: { + lockedAt: new Date(), + lockedById: result.task.id, + lockedToVersionId: result.worker.id, + startedAt: result.run.startedAt ?? new Date(), + baseCostInCents: this.options.machines.baseCostInCents, + machinePreset: machinePreset.name, + taskVersion: result.worker.version, + sdkVersion: result.worker.sdkVersion, + cliVersion: result.worker.cliVersion, + maxDurationInSeconds: getMaxDuration( + result.run.maxDurationInSeconds, + result.task.maxDurationInSeconds + ), + maxAttempts: maxAttempts ?? undefined, + }, + include: { + runtimeEnvironment: true, + tags: true, + }, + }); + + if (!lockedTaskRun) { + this.logger.error("RunEngine.dequeueFromMasterQueue(): Failed to lock task run", { + taskRun: result.run.id, + taskIdentifier: result.run.taskIdentifier, + deployment: result.deployment?.id, + worker: result.worker.id, + task: result.task.id, + runId, + }); + + await this.runQueue.acknowledgeMessage(orgId, runId); + return null; + } + + const queue = await prisma.taskQueue.findUnique({ + where: { + runtimeEnvironmentId_name: { + runtimeEnvironmentId: lockedTaskRun.runtimeEnvironmentId, + name: sanitizeQueueName(lockedTaskRun.queue), + }, + }, + }); + + if (!queue) { + this.logger.debug( + "RunEngine.dequeueFromMasterQueue(): queue not found, so nacking message", + { + queueMessage: message, + taskRunQueue: lockedTaskRun.queue, + runtimeEnvironmentId: lockedTaskRun.runtimeEnvironmentId, + } + ); + + //will auto-retry + const gotRequeued = await this.runQueue.nackMessage({ orgId, messageId: runId }); + if (!gotRequeued) { + await this.runAttemptSystem.systemFailure({ + runId, + error: { + type: "INTERNAL_ERROR", + code: "TASK_DEQUEUED_QUEUE_NOT_FOUND", + message: `Tried to dequeue the run but the queue doesn't exist: ${lockedTaskRun.queue}`, + }, + tx: prisma, + }); + } + + return null; + } + + const currentAttemptNumber = lockedTaskRun.attemptNumber ?? 0; + const nextAttemptNumber = currentAttemptNumber + 1; + + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot( + prisma, + { + run: { + id: runId, + status: snapshot.runStatus, + attemptNumber: lockedTaskRun.attemptNumber, + }, + snapshot: { + executionStatus: "PENDING_EXECUTING", + description: "Run was dequeued for execution", + }, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + checkpointId: snapshot.checkpointId ?? undefined, + completedWaitpoints: snapshot.completedWaitpoints, + workerId, + runnerId, + } + ); + + return { + version: "1" as const, + dequeuedAt: new Date(), + snapshot: { + id: newSnapshot.id, + friendlyId: newSnapshot.friendlyId, + executionStatus: newSnapshot.executionStatus, + description: newSnapshot.description, + }, + image: result.deployment?.imageReference ?? undefined, + checkpoint: newSnapshot.checkpoint ?? undefined, + completedWaitpoints: snapshot.completedWaitpoints, + backgroundWorker: { + id: result.worker.id, + friendlyId: result.worker.friendlyId, + version: result.worker.version, + }, + deployment: { + id: result.deployment?.id, + friendlyId: result.deployment?.friendlyId, + }, + run: { + id: lockedTaskRun.id, + friendlyId: lockedTaskRun.friendlyId, + isTest: lockedTaskRun.isTest, + machine: machinePreset, + attemptNumber: nextAttemptNumber, + masterQueue: lockedTaskRun.masterQueue, + traceContext: lockedTaskRun.traceContext as Record, + }, + environment: { + id: lockedTaskRun.runtimeEnvironment.id, + type: lockedTaskRun.runtimeEnvironment.type, + }, + organization: { + id: orgId, + }, + project: { + id: lockedTaskRun.projectId, + }, + } satisfies DequeuedMessage; + }); + + if (dequeuedRun !== null) { + dequeuedRuns.push(dequeuedRun); + } + } catch (error) { + this.logger.error( + "RunEngine.dequeueFromMasterQueue(): Thrown error while preparing run to be run", + { + error, + runId, + } + ); + + const run = await prisma.taskRun.findFirst({ + where: { id: runId }, + include: { + runtimeEnvironment: true, + }, + }); + + if (!run) { + //this isn't ideal because we're not creating a snapshot… but we can't do much else + this.logger.error( + "RunEngine.dequeueFromMasterQueue(): Thrown error, then run not found. Nacking.", + { + runId, + orgId, + } + ); + await this.runQueue.nackMessage({ orgId, messageId: runId }); + continue; + } + + //this is an unknown error, we'll reattempt (with auto-backoff and eventually DLQ) + const gotRequeued = await this.runAttemptSystem.tryNackAndRequeue({ + run, + environment: run.runtimeEnvironment, + orgId, + error: { + type: "INTERNAL_ERROR", + code: "TASK_RUN_DEQUEUED_MAX_RETRIES", + message: `We tried to dequeue the run the maximum number of times but it wouldn't start executing`, + }, + tx: prisma, + }); + //we don't need this, but it makes it clear we're in a loop here + continue; + } + } + + return dequeuedRuns; + }, + { + attributes: { consumerId, masterQueue }, + } + ); + } + + async #waitingForDeploy({ + orgId, + runId, + workerId, + runnerId, + reason, + tx, + }: { + orgId: string; + runId: string; + workerId?: string; + runnerId?: string; + reason?: string; + tx?: PrismaClientOrTransaction; + }) { + const prisma = tx ?? this.prisma; + + return startSpan( + this.tracer, + "#waitingForDeploy", + async (span) => { + return this.runLock.lock([runId], 5_000, async (signal) => { + //mark run as waiting for deploy + const run = await prisma.taskRun.update({ + where: { id: runId }, + data: { + status: "WAITING_FOR_DEPLOY", + }, + select: { + id: true, + status: true, + attemptNumber: true, + runtimeEnvironment: { + select: { id: true, type: true }, + }, + }, + }); + + await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run, + snapshot: { + executionStatus: "RUN_CREATED", + description: + reason ?? + "The run doesn't have a background worker, so we're going to ack it for now.", + }, + environmentId: run.runtimeEnvironment.id, + environmentType: run.runtimeEnvironment.type, + workerId, + runnerId, + }); + + //we ack because when it's deployed it will be requeued + await this.runQueue.acknowledgeMessage(orgId, runId); + }); + }, + { + attributes: { + runId, + }, + } + ); + } +} diff --git a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts new file mode 100644 index 0000000000..91485d1060 --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts @@ -0,0 +1,335 @@ +import { + PrismaClient, + PrismaClientOrTransaction, + RuntimeEnvironmentType, + TaskRunExecutionStatus, + TaskRunStatus, + TaskRunCheckpoint, + TaskRunExecutionSnapshot, +} from "@trigger.dev/database"; +import { EngineWorker, HeartbeatTimeouts } from "../types.js"; +import { EventBus } from "../eventBus.js"; +import { CompletedWaitpoint, ExecutionResult } from "@trigger.dev/core/v3"; +import { BatchId, RunId, SnapshotId } from "@trigger.dev/core/v3/isomorphic"; +import { Logger } from "@trigger.dev/core/logger"; +import { Tracer } from "@internal/tracing"; + +export type ExecutionSnapshotSystemOptions = { + prisma: PrismaClient; + logger: Logger; + tracer: Tracer; + worker: EngineWorker; + eventBus: EventBus; + heartbeatTimeouts: HeartbeatTimeouts; +}; + +export interface LatestExecutionSnapshot extends TaskRunExecutionSnapshot { + friendlyId: string; + runFriendlyId: string; + checkpoint: TaskRunCheckpoint | null; + completedWaitpoints: CompletedWaitpoint[]; +} + +/* Gets the most recent valid snapshot for a run */ +export async function getLatestExecutionSnapshot( + prisma: PrismaClientOrTransaction, + runId: string +): Promise { + const snapshot = await prisma.taskRunExecutionSnapshot.findFirst({ + where: { runId, isValid: true }, + include: { + completedWaitpoints: true, + checkpoint: true, + }, + orderBy: { createdAt: "desc" }, + }); + + if (!snapshot) { + throw new Error(`No execution snapshot found for TaskRun ${runId}`); + } + + return { + ...snapshot, + friendlyId: SnapshotId.toFriendlyId(snapshot.id), + runFriendlyId: RunId.toFriendlyId(snapshot.runId), + completedWaitpoints: snapshot.completedWaitpoints.flatMap((w) => { + //get all indexes of the waitpoint in the completedWaitpointOrder + //we do this because the same run can be in a batch multiple times (i.e. same idempotencyKey) + let indexes: (number | undefined)[] = []; + for (let i = 0; i < snapshot.completedWaitpointOrder.length; i++) { + if (snapshot.completedWaitpointOrder[i] === w.id) { + indexes.push(i); + } + } + + if (indexes.length === 0) { + indexes.push(undefined); + } + + return indexes.map((index) => { + return { + id: w.id, + index: index === -1 ? undefined : index, + friendlyId: w.friendlyId, + type: w.type, + completedAt: w.completedAt ?? new Date(), + idempotencyKey: + w.userProvidedIdempotencyKey && !w.inactiveIdempotencyKey + ? w.idempotencyKey + : undefined, + completedByTaskRun: w.completedByTaskRunId + ? { + id: w.completedByTaskRunId, + friendlyId: RunId.toFriendlyId(w.completedByTaskRunId), + batch: snapshot.batchId + ? { + id: snapshot.batchId, + friendlyId: BatchId.toFriendlyId(snapshot.batchId), + } + : undefined, + } + : undefined, + completedAfter: w.completedAfter ?? undefined, + completedByBatch: w.completedByBatchId + ? { + id: w.completedByBatchId, + friendlyId: BatchId.toFriendlyId(w.completedByBatchId), + } + : undefined, + output: w.output ?? undefined, + outputType: w.outputType, + outputIsError: w.outputIsError, + } satisfies CompletedWaitpoint; + }); + }), + }; +} + +export async function getExecutionSnapshotCompletedWaitpoints( + prisma: PrismaClientOrTransaction, + snapshotId: string +) { + const waitpoints = await prisma.taskRunExecutionSnapshot.findFirst({ + where: { id: snapshotId }, + include: { + completedWaitpoints: true, + }, + }); + + //deduplicate waitpoints + const waitpointIds = new Set(); + return ( + waitpoints?.completedWaitpoints.filter((waitpoint) => { + if (waitpointIds.has(waitpoint.id)) { + return false; + } else { + waitpointIds.add(waitpoint.id); + return true; + } + }) ?? [] + ); +} + +export function executionResultFromSnapshot(snapshot: TaskRunExecutionSnapshot): ExecutionResult { + return { + snapshot: { + id: snapshot.id, + friendlyId: SnapshotId.toFriendlyId(snapshot.id), + executionStatus: snapshot.executionStatus, + description: snapshot.description, + }, + run: { + id: snapshot.runId, + friendlyId: RunId.toFriendlyId(snapshot.runId), + status: snapshot.runStatus, + attemptNumber: snapshot.attemptNumber, + }, + }; +} + +export class ExecutionSnapshotSystem { + private readonly worker: EngineWorker; + private readonly eventBus: EventBus; + private readonly heartbeatTimeouts: HeartbeatTimeouts; + private readonly prisma: PrismaClient; + private readonly logger: Logger; + private readonly tracer: Tracer; + + constructor(private readonly options: ExecutionSnapshotSystemOptions) { + this.worker = options.worker; + this.eventBus = options.eventBus; + this.heartbeatTimeouts = options.heartbeatTimeouts; + this.prisma = options.prisma; + this.logger = options.logger; + this.tracer = options.tracer; + } + + public async createExecutionSnapshot( + prisma: PrismaClientOrTransaction, + { + run, + snapshot, + batchId, + environmentId, + environmentType, + checkpointId, + workerId, + runnerId, + completedWaitpoints, + error, + }: { + run: { id: string; status: TaskRunStatus; attemptNumber?: number | null }; + snapshot: { + executionStatus: TaskRunExecutionStatus; + description: string; + }; + batchId?: string; + environmentId: string; + environmentType: RuntimeEnvironmentType; + checkpointId?: string; + workerId?: string; + runnerId?: string; + completedWaitpoints?: { + id: string; + index?: number; + }[]; + error?: string; + } + ) { + const newSnapshot = await prisma.taskRunExecutionSnapshot.create({ + data: { + engine: "V2", + executionStatus: snapshot.executionStatus, + description: snapshot.description, + runId: run.id, + runStatus: run.status, + attemptNumber: run.attemptNumber ?? undefined, + batchId, + environmentId, + environmentType, + checkpointId, + workerId, + runnerId, + completedWaitpoints: { + connect: completedWaitpoints?.map((w) => ({ id: w.id })), + }, + completedWaitpointOrder: completedWaitpoints + ?.filter((c) => c.index !== undefined) + .sort((a, b) => a.index! - b.index!) + .map((w) => w.id), + isValid: error ? false : true, + error, + }, + include: { + checkpoint: true, + }, + }); + + if (!error) { + //set heartbeat (if relevant) + const intervalMs = this.#getHeartbeatIntervalMs(newSnapshot.executionStatus); + if (intervalMs !== null) { + await this.worker.enqueue({ + id: `heartbeatSnapshot.${run.id}`, + job: "heartbeatSnapshot", + payload: { snapshotId: newSnapshot.id, runId: run.id }, + availableAt: new Date(Date.now() + intervalMs), + }); + } + } + + this.eventBus.emit("executionSnapshotCreated", { + time: newSnapshot.createdAt, + run: { + id: newSnapshot.runId, + }, + snapshot: { + ...newSnapshot, + completedWaitpointIds: completedWaitpoints?.map((w) => w.id) ?? [], + }, + }); + + return { + ...newSnapshot, + friendlyId: SnapshotId.toFriendlyId(newSnapshot.id), + runFriendlyId: RunId.toFriendlyId(newSnapshot.runId), + }; + } + + public async heartbeatRun({ + runId, + snapshotId, + workerId, + runnerId, + tx, + }: { + runId: string; + snapshotId: string; + workerId?: string; + runnerId?: string; + tx?: PrismaClientOrTransaction; + }): Promise { + const prisma = tx ?? this.prisma; + + //we don't need to acquire a run lock for any of this, it's not critical if it happens on an older version + const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); + if (latestSnapshot.id !== snapshotId) { + this.logger.log("heartbeatRun: no longer the latest snapshot, stopping the heartbeat.", { + runId, + snapshotId, + latestSnapshot, + workerId, + runnerId, + }); + + await this.worker.ack(`heartbeatSnapshot.${runId}`); + return executionResultFromSnapshot(latestSnapshot); + } + + if (latestSnapshot.workerId !== workerId) { + this.logger.debug("heartbeatRun: worker ID does not match the latest snapshot", { + runId, + snapshotId, + latestSnapshot, + workerId, + runnerId, + }); + } + + //update the snapshot heartbeat time + await prisma.taskRunExecutionSnapshot.update({ + where: { id: latestSnapshot.id }, + data: { + lastHeartbeatAt: new Date(), + }, + }); + + //extending the heartbeat + const intervalMs = this.#getHeartbeatIntervalMs(latestSnapshot.executionStatus); + if (intervalMs !== null) { + await this.worker.reschedule(`heartbeatSnapshot.${runId}`, new Date(Date.now() + intervalMs)); + } + + return executionResultFromSnapshot(latestSnapshot); + } + + #getHeartbeatIntervalMs(status: TaskRunExecutionStatus): number | null { + switch (status) { + case "PENDING_EXECUTING": { + return this.heartbeatTimeouts.PENDING_EXECUTING; + } + case "PENDING_CANCEL": { + return this.heartbeatTimeouts.PENDING_CANCEL; + } + case "EXECUTING": { + return this.heartbeatTimeouts.EXECUTING; + } + case "EXECUTING_WITH_WAITPOINTS": { + return this.heartbeatTimeouts.EXECUTING_WITH_WAITPOINTS; + } + default: { + return null; + } + } + } +} diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts new file mode 100644 index 0000000000..e356c0fb1d --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -0,0 +1,897 @@ +import { + PrismaClient, + PrismaClientOrTransaction, + RuntimeEnvironmentType, + TaskRun, +} from "@trigger.dev/database"; +import { Logger } from "@trigger.dev/core/logger"; +import { startSpan, Tracer } from "@internal/tracing"; +import { + executionResultFromSnapshot, + ExecutionSnapshotSystem, + getLatestExecutionSnapshot, +} from "./executionSnapshotSystem.js"; +import { + CompleteRunAttemptResult, + ExecutionResult, + TaskRunError, + TaskRunExecutionResult, + TaskRunFailedExecutionResult, + TaskRunInternalError, + TaskRunSuccessfulExecutionResult, +} from "@trigger.dev/core/v3/schemas"; +import { RunLocker } from "../locking.js"; +import { EventBus, sendNotificationToWorker } from "../eventBus.js"; +import { ServiceValidationError } from "../index.js"; +import { retryOutcomeFromCompletion } from "../retrying.js"; +import { RunQueue } from "../../run-queue/index.js"; +import { isExecuting } from "../statuses.js"; +import { EngineWorker } from "../types.js"; +import { runStatusFromError } from "../errors.js"; +import { BatchSystem } from "./batchSystem.js"; +import { WaitpointSystem } from "./waitpointSystem.js"; + +export type RunAttemptSystemOptions = { + prisma: PrismaClient; + logger: Logger; + tracer: Tracer; + runLock: RunLocker; + eventBus: EventBus; + runQueue: RunQueue; + worker: EngineWorker; + executionSnapshotSystem: ExecutionSnapshotSystem; + batchSystem: BatchSystem; + waitpointSystem: WaitpointSystem; + retryWarmStartThresholdMs?: number; +}; + +export class RunAttemptSystem { + private readonly prisma: PrismaClient; + private readonly logger: Logger; + private readonly tracer: Tracer; + private readonly runLock: RunLocker; + private readonly eventBus: EventBus; + private readonly runQueue: RunQueue; + private readonly worker: EngineWorker; + private readonly executionSnapshotSystem: ExecutionSnapshotSystem; + private readonly batchSystem: BatchSystem; + private readonly waitpointSystem: WaitpointSystem; + + constructor(private readonly options: RunAttemptSystemOptions) { + this.prisma = options.prisma; + this.logger = options.logger; + this.tracer = options.tracer; + this.runLock = options.runLock; + this.eventBus = options.eventBus; + this.runQueue = options.runQueue; + this.worker = options.worker; + this.executionSnapshotSystem = options.executionSnapshotSystem; + this.batchSystem = options.batchSystem; + this.waitpointSystem = options.waitpointSystem; + } + + public async completeRunAttempt({ + runId, + snapshotId, + completion, + workerId, + runnerId, + }: { + runId: string; + snapshotId: string; + completion: TaskRunExecutionResult; + workerId?: string; + runnerId?: string; + }): Promise { + if (completion.metadata) { + this.eventBus.emit("runMetadataUpdated", { + time: new Date(), + run: { + id: runId, + metadata: completion.metadata, + }, + }); + } + + switch (completion.ok) { + case true: { + return this.attemptSucceeded({ + runId, + snapshotId, + completion, + tx: this.prisma, + workerId, + runnerId, + }); + } + case false: { + return this.attemptFailed({ + runId, + snapshotId, + completion, + tx: this.prisma, + workerId, + runnerId, + }); + } + } + } + + public async attemptSucceeded({ + runId, + snapshotId, + completion, + tx, + workerId, + runnerId, + }: { + runId: string; + snapshotId: string; + completion: TaskRunSuccessfulExecutionResult; + tx: PrismaClientOrTransaction; + workerId?: string; + runnerId?: string; + }): Promise { + const prisma = tx ?? this.prisma; + + return startSpan( + this.tracer, + "#completeRunAttemptSuccess", + async (span) => { + return this.runLock.lock([runId], 5_000, async (signal) => { + const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); + + if (latestSnapshot.id !== snapshotId) { + throw new ServiceValidationError("Snapshot ID doesn't match the latest snapshot", 400); + } + + span.setAttribute("completionStatus", completion.ok); + + const completedAt = new Date(); + + const run = await prisma.taskRun.update({ + where: { id: runId }, + data: { + status: "COMPLETED_SUCCESSFULLY", + completedAt, + output: completion.output, + outputType: completion.outputType, + executionSnapshots: { + create: { + executionStatus: "FINISHED", + description: "Task completed successfully", + runStatus: "COMPLETED_SUCCESSFULLY", + attemptNumber: latestSnapshot.attemptNumber, + environmentId: latestSnapshot.environmentId, + environmentType: latestSnapshot.environmentType, + workerId, + runnerId, + }, + }, + }, + select: { + id: true, + friendlyId: true, + status: true, + attemptNumber: true, + spanId: true, + associatedWaitpoint: { + select: { + id: true, + }, + }, + project: { + select: { + organizationId: true, + }, + }, + batchId: true, + createdAt: true, + completedAt: true, + taskEventStore: true, + parentTaskRunId: true, + }, + }); + const newSnapshot = await getLatestExecutionSnapshot(prisma, runId); + + await this.runQueue.acknowledgeMessage(run.project.organizationId, runId); + + // We need to manually emit this as we created the final snapshot as part of the task run update + this.eventBus.emit("executionSnapshotCreated", { + time: newSnapshot.createdAt, + run: { + id: newSnapshot.runId, + }, + snapshot: { + ...newSnapshot, + completedWaitpointIds: newSnapshot.completedWaitpoints.map((wp) => wp.id), + }, + }); + + if (!run.associatedWaitpoint) { + throw new ServiceValidationError("No associated waitpoint found", 400); + } + + await this.waitpointSystem.completeWaitpoint({ + id: run.associatedWaitpoint.id, + output: completion.output + ? { value: completion.output, type: completion.outputType, isError: false } + : undefined, + }); + + this.eventBus.emit("runSucceeded", { + time: completedAt, + run: { + id: runId, + spanId: run.spanId, + output: completion.output, + outputType: completion.outputType, + createdAt: run.createdAt, + completedAt: run.completedAt, + taskEventStore: run.taskEventStore, + }, + }); + + await this.#finalizeRun(run); + + return { + attemptStatus: "RUN_FINISHED", + snapshot: newSnapshot, + run, + }; + }); + }, + { + attributes: { runId, snapshotId }, + } + ); + } + + public async attemptFailed({ + runId, + snapshotId, + workerId, + runnerId, + completion, + forceRequeue, + tx, + }: { + runId: string; + snapshotId: string; + workerId?: string; + runnerId?: string; + completion: TaskRunFailedExecutionResult; + forceRequeue?: boolean; + tx: PrismaClientOrTransaction; + }): Promise { + const prisma = this.prisma; + + return startSpan( + this.tracer, + "completeRunAttemptFailure", + async (span) => { + return this.runLock.lock([runId], 5_000, async (signal) => { + const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); + + if (latestSnapshot.id !== snapshotId) { + throw new ServiceValidationError("Snapshot ID doesn't match the latest snapshot", 400); + } + + span.setAttribute("completionStatus", completion.ok); + + //remove waitpoints blocking the run + const deletedCount = await this.waitpointSystem.clearBlockingWaitpoints({ runId, tx }); + if (deletedCount > 0) { + this.logger.debug("Cleared blocking waitpoints", { runId, deletedCount }); + } + + const failedAt = new Date(); + + const retryResult = await retryOutcomeFromCompletion(prisma, { + runId, + error: completion.error, + retryUsingQueue: forceRequeue ?? false, + retrySettings: completion.retry, + attemptNumber: latestSnapshot.attemptNumber, + }); + + // Force requeue means it was crashed so the attempt span needs to be closed + if (forceRequeue) { + const minimalRun = await prisma.taskRun.findFirst({ + where: { + id: runId, + }, + select: { + status: true, + spanId: true, + maxAttempts: true, + runtimeEnvironment: { + select: { + organizationId: true, + }, + }, + taskEventStore: true, + createdAt: true, + completedAt: true, + }, + }); + + if (!minimalRun) { + throw new ServiceValidationError("Run not found", 404); + } + + this.eventBus.emit("runAttemptFailed", { + time: failedAt, + run: { + id: runId, + status: minimalRun.status, + spanId: minimalRun.spanId, + error: completion.error, + attemptNumber: latestSnapshot.attemptNumber ?? 0, + createdAt: minimalRun.createdAt, + completedAt: minimalRun.completedAt, + taskEventStore: minimalRun.taskEventStore, + }, + }); + } + + switch (retryResult.outcome) { + case "cancel_run": { + const result = await this.cancelRun({ + runId, + completedAt: failedAt, + reason: retryResult.reason, + finalizeRun: true, + tx: prisma, + }); + return { + attemptStatus: + result.snapshot.executionStatus === "PENDING_CANCEL" + ? "RUN_PENDING_CANCEL" + : "RUN_FINISHED", + ...result, + }; + } + case "fail_run": { + return await this.#permanentlyFailRun({ + runId, + snapshotId, + failedAt, + error: retryResult.sanitizedError, + workerId, + runnerId, + }); + } + case "retry": { + const retryAt = new Date(retryResult.settings.timestamp); + + const run = await prisma.taskRun.update({ + where: { + id: runId, + }, + data: { + status: "RETRYING_AFTER_FAILURE", + machinePreset: retryResult.machine, + }, + include: { + runtimeEnvironment: { + include: { + project: true, + organization: true, + orgMember: true, + }, + }, + }, + }); + + const nextAttemptNumber = + latestSnapshot.attemptNumber === null ? 1 : latestSnapshot.attemptNumber + 1; + + if (retryResult.wasOOMError) { + this.eventBus.emit("runAttemptFailed", { + time: failedAt, + run: { + id: runId, + status: run.status, + spanId: run.spanId, + error: completion.error, + attemptNumber: latestSnapshot.attemptNumber ?? 0, + createdAt: run.createdAt, + completedAt: run.completedAt, + taskEventStore: run.taskEventStore, + }, + }); + } + + this.eventBus.emit("runRetryScheduled", { + time: failedAt, + run: { + id: run.id, + friendlyId: run.friendlyId, + attemptNumber: nextAttemptNumber, + queue: run.queue, + taskIdentifier: run.taskIdentifier, + traceContext: run.traceContext as Record, + baseCostInCents: run.baseCostInCents, + spanId: run.spanId, + }, + organization: { + id: run.runtimeEnvironment.organizationId, + }, + environment: run.runtimeEnvironment, + retryAt, + }); + + //if it's a long delay and we support checkpointing, put it back in the queue + if ( + forceRequeue || + retryResult.method === "queue" || + (this.options.retryWarmStartThresholdMs !== undefined && + retryResult.settings.delay >= this.options.retryWarmStartThresholdMs) + ) { + //we nack the message, requeuing it for later + const nackResult = await this.tryNackAndRequeue({ + run, + environment: run.runtimeEnvironment, + orgId: run.runtimeEnvironment.organizationId, + timestamp: retryAt.getTime(), + error: { + type: "INTERNAL_ERROR", + code: "TASK_RUN_DEQUEUED_MAX_RETRIES", + message: `We tried to dequeue the run the maximum number of times but it wouldn't start executing`, + }, + tx: prisma, + }); + + if (!nackResult.wasRequeued) { + return { + attemptStatus: "RUN_FINISHED", + ...nackResult, + }; + } else { + return { attemptStatus: "RETRY_QUEUED", ...nackResult }; + } + } + + //it will continue running because the retry delay is short + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot( + prisma, + { + run, + snapshot: { + executionStatus: "PENDING_EXECUTING", + description: "Attempt failed with a short delay, starting a new attempt", + }, + environmentId: latestSnapshot.environmentId, + environmentType: latestSnapshot.environmentType, + workerId, + runnerId, + } + ); + + //the worker can fetch the latest snapshot and should create a new attempt + await sendNotificationToWorker({ + runId, + snapshot: newSnapshot, + eventBus: this.eventBus, + }); + + return { + attemptStatus: "RETRY_IMMEDIATELY", + ...executionResultFromSnapshot(newSnapshot), + }; + } + } + }); + }, + { + attributes: { runId, snapshotId }, + } + ); + } + + public async systemFailure({ + runId, + error, + tx, + }: { + runId: string; + error: TaskRunInternalError; + tx?: PrismaClientOrTransaction; + }): Promise { + const prisma = tx ?? this.prisma; + + return startSpan( + this.tracer, + "systemFailure", + async (span) => { + const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); + + //already finished + if (latestSnapshot.executionStatus === "FINISHED") { + //todo check run is in the correct state + return { + attemptStatus: "RUN_FINISHED", + snapshot: latestSnapshot, + run: { + id: runId, + friendlyId: latestSnapshot.runFriendlyId, + status: latestSnapshot.runStatus, + attemptNumber: latestSnapshot.attemptNumber, + }, + }; + } + + const result = await this.attemptFailed({ + runId, + snapshotId: latestSnapshot.id, + completion: { + ok: false, + id: runId, + error, + }, + tx: prisma, + }); + + return result; + }, + { + attributes: { + runId, + }, + } + ); + } + + public async tryNackAndRequeue({ + run, + environment, + orgId, + timestamp, + error, + workerId, + runnerId, + tx, + }: { + run: TaskRun; + environment: { + id: string; + type: RuntimeEnvironmentType; + }; + orgId: string; + timestamp?: number; + error: TaskRunInternalError; + workerId?: string; + runnerId?: string; + tx?: PrismaClientOrTransaction; + }): Promise<{ wasRequeued: boolean } & ExecutionResult> { + const prisma = tx ?? this.prisma; + + return await this.runLock.lock([run.id], 5000, async (signal) => { + //we nack the message, this allows another work to pick up the run + const gotRequeued = await this.runQueue.nackMessage({ + orgId, + messageId: run.id, + retryAt: timestamp, + }); + + if (!gotRequeued) { + const result = await this.systemFailure({ + runId: run.id, + error, + tx: prisma, + }); + return { wasRequeued: false, ...result }; + } + + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run: run, + snapshot: { + executionStatus: "QUEUED", + description: "Requeued the run after a failure", + }, + environmentId: environment.id, + environmentType: environment.type, + workerId, + runnerId, + }); + + return { + wasRequeued: true, + snapshot: { + id: newSnapshot.id, + friendlyId: newSnapshot.friendlyId, + executionStatus: newSnapshot.executionStatus, + description: newSnapshot.description, + }, + run: { + id: newSnapshot.runId, + friendlyId: newSnapshot.runFriendlyId, + status: newSnapshot.runStatus, + attemptNumber: newSnapshot.attemptNumber, + }, + }; + }); + } + + /** + Call this to cancel a run. + If the run is in-progress it will change it's state to PENDING_CANCEL and notify the worker. + If the run is not in-progress it will finish it. + You can pass `finalizeRun` in if you know it's no longer running, e.g. the worker has messaged to say it's done. + */ + async cancelRun({ + runId, + workerId, + runnerId, + completedAt, + reason, + finalizeRun, + tx, + }: { + runId: string; + workerId?: string; + runnerId?: string; + completedAt?: Date; + reason?: string; + finalizeRun?: boolean; + tx?: PrismaClientOrTransaction; + }): Promise { + const prisma = tx ?? this.prisma; + reason = reason ?? "Cancelled by user"; + + return startSpan(this.tracer, "cancelRun", async (span) => { + return this.runLock.lock([runId], 5_000, async (signal) => { + const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); + + //already finished, do nothing + if (latestSnapshot.executionStatus === "FINISHED") { + return executionResultFromSnapshot(latestSnapshot); + } + + //is pending cancellation and we're not finalizing, alert the worker again + if (latestSnapshot.executionStatus === "PENDING_CANCEL" && !finalizeRun) { + await sendNotificationToWorker({ + runId, + snapshot: latestSnapshot, + eventBus: this.eventBus, + }); + return executionResultFromSnapshot(latestSnapshot); + } + + //set the run to cancelled immediately + const error: TaskRunError = { + type: "STRING_ERROR", + raw: reason, + }; + + const run = await prisma.taskRun.update({ + where: { id: runId }, + data: { + status: "CANCELED", + completedAt: finalizeRun ? completedAt ?? new Date() : completedAt, + error, + }, + select: { + id: true, + friendlyId: true, + status: true, + attemptNumber: true, + spanId: true, + batchId: true, + createdAt: true, + completedAt: true, + taskEventStore: true, + parentTaskRunId: true, + runtimeEnvironment: { + select: { + organizationId: true, + }, + }, + associatedWaitpoint: { + select: { + id: true, + }, + }, + childRuns: { + select: { + id: true, + }, + }, + }, + }); + + //remove it from the queue and release concurrency + await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); + + //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, { + run, + snapshot: { + executionStatus: "PENDING_CANCEL", + description: "Run was cancelled", + }, + environmentId: latestSnapshot.environmentId, + environmentType: latestSnapshot.environmentType, + workerId, + runnerId, + }); + + //the worker needs to be notified so it can kill the run and complete the attempt + await sendNotificationToWorker({ + runId, + snapshot: newSnapshot, + eventBus: this.eventBus, + }); + return executionResultFromSnapshot(newSnapshot); + } + + //not executing, so we will actually finish the run + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run, + snapshot: { + executionStatus: "FINISHED", + description: "Run was cancelled, not finished", + }, + environmentId: latestSnapshot.environmentId, + environmentType: latestSnapshot.environmentType, + workerId, + runnerId, + }); + + if (!run.associatedWaitpoint) { + throw new ServiceValidationError("No associated waitpoint found", 400); + } + + //complete the waitpoint so the parent run can continue + await this.waitpointSystem.completeWaitpoint({ + id: run.associatedWaitpoint.id, + output: { value: JSON.stringify(error), isError: true }, + }); + + this.eventBus.emit("runCancelled", { + time: new Date(), + run: { + id: run.id, + friendlyId: run.friendlyId, + spanId: run.spanId, + taskEventStore: run.taskEventStore, + createdAt: run.createdAt, + completedAt: run.completedAt, + error, + }, + }); + + //schedule the cancellation of all the child runs + //it will call this function for each child, + //which will recursively cancel all children if they need to be + if (run.childRuns.length > 0) { + for (const childRun of run.childRuns) { + await this.worker.enqueue({ + id: `cancelRun:${childRun.id}`, + job: "cancelRun", + payload: { runId: childRun.id, completedAt: run.completedAt ?? new Date(), reason }, + }); + } + } + + return executionResultFromSnapshot(newSnapshot); + }); + }); + } + + async #permanentlyFailRun({ + runId, + snapshotId, + failedAt, + error, + workerId, + runnerId, + }: { + runId: string; + snapshotId?: string; + failedAt: Date; + error: TaskRunError; + workerId?: string; + runnerId?: string; + }): Promise { + const prisma = this.prisma; + + return startSpan(this.tracer, "permanentlyFailRun", async (span) => { + const status = runStatusFromError(error); + + //run permanently failed + const run = await prisma.taskRun.update({ + where: { + id: runId, + }, + data: { + status, + completedAt: failedAt, + error, + }, + select: { + id: true, + friendlyId: true, + status: true, + attemptNumber: true, + spanId: true, + batchId: true, + parentTaskRunId: true, + associatedWaitpoint: { + select: { + id: true, + }, + }, + runtimeEnvironment: { + select: { + id: true, + type: true, + organizationId: true, + }, + }, + taskEventStore: true, + createdAt: true, + completedAt: true, + }, + }); + + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run, + snapshot: { + executionStatus: "FINISHED", + description: "Run failed", + }, + environmentId: run.runtimeEnvironment.id, + environmentType: run.runtimeEnvironment.type, + workerId, + runnerId, + }); + + if (!run.associatedWaitpoint) { + throw new ServiceValidationError("No associated waitpoint found", 400); + } + + await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); + + await this.waitpointSystem.completeWaitpoint({ + id: run.associatedWaitpoint.id, + output: { value: JSON.stringify(error), isError: true }, + }); + + this.eventBus.emit("runFailed", { + time: failedAt, + run: { + id: runId, + status: run.status, + spanId: run.spanId, + error, + taskEventStore: run.taskEventStore, + createdAt: run.createdAt, + completedAt: run.completedAt, + }, + }); + + await this.#finalizeRun(run); + + return { + attemptStatus: "RUN_FINISHED", + snapshot: newSnapshot, + run, + }; + }); + } + + /* + * Whether the run succeeds, fails, is cancelled… we need to run these operations + */ + async #finalizeRun({ id, batchId }: { id: string; batchId: string | null }) { + if (batchId) { + await this.batchSystem.scheduleCompleteBatch({ batchId }); + } + + //cancel the heartbeats + await this.worker.ack(`heartbeatSnapshot.${id}`); + } +} diff --git a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts new file mode 100644 index 0000000000..25b48ce8a0 --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts @@ -0,0 +1,150 @@ +import { + $transaction, + Prisma, + PrismaClient, + PrismaClientOrTransaction, + Waitpoint, +} from "@trigger.dev/database"; +import { EventBus } from "../eventBus.js"; +import { EngineWorker } from "../types.js"; +import { Logger } from "@trigger.dev/core/logger"; +import { Tracer } from "@internal/tracing"; + +export type WaitpointSystemOptions = { + prisma: PrismaClient; + worker: EngineWorker; + eventBus: EventBus; + logger: Logger; + tracer: Tracer; +}; + +export class WaitpointSystem { + private readonly prisma: PrismaClient; + private readonly worker: EngineWorker; + private readonly eventBus: EventBus; + private readonly logger: Logger; + private readonly tracer: Tracer; + + constructor(private readonly options: WaitpointSystemOptions) { + this.prisma = options.prisma; + this.worker = options.worker; + this.eventBus = options.eventBus; + this.logger = options.logger; + this.tracer = options.tracer; + } + + public async clearBlockingWaitpoints({ + runId, + tx, + }: { + runId: string; + tx?: PrismaClientOrTransaction; + }) { + const prisma = tx ?? this.prisma; + const deleted = await prisma.taskRunWaitpoint.deleteMany({ + where: { + taskRunId: runId, + }, + }); + + return deleted.count; + } + + /** This completes a waitpoint and updates all entries so the run isn't blocked, + * if they're no longer blocked. This doesn't suffer from race conditions. */ + async completeWaitpoint({ + id, + output, + }: { + id: string; + output?: { + value: string; + type?: string; + isError: boolean; + }; + }): Promise { + const result = await $transaction( + this.prisma, + async (tx) => { + // 1. Find the TaskRuns blocked by this waitpoint + const affectedTaskRuns = await tx.taskRunWaitpoint.findMany({ + where: { waitpointId: id }, + select: { taskRunId: true, spanIdToComplete: true, createdAt: true }, + }); + + if (affectedTaskRuns.length === 0) { + this.logger.warn(`completeWaitpoint: No TaskRunWaitpoints found for waitpoint`, { + waitpointId: id, + }); + } + + // 2. Update the waitpoint to completed (only if it's pending) + let waitpoint: Waitpoint | null = null; + try { + waitpoint = await tx.waitpoint.update({ + where: { id, status: "PENDING" }, + data: { + status: "COMPLETED", + completedAt: new Date(), + output: output?.value, + outputType: output?.type, + outputIsError: output?.isError, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { + waitpoint = await tx.waitpoint.findFirst({ + where: { id }, + }); + } else { + this.logger.log("completeWaitpoint: error updating waitpoint:", { error }); + throw error; + } + } + + return { waitpoint, affectedTaskRuns }; + }, + (error) => { + this.logger.error(`completeWaitpoint: Error completing waitpoint ${id}, retrying`, { + error, + }); + throw error; + } + ); + + if (!result) { + throw new Error(`Waitpoint couldn't be updated`); + } + + if (!result.waitpoint) { + throw new Error(`Waitpoint ${id} not found`); + } + + //schedule trying to continue the runs + for (const run of result.affectedTaskRuns) { + await this.worker.enqueue({ + //this will debounce the call + id: `continueRunIfUnblocked:${run.taskRunId}`, + job: "continueRunIfUnblocked", + payload: { runId: run.taskRunId }, + //50ms in the future + availableAt: new Date(Date.now() + 50), + }); + + // emit an event to complete associated cached runs + if (run.spanIdToComplete) { + this.eventBus.emit("cachedRunCompleted", { + time: new Date(), + span: { + id: run.spanIdToComplete, + createdAt: run.createdAt, + }, + blockedRunId: run.taskRunId, + hasError: output?.isError ?? false, + }); + } + } + + return result.waitpoint; + } +} diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index 3dd49e2701..2d548f1bae 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -1,10 +1,11 @@ -import { type WorkerConcurrencyOptions } from "@internal/redis-worker"; +import { type RedisOptions } from "@internal/redis"; +import { Worker, type WorkerConcurrencyOptions } from "@internal/redis-worker"; import { Tracer } from "@internal/tracing"; import { MachinePreset, MachinePresetName, QueueOptions, RetryOptions } from "@trigger.dev/core/v3"; import { PrismaClient } from "@trigger.dev/database"; -import { type RedisOptions } from "@internal/redis"; -import { MinimalAuthenticatedEnvironment } from "../shared/index.js"; import { FairQueueSelectionStrategyOptions } from "../run-queue/fairQueueSelectionStrategy.js"; +import { MinimalAuthenticatedEnvironment } from "../shared/index.js"; +import { workerCatalog } from "./workerCatalog.js"; export type RunEngineOptions = { prisma: PrismaClient; @@ -105,3 +106,5 @@ export type TriggerParams = { workerId?: string; runnerId?: string; }; + +export type EngineWorker = Worker; diff --git a/internal-packages/run-engine/src/engine/workerCatalog.ts b/internal-packages/run-engine/src/engine/workerCatalog.ts new file mode 100644 index 0000000000..e4d945d654 --- /dev/null +++ b/internal-packages/run-engine/src/engine/workerCatalog.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +export const workerCatalog = { + finishWaitpoint: { + schema: z.object({ + waitpointId: z.string(), + error: z.string().optional(), + }), + visibilityTimeoutMs: 5000, + }, + heartbeatSnapshot: { + schema: z.object({ + runId: z.string(), + snapshotId: z.string(), + }), + visibilityTimeoutMs: 5000, + }, + expireRun: { + schema: z.object({ + runId: z.string(), + }), + visibilityTimeoutMs: 5000, + }, + cancelRun: { + schema: z.object({ + runId: z.string(), + completedAt: z.coerce.date(), + reason: z.string().optional(), + }), + visibilityTimeoutMs: 5000, + }, + queueRunsWaitingForWorker: { + schema: z.object({ + backgroundWorkerId: z.string(), + }), + visibilityTimeoutMs: 5000, + }, + tryCompleteBatch: { + schema: z.object({ + batchId: z.string(), + }), + visibilityTimeoutMs: 10_000, + }, + continueRunIfUnblocked: { + schema: z.object({ + runId: z.string(), + }), + visibilityTimeoutMs: 10_000, + }, + enqueueDelayedRun: { + schema: z.object({ + runId: z.string(), + }), + visibilityTimeoutMs: 10_000, + }, +}; From 717cec879b9a3c50262f71ba54b45e3e7ff2f30e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 16:08:19 +0000 Subject: [PATCH 24/38] move startRunAttempt to RunAttemptSystem --- .../run-engine/src/engine/index.ts | 278 +-------------- .../src/engine/systems/runAttemptSystem.ts | 318 +++++++++++++++++- 2 files changed, 326 insertions(+), 270 deletions(-) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 7ce4b2b869..10c000b319 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -267,6 +267,7 @@ export class RunEngine { executionSnapshotSystem: this.executionSnapshotSystem, batchSystem: this.batchSystem, waitpointSystem: this.waitpointSystem, + machines: this.options.machines, }); this.dequeueSystem = new DequeueSystem({ @@ -703,275 +704,14 @@ export class RunEngine { isWarmStart?: boolean; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; - - return startSpan( - this.tracer, - "startRunAttempt", - async (span) => { - return this.runLock.lock([runId], 5000, async () => { - const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); - - if (latestSnapshot.id !== snapshotId) { - //if there is a big delay between the snapshot and the attempt, the snapshot might have changed - //we just want to log because elsewhere it should have been put back into a state where it can be attempted - this.logger.warn( - "RunEngine.createRunAttempt(): snapshot has changed since the attempt was created, ignoring." - ); - throw new ServiceValidationError("Snapshot changed", 409); - } - - const environment = await this.#getAuthenticatedEnvironmentFromRun(runId, prisma); - if (!environment) { - throw new ServiceValidationError("Environment not found", 404); - } - - const taskRun = await prisma.taskRun.findFirst({ - where: { - id: runId, - }, - include: { - tags: true, - lockedBy: { - include: { - worker: { - select: { - id: true, - version: true, - sdkVersion: true, - cliVersion: true, - supportsLazyAttempts: true, - }, - }, - }, - }, - batchItems: { - include: { - batchTaskRun: true, - }, - }, - }, - }); - - this.logger.debug("Creating a task run attempt", { taskRun }); - - if (!taskRun) { - throw new ServiceValidationError("Task run not found", 404); - } - - span.setAttribute("projectId", taskRun.projectId); - span.setAttribute("environmentId", taskRun.runtimeEnvironmentId); - span.setAttribute("taskRunId", taskRun.id); - span.setAttribute("taskRunFriendlyId", taskRun.friendlyId); - - if (taskRun.status === "CANCELED") { - throw new ServiceValidationError("Task run is cancelled", 400); - } - - if (!taskRun.lockedBy) { - throw new ServiceValidationError("Task run is not locked", 400); - } - - const queue = await prisma.taskQueue.findUnique({ - where: { - runtimeEnvironmentId_name: { - runtimeEnvironmentId: environment.id, - name: taskRun.queue, - }, - }, - }); - - if (!queue) { - throw new ServiceValidationError("Queue not found", 404); - } - - //increment the attempt number (start at 1) - const nextAttemptNumber = (taskRun.attemptNumber ?? 0) + 1; - - if (nextAttemptNumber > MAX_TASK_RUN_ATTEMPTS) { - await this.runAttemptSystem.attemptFailed({ - runId: taskRun.id, - snapshotId, - completion: { - ok: false, - id: taskRun.id, - error: { - type: "INTERNAL_ERROR", - code: "TASK_RUN_CRASHED", - message: "Max attempts reached.", - }, - }, - tx: prisma, - }); - throw new ServiceValidationError("Max attempts reached", 400); - } - - this.eventBus.emit("runAttemptStarted", { - time: new Date(), - run: { - id: taskRun.id, - attemptNumber: nextAttemptNumber, - baseCostInCents: taskRun.baseCostInCents, - }, - organization: { - id: environment.organization.id, - }, - }); - - const result = await $transaction( - prisma, - async (tx) => { - const run = await tx.taskRun.update({ - where: { - id: taskRun.id, - }, - data: { - status: "EXECUTING", - attemptNumber: nextAttemptNumber, - executedAt: taskRun.attemptNumber === null ? new Date() : undefined, - }, - include: { - tags: true, - lockedBy: { - include: { worker: true }, - }, - }, - }); - - const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(tx, { - run, - snapshot: { - executionStatus: "EXECUTING", - description: `Attempt created, starting execution${ - isWarmStart ? " (warm start)" : "" - }`, - }, - environmentId: latestSnapshot.environmentId, - environmentType: latestSnapshot.environmentType, - workerId, - runnerId, - }); - - if (taskRun.ttl) { - //don't expire the run, it's going to execute - await this.worker.ack(`expireRun:${taskRun.id}`); - } - - return { run, snapshot: newSnapshot }; - }, - (error) => { - this.logger.error("RunEngine.createRunAttempt(): prisma.$transaction error", { - code: error.code, - meta: error.meta, - stack: error.stack, - message: error.message, - name: error.name, - }); - throw new ServiceValidationError( - "Failed to update task run and execution snapshot", - 500 - ); - } - ); - - if (!result) { - this.logger.error("RunEngine.createRunAttempt(): failed to create task run attempt", { - runId: taskRun.id, - nextAttemptNumber, - }); - throw new ServiceValidationError("Failed to create task run attempt", 500); - } - - const { run, snapshot } = result; - - const machinePreset = getMachinePreset({ - machines: this.options.machines.machines, - defaultMachine: this.options.machines.defaultMachine, - config: taskRun.lockedBy.machineConfig ?? {}, - run: taskRun, - }); - - const metadata = await parsePacket({ - data: taskRun.metadata ?? undefined, - dataType: taskRun.metadataType, - }); - - const execution: TaskRunExecution = { - task: { - id: run.lockedBy!.slug, - filePath: run.lockedBy!.filePath, - exportName: run.lockedBy!.exportName, - }, - attempt: { - number: nextAttemptNumber, - startedAt: latestSnapshot.updatedAt, - /** @deprecated */ - id: "deprecated", - /** @deprecated */ - backgroundWorkerId: "deprecated", - /** @deprecated */ - backgroundWorkerTaskId: "deprecated", - /** @deprecated */ - status: "deprecated", - }, - run: { - id: run.friendlyId, - payload: run.payload, - payloadType: run.payloadType, - createdAt: run.createdAt, - tags: run.tags.map((tag) => tag.name), - isTest: run.isTest, - idempotencyKey: run.idempotencyKey ?? undefined, - startedAt: run.startedAt ?? run.createdAt, - maxAttempts: run.maxAttempts ?? undefined, - version: run.lockedBy!.worker.version, - metadata, - maxDuration: run.maxDurationInSeconds ?? undefined, - /** @deprecated */ - context: undefined, - /** @deprecated */ - durationMs: run.usageDurationMs, - /** @deprecated */ - costInCents: run.costInCents, - /** @deprecated */ - baseCostInCents: run.baseCostInCents, - traceContext: run.traceContext as Record, - priority: run.priorityMs === 0 ? undefined : run.priorityMs / 1_000, - }, - queue: { - id: queue.friendlyId, - name: queue.name, - }, - environment: { - id: environment.id, - slug: environment.slug, - type: environment.type, - }, - organization: { - id: environment.organization.id, - slug: environment.organization.slug, - name: environment.organization.title, - }, - project: { - id: environment.project.id, - ref: environment.project.externalRef, - slug: environment.project.slug, - name: environment.project.name, - }, - batch: - taskRun.batchItems[0] && taskRun.batchItems[0].batchTaskRun - ? { id: taskRun.batchItems[0].batchTaskRun.friendlyId } - : undefined, - machine: machinePreset, - }; - - return { run, snapshot, execution }; - }); - }, - { - attributes: { runId, snapshotId }, - } - ); + return this.runAttemptSystem.startRunAttempt({ + runId, + snapshotId, + workerId, + runnerId, + isWarmStart, + tx, + }); } /** How a run is completed */ diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index e356c0fb1d..9ec8e924ba 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -1,4 +1,5 @@ import { + $transaction, PrismaClient, PrismaClientOrTransaction, RuntimeEnvironmentType, @@ -14,7 +15,9 @@ import { import { CompleteRunAttemptResult, ExecutionResult, + StartRunAttemptResult, TaskRunError, + TaskRunExecution, TaskRunExecutionResult, TaskRunFailedExecutionResult, TaskRunInternalError, @@ -26,10 +29,13 @@ import { ServiceValidationError } from "../index.js"; import { retryOutcomeFromCompletion } from "../retrying.js"; import { RunQueue } from "../../run-queue/index.js"; import { isExecuting } from "../statuses.js"; -import { EngineWorker } from "../types.js"; +import { EngineWorker, RunEngineOptions } from "../types.js"; import { runStatusFromError } from "../errors.js"; import { BatchSystem } from "./batchSystem.js"; import { WaitpointSystem } from "./waitpointSystem.js"; +import { MAX_TASK_RUN_ATTEMPTS } from "../consts.js"; +import { getMachinePreset } from "../machinePresets.js"; +import { parsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; export type RunAttemptSystemOptions = { prisma: PrismaClient; @@ -43,6 +49,7 @@ export type RunAttemptSystemOptions = { batchSystem: BatchSystem; waitpointSystem: WaitpointSystem; retryWarmStartThresholdMs?: number; + machines: RunEngineOptions["machines"]; }; export class RunAttemptSystem { @@ -70,6 +77,292 @@ export class RunAttemptSystem { this.waitpointSystem = options.waitpointSystem; } + public async startRunAttempt({ + runId, + snapshotId, + workerId, + runnerId, + isWarmStart, + tx, + }: { + runId: string; + snapshotId: string; + workerId?: string; + runnerId?: string; + isWarmStart?: boolean; + tx?: PrismaClientOrTransaction; + }): Promise { + const prisma = tx ?? this.prisma; + + return startSpan( + this.tracer, + "startRunAttempt", + async (span) => { + return this.runLock.lock([runId], 5000, async () => { + const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); + + if (latestSnapshot.id !== snapshotId) { + //if there is a big delay between the snapshot and the attempt, the snapshot might have changed + //we just want to log because elsewhere it should have been put back into a state where it can be attempted + this.logger.warn( + "RunEngine.createRunAttempt(): snapshot has changed since the attempt was created, ignoring." + ); + throw new ServiceValidationError("Snapshot changed", 409); + } + + const environment = await this.#getAuthenticatedEnvironmentFromRun(runId, prisma); + if (!environment) { + throw new ServiceValidationError("Environment not found", 404); + } + + const taskRun = await prisma.taskRun.findFirst({ + where: { + id: runId, + }, + include: { + tags: true, + lockedBy: { + include: { + worker: { + select: { + id: true, + version: true, + sdkVersion: true, + cliVersion: true, + supportsLazyAttempts: true, + }, + }, + }, + }, + batchItems: { + include: { + batchTaskRun: true, + }, + }, + }, + }); + + this.logger.debug("Creating a task run attempt", { taskRun }); + + if (!taskRun) { + throw new ServiceValidationError("Task run not found", 404); + } + + span.setAttribute("projectId", taskRun.projectId); + span.setAttribute("environmentId", taskRun.runtimeEnvironmentId); + span.setAttribute("taskRunId", taskRun.id); + span.setAttribute("taskRunFriendlyId", taskRun.friendlyId); + + if (taskRun.status === "CANCELED") { + throw new ServiceValidationError("Task run is cancelled", 400); + } + + if (!taskRun.lockedBy) { + throw new ServiceValidationError("Task run is not locked", 400); + } + + const queue = await prisma.taskQueue.findUnique({ + where: { + runtimeEnvironmentId_name: { + runtimeEnvironmentId: environment.id, + name: taskRun.queue, + }, + }, + }); + + if (!queue) { + throw new ServiceValidationError("Queue not found", 404); + } + + //increment the attempt number (start at 1) + const nextAttemptNumber = (taskRun.attemptNumber ?? 0) + 1; + + if (nextAttemptNumber > MAX_TASK_RUN_ATTEMPTS) { + await this.attemptFailed({ + runId: taskRun.id, + snapshotId, + completion: { + ok: false, + id: taskRun.id, + error: { + type: "INTERNAL_ERROR", + code: "TASK_RUN_CRASHED", + message: "Max attempts reached.", + }, + }, + tx: prisma, + }); + throw new ServiceValidationError("Max attempts reached", 400); + } + + this.eventBus.emit("runAttemptStarted", { + time: new Date(), + run: { + id: taskRun.id, + attemptNumber: nextAttemptNumber, + baseCostInCents: taskRun.baseCostInCents, + }, + organization: { + id: environment.organization.id, + }, + }); + + const result = await $transaction( + prisma, + async (tx) => { + const run = await tx.taskRun.update({ + where: { + id: taskRun.id, + }, + data: { + status: "EXECUTING", + attemptNumber: nextAttemptNumber, + executedAt: taskRun.attemptNumber === null ? new Date() : undefined, + }, + include: { + tags: true, + lockedBy: { + include: { worker: true }, + }, + }, + }); + + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(tx, { + run, + snapshot: { + executionStatus: "EXECUTING", + description: `Attempt created, starting execution${ + isWarmStart ? " (warm start)" : "" + }`, + }, + environmentId: latestSnapshot.environmentId, + environmentType: latestSnapshot.environmentType, + workerId, + runnerId, + }); + + if (taskRun.ttl) { + //don't expire the run, it's going to execute + await this.worker.ack(`expireRun:${taskRun.id}`); + } + + return { run, snapshot: newSnapshot }; + }, + (error) => { + this.logger.error("RunEngine.createRunAttempt(): prisma.$transaction error", { + code: error.code, + meta: error.meta, + stack: error.stack, + message: error.message, + name: error.name, + }); + throw new ServiceValidationError( + "Failed to update task run and execution snapshot", + 500 + ); + } + ); + + if (!result) { + this.logger.error("RunEngine.createRunAttempt(): failed to create task run attempt", { + runId: taskRun.id, + nextAttemptNumber, + }); + throw new ServiceValidationError("Failed to create task run attempt", 500); + } + + const { run, snapshot } = result; + + const machinePreset = getMachinePreset({ + machines: this.options.machines.machines, + defaultMachine: this.options.machines.defaultMachine, + config: taskRun.lockedBy.machineConfig ?? {}, + run: taskRun, + }); + + const metadata = await parsePacket({ + data: taskRun.metadata ?? undefined, + dataType: taskRun.metadataType, + }); + + const execution: TaskRunExecution = { + task: { + id: run.lockedBy!.slug, + filePath: run.lockedBy!.filePath, + exportName: run.lockedBy!.exportName, + }, + attempt: { + number: nextAttemptNumber, + startedAt: latestSnapshot.updatedAt, + /** @deprecated */ + id: "deprecated", + /** @deprecated */ + backgroundWorkerId: "deprecated", + /** @deprecated */ + backgroundWorkerTaskId: "deprecated", + /** @deprecated */ + status: "deprecated", + }, + run: { + id: run.friendlyId, + payload: run.payload, + payloadType: run.payloadType, + createdAt: run.createdAt, + tags: run.tags.map((tag) => tag.name), + isTest: run.isTest, + idempotencyKey: run.idempotencyKey ?? undefined, + startedAt: run.startedAt ?? run.createdAt, + maxAttempts: run.maxAttempts ?? undefined, + version: run.lockedBy!.worker.version, + metadata, + maxDuration: run.maxDurationInSeconds ?? undefined, + /** @deprecated */ + context: undefined, + /** @deprecated */ + durationMs: run.usageDurationMs, + /** @deprecated */ + costInCents: run.costInCents, + /** @deprecated */ + baseCostInCents: run.baseCostInCents, + traceContext: run.traceContext as Record, + priority: run.priorityMs === 0 ? undefined : run.priorityMs / 1_000, + }, + queue: { + id: queue.friendlyId, + name: queue.name, + }, + environment: { + id: environment.id, + slug: environment.slug, + type: environment.type, + }, + organization: { + id: environment.organization.id, + slug: environment.organization.slug, + name: environment.organization.title, + }, + project: { + id: environment.project.id, + ref: environment.project.externalRef, + slug: environment.project.slug, + name: environment.project.name, + }, + batch: + taskRun.batchItems[0] && taskRun.batchItems[0].batchTaskRun + ? { id: taskRun.batchItems[0].batchTaskRun.friendlyId } + : undefined, + machine: machinePreset, + }; + + return { run, snapshot, execution }; + }); + }, + { + attributes: { runId, snapshotId }, + } + ); + } + public async completeRunAttempt({ runId, snapshotId, @@ -894,4 +1187,27 @@ export class RunAttemptSystem { //cancel the heartbeats await this.worker.ack(`heartbeatSnapshot.${id}`); } + + async #getAuthenticatedEnvironmentFromRun(runId: string, tx?: PrismaClientOrTransaction) { + const prisma = tx ?? this.prisma; + const taskRun = await prisma.taskRun.findUnique({ + where: { + id: runId, + }, + include: { + runtimeEnvironment: { + include: { + organization: true, + project: true, + }, + }, + }, + }); + + if (!taskRun) { + return; + } + + return taskRun?.runtimeEnvironment; + } } From 63b43ff357a56110522d6e3319f27e5b1a90b57e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 17:22:33 +0000 Subject: [PATCH 25/38] more system work --- internal-packages/run-engine/README.md | 32 +- .../run-engine/src/engine/index.ts | 918 ++---------------- .../src/engine/systems/batchSystem.ts | 34 +- .../src/engine/systems/checkpointSystem.ts | 247 +++++ .../src/engine/systems/dequeueSystem.ts | 86 +- .../src/engine/systems/enqueueSystem.ts | 90 ++ .../engine/systems/executionSnapshotSystem.ts | 50 +- .../src/engine/systems/runAttemptSystem.ts | 116 +-- .../run-engine/src/engine/systems/systems.ts | 23 + .../src/engine/systems/waitpointSystem.ts | 546 ++++++++++- .../src/engine/tests/waitpoints.test.ts | 8 - 11 files changed, 1106 insertions(+), 1044 deletions(-) create mode 100644 internal-packages/run-engine/src/engine/systems/checkpointSystem.ts create mode 100644 internal-packages/run-engine/src/engine/systems/enqueueSystem.ts create mode 100644 internal-packages/run-engine/src/engine/systems/systems.ts diff --git a/internal-packages/run-engine/README.md b/internal-packages/run-engine/README.md index 766ad082b3..c49ea7a5d9 100644 --- a/internal-packages/run-engine/README.md +++ b/internal-packages/run-engine/README.md @@ -211,6 +211,7 @@ graph TD ESS[ExecutionSnapshotSystem] WS[WaitpointSystem] BS[BatchSystem] + ES[EnqueueSystem] %% Core Dependencies RE --> DS @@ -218,6 +219,7 @@ graph TD RE --> ESS RE --> WS RE --> BS + RE --> ES %% System Dependencies DS --> ESS @@ -227,6 +229,11 @@ graph TD RAS --> WS RAS --> BS + WS --> ESS + WS --> ES + + ES --> ESS + %% Shared Resources subgraph Resources PRI[(Prisma)] @@ -236,15 +243,17 @@ graph TD RL[RunLocker] EB[EventBus] WRK[Worker] + RCQ[ReleaseConcurrencyQueue] end %% Resource Dependencies RE -.-> Resources - DS -.-> PRI & LOG & TRC & RQ & RL + DS -.-> PRI & LOG & TRC & RQ & RL & EB & WRK RAS -.-> PRI & LOG & TRC & RL & EB & RQ & WRK ESS -.-> PRI & LOG & TRC & WRK & EB - WS -.-> PRI & LOG & TRC & WRK & EB + WS -.-> PRI & LOG & TRC & WRK & EB & RCQ BS -.-> PRI & LOG & TRC & WRK + ES -.-> PRI & LOG & TRC & WRK & EB & RQ ``` ## System Responsibilities @@ -274,6 +283,7 @@ graph TD - Manages waitpoints for task synchronization - Handles waitpoint completion - Coordinates blocked runs +- Manages concurrency release ### BatchSystem @@ -281,6 +291,12 @@ graph TD - Handles batch completion - Coordinates batch-related task runs +### EnqueueSystem + +- Handles enqueueing of runs +- Manages run scheduling +- Coordinates with execution snapshots + ## Shared Resources - **Prisma**: Database access @@ -290,11 +306,13 @@ graph TD - **RunLocker**: Run locking mechanism - **EventBus**: Event communication - **Worker**: Background task execution +- **ReleaseConcurrencyQueue**: Manages concurrency token release ## Key Interactions -1. **RunEngine** orchestrates all systems and holds shared resources -2. **DequeueSystem** works closely with **RunAttemptSystem** for task execution -3. **RunAttemptSystem** coordinates with **WaitpointSystem** and **BatchSystem** for run completion -4. **ExecutionSnapshotSystem** is used by all other systems to track state -5. All systems share common resources but have specific responsibilities +1. **RunEngine** orchestrates all systems and manages shared resources +2. **DequeueSystem** works with **RunAttemptSystem** for task execution +3. **RunAttemptSystem** coordinates with **WaitpointSystem** and **BatchSystem** +4. **WaitpointSystem** uses **EnqueueSystem** for run scheduling +5. **ExecutionSnapshotSystem** is used by all other systems to track state +6. All systems share common resources through the `SystemResources` interface diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 10c000b319..bb84256419 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -9,58 +9,46 @@ import { DequeuedMessage, ExecutionResult, MachineResources, - parsePacket, RunExecutionData, StartRunAttemptResult, TaskRunError, - TaskRunExecution, TaskRunExecutionResult, - timeoutError, } from "@trigger.dev/core/v3"; import { BatchId, - CheckpointId, parseNaturalLanguageDuration, QueueId, RunId, WaitpointId, } from "@trigger.dev/core/v3/isomorphic"; import { - $transaction, Prisma, PrismaClient, PrismaClientOrTransaction, TaskRun, TaskRunExecutionSnapshot, - TaskRunExecutionStatus, Waitpoint, } from "@trigger.dev/database"; import { assertNever } from "assert-never"; -import { nanoid } from "nanoid"; import { EventEmitter } from "node:events"; import { FairQueueSelectionStrategy } from "../run-queue/fairQueueSelectionStrategy.js"; import { RunQueue } from "../run-queue/index.js"; import { RunQueueFullKeyProducer } from "../run-queue/keyProducer.js"; import { MinimalAuthenticatedEnvironment } from "../shared/index.js"; -import { MAX_TASK_RUN_ATTEMPTS } from "./consts.js"; import { EventBus, EventBusEvents, sendNotificationToWorker } from "./eventBus.js"; import { RunLocker } from "./locking.js"; -import { getMachinePreset } from "./machinePresets.js"; import { ReleaseConcurrencyTokenBucketQueue } from "./releaseConcurrencyTokenBucketQueue.js"; -import { - canReleaseConcurrency, - isCheckpointable, - isExecuting, - isPendingExecuting, -} from "./statuses.js"; +import { canReleaseConcurrency, isExecuting } from "./statuses.js"; import { BatchSystem } from "./systems/batchSystem.js"; +import { CheckpointSystem } from "./systems/checkpointSystem.js"; import { DequeueSystem } from "./systems/dequeueSystem.js"; +import { EnqueueSystem } from "./systems/enqueueSystem.js"; import { - executionResultFromSnapshot, ExecutionSnapshotSystem, getLatestExecutionSnapshot, } from "./systems/executionSnapshotSystem.js"; import { RunAttemptSystem } from "./systems/runAttemptSystem.js"; +import { SystemResources } from "./systems/systems.js"; import { WaitpointSystem } from "./systems/waitpointSystem.js"; import { EngineWorker, HeartbeatTimeouts, RunEngineOptions, TriggerParams } from "./types.js"; import { workerCatalog } from "./workerCatalog.js"; @@ -85,6 +73,8 @@ export class RunEngine { dequeueSystem: DequeueSystem; waitpointSystem: WaitpointSystem; batchSystem: BatchSystem; + enqueueSystem: EnqueueSystem; + checkpointSystem: CheckpointSystem; constructor(private readonly options: RunEngineOptions) { this.prisma = options.prisma; @@ -164,7 +154,7 @@ export class RunEngine { await this.batchSystem.performCompleteBatch({ batchId: payload.batchId }); }, continueRunIfUnblocked: async ({ payload }) => { - await this.#continueRunIfUnblocked({ + await this.waitpointSystem.continueRunIfUnblocked({ runId: payload.runId, }); }, @@ -232,38 +222,44 @@ export class RunEngine { tracer: this.tracer, }); - this.executionSnapshotSystem = new ExecutionSnapshotSystem({ + const resources: SystemResources = { + prisma: this.prisma, worker: this.worker, eventBus: this.eventBus, - heartbeatTimeouts: this.heartbeatTimeouts, - prisma: this.prisma, logger: this.logger, tracer: this.tracer, + runLock: this.runLock, + runQueue: this.runQueue, + releaseConcurrencyQueue: this.releaseConcurrencyQueue, + }; + + this.executionSnapshotSystem = new ExecutionSnapshotSystem({ + resources, + heartbeatTimeouts: this.heartbeatTimeouts, + }); + + this.checkpointSystem = new CheckpointSystem({ + resources, + executionSnapshotSystem: this.executionSnapshotSystem, + }); + + this.enqueueSystem = new EnqueueSystem({ + resources, + executionSnapshotSystem: this.executionSnapshotSystem, }); this.waitpointSystem = new WaitpointSystem({ - prisma: this.prisma, - worker: this.worker, - eventBus: this.eventBus, - logger: this.logger, - tracer: this.tracer, + resources, + executionSnapshotSystem: this.executionSnapshotSystem, + enqueueSystem: this.enqueueSystem, }); this.batchSystem = new BatchSystem({ - prisma: this.prisma, - logger: this.logger, - tracer: this.tracer, - worker: this.worker, + resources, }); this.runAttemptSystem = new RunAttemptSystem({ - prisma: this.prisma, - logger: this.logger, - tracer: this.tracer, - runLock: this.runLock, - eventBus: this.eventBus, - runQueue: this.runQueue, - worker: this.worker, + resources, executionSnapshotSystem: this.executionSnapshotSystem, batchSystem: this.batchSystem, waitpointSystem: this.waitpointSystem, @@ -271,14 +267,10 @@ export class RunEngine { }); this.dequeueSystem = new DequeueSystem({ - prisma: this.prisma, - queue: this.runQueue, - runLock: this.runLock, - logger: this.logger, - machines: this.options.machines, - tracer: this.tracer, + resources, executionSnapshotSystem: this.executionSnapshotSystem, runAttemptSystem: this.runAttemptSystem, + machines: this.options.machines, }); } @@ -460,19 +452,21 @@ export class RunEngine { await this.runLock.lock([taskRun.id], 5000, async (signal) => { //create associated waitpoint (this completes when the run completes) - const associatedWaitpoint = await this.#createRunAssociatedWaitpoint(prisma, { - projectId: environment.project.id, - environmentId: environment.id, - completedByTaskRunId: taskRun.id, - }); + const associatedWaitpoint = await this.waitpointSystem.createRunAssociatedWaitpoint( + prisma, + { + projectId: environment.project.id, + environmentId: environment.id, + completedByTaskRunId: taskRun.id, + } + ); //triggerAndWait or batchTriggerAndWait if (resumeParentOnCompletion && parentTaskRunId) { //this will block the parent run from continuing until this waitpoint is completed (and removed) - await this.blockRunWithWaitpoint({ + await this.waitpointSystem.blockRunWithWaitpoint({ runId: parentTaskRunId, waitpoints: associatedWaitpoint.id, - environmentId: associatedWaitpoint.environmentId, projectId: associatedWaitpoint.projectId, organizationId: environment.organization.id, batch, @@ -555,7 +549,7 @@ export class RunEngine { availableAt: taskRun.delayUntil, }); } else { - await this.#enqueueRun({ + await this.enqueueSystem.enqueueRun({ run: taskRun, env: environment, timestamp: Date.now() - taskRun.priorityMs, @@ -799,7 +793,7 @@ export class RunEngine { return startSpan( this.tracer, "rescheduleRun", - async (span) => { + async () => { return await this.runLock.lock([runId], 5_000, async () => { const snapshot = await getLatestExecutionSnapshot(prisma, runId); @@ -879,70 +873,14 @@ export class RunEngine { idempotencyKeyExpiresAt?: Date; tx?: PrismaClientOrTransaction; }) { - const prisma = tx ?? this.prisma; - - const existingWaitpoint = idempotencyKey - ? await prisma.waitpoint.findUnique({ - where: { - environmentId_idempotencyKey: { - environmentId, - idempotencyKey, - }, - }, - }) - : undefined; - - if (existingWaitpoint) { - if ( - existingWaitpoint.idempotencyKeyExpiresAt && - new Date() > existingWaitpoint.idempotencyKeyExpiresAt - ) { - //the idempotency key has expired - //remove the waitpoint idempotencyKey - await prisma.waitpoint.update({ - where: { - id: existingWaitpoint.id, - }, - data: { - idempotencyKey: nanoid(24), - inactiveIdempotencyKey: existingWaitpoint.idempotencyKey, - }, - }); - - //let it fall through to create a new waitpoint - } else { - return { waitpoint: existingWaitpoint, isCached: true }; - } - } - - const waitpoint = await prisma.waitpoint.upsert({ - where: { - environmentId_idempotencyKey: { - environmentId, - idempotencyKey: idempotencyKey ?? nanoid(24), - }, - }, - create: { - ...WaitpointId.generate(), - type: "DATETIME", - idempotencyKey: idempotencyKey ?? nanoid(24), - idempotencyKeyExpiresAt, - userProvidedIdempotencyKey: !!idempotencyKey, - environmentId, - projectId, - completedAfter, - }, - update: {}, - }); - - await this.worker.enqueue({ - id: `finishWaitpoint.${waitpoint.id}`, - job: "finishWaitpoint", - payload: { waitpointId: waitpoint.id }, - availableAt: completedAfter, + return this.waitpointSystem.createDateTimeWaitpoint({ + projectId, + environmentId, + completedAfter, + idempotencyKey, + idempotencyKeyExpiresAt, + tx, }); - - return { waitpoint, isCached: false }; } /** This creates a MANUAL waitpoint, that can be explicitly completed (or failed). @@ -961,74 +899,13 @@ export class RunEngine { idempotencyKeyExpiresAt?: Date; timeout?: Date; }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { - const existingWaitpoint = idempotencyKey - ? await this.prisma.waitpoint.findUnique({ - where: { - environmentId_idempotencyKey: { - environmentId, - idempotencyKey, - }, - }, - }) - : undefined; - - if (existingWaitpoint) { - if ( - existingWaitpoint.idempotencyKeyExpiresAt && - new Date() > existingWaitpoint.idempotencyKeyExpiresAt - ) { - //the idempotency key has expired - //remove the waitpoint idempotencyKey - await this.prisma.waitpoint.update({ - where: { - id: existingWaitpoint.id, - }, - data: { - idempotencyKey: nanoid(24), - inactiveIdempotencyKey: existingWaitpoint.idempotencyKey, - }, - }); - - //let it fall through to create a new waitpoint - } else { - return { waitpoint: existingWaitpoint, isCached: true }; - } - } - - const waitpoint = await this.prisma.waitpoint.upsert({ - where: { - environmentId_idempotencyKey: { - environmentId, - idempotencyKey: idempotencyKey ?? nanoid(24), - }, - }, - create: { - ...WaitpointId.generate(), - type: "MANUAL", - idempotencyKey: idempotencyKey ?? nanoid(24), - idempotencyKeyExpiresAt, - userProvidedIdempotencyKey: !!idempotencyKey, - environmentId, - projectId, - completedAfter: timeout, - }, - update: {}, + return this.waitpointSystem.createManualWaitpoint({ + environmentId, + projectId, + idempotencyKey, + idempotencyKeyExpiresAt, + timeout, }); - - //schedule the timeout - if (timeout) { - await this.worker.enqueue({ - id: `finishWaitpoint.${waitpoint.id}`, - job: "finishWaitpoint", - payload: { - waitpointId: waitpoint.id, - error: JSON.stringify(timeoutError(timeout)), - }, - availableAt: timeout, - }); - } - - return { waitpoint, isCached: false }; } /** This block a run with a BATCH waitpoint. @@ -1067,7 +944,6 @@ export class RunEngine { await this.blockRunWithWaitpoint({ runId, waitpoints: waitpoint.id, - environmentId, projectId, organizationId, batch: { id: batchId }, @@ -1173,11 +1049,9 @@ export class RunEngine { batch, workerId, runnerId, - tx, }: { runId: string; waitpoints: string | string[]; - environmentId: string; projectId: string; organizationId: string; releaseConcurrency?: boolean; @@ -1188,145 +1062,18 @@ export class RunEngine { runnerId?: string; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; - - let $waitpoints = typeof waitpoints === "string" ? [waitpoints] : waitpoints; - - return await this.runLock.lock([runId], 5000, async () => { - let snapshot: TaskRunExecutionSnapshot = await getLatestExecutionSnapshot(prisma, runId); - - //block the run with the waitpoints, returning how many waitpoints are pending - const insert = await prisma.$queryRaw<{ pending_count: BigInt }[]>` - WITH inserted AS ( - INSERT INTO "TaskRunWaitpoint" ("id", "taskRunId", "waitpointId", "projectId", "createdAt", "updatedAt", "spanIdToComplete", "batchId", "batchIndex") - SELECT - gen_random_uuid(), - ${runId}, - w.id, - ${projectId}, - NOW(), - NOW(), - ${spanIdToComplete ?? null}, - ${batch?.id ?? null}, - ${batch?.index ?? null} - FROM "Waitpoint" w - WHERE w.id IN (${Prisma.join($waitpoints)}) - ON CONFLICT DO NOTHING - RETURNING "waitpointId" - ) - SELECT COUNT(*) as pending_count - FROM inserted i - JOIN "Waitpoint" w ON w.id = i."waitpointId" - WHERE w.status = 'PENDING';`; - - const pendingCount = Number(insert.at(0)?.pending_count ?? 0); - - let newStatus: TaskRunExecutionStatus = "SUSPENDED"; - if ( - snapshot.executionStatus === "EXECUTING" || - snapshot.executionStatus === "EXECUTING_WITH_WAITPOINTS" - ) { - newStatus = "EXECUTING_WITH_WAITPOINTS"; - } - - //if the state has changed, create a new snapshot - if (newStatus !== snapshot.executionStatus) { - snapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { - run: { - id: snapshot.runId, - status: snapshot.runStatus, - attemptNumber: snapshot.attemptNumber, - }, - snapshot: { - executionStatus: newStatus, - description: "Run was blocked by a waitpoint.", - }, - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - batchId: batch?.id ?? snapshot.batchId ?? undefined, - workerId, - runnerId, - }); - - // Let the worker know immediately, so it can suspend the run - await sendNotificationToWorker({ runId, snapshot, eventBus: this.eventBus }); - } - - if (timeout) { - for (const waitpoint of $waitpoints) { - await this.worker.enqueue({ - id: `finishWaitpoint.${waitpoint}`, - job: "finishWaitpoint", - payload: { - waitpointId: waitpoint, - error: JSON.stringify(timeoutError(timeout)), - }, - availableAt: timeout, - }); - } - } - - //no pending waitpoint, schedule unblocking the run - //debounce if we're rapidly adding waitpoints - if (pendingCount === 0) { - await this.worker.enqueue({ - //this will debounce the call - id: `continueRunIfUnblocked:${runId}`, - job: "continueRunIfUnblocked", - payload: { runId: runId }, - //in the near future - availableAt: new Date(Date.now() + 50), - }); - } else { - if (releaseConcurrency) { - //release concurrency - await this.#attemptToReleaseConcurrency(organizationId, snapshot); - } - } - - return snapshot; - }); - } - - async #attemptToReleaseConcurrency(orgId: string, snapshot: TaskRunExecutionSnapshot) { - // Go ahead and release concurrency immediately if the run is in a development environment - if (snapshot.environmentType === "DEVELOPMENT") { - return await this.runQueue.releaseConcurrency(orgId, snapshot.runId); - } - - const run = await this.prisma.taskRun.findFirst({ - where: { - id: snapshot.runId, - }, - select: { - runtimeEnvironment: { - select: { - id: true, - projectId: true, - organizationId: true, - }, - }, - }, + return this.waitpointSystem.blockRunWithWaitpoint({ + runId, + waitpoints, + projectId, + organizationId, + releaseConcurrency, + timeout, + spanIdToComplete, + batch, + workerId, + runnerId, }); - - if (!run) { - this.logger.error("Run not found for attemptToReleaseConcurrency", { - runId: snapshot.runId, - }); - - return; - } - - await this.releaseConcurrencyQueue.attemptToRelease( - { - orgId: run.runtimeEnvironment.organizationId, - projectId: run.runtimeEnvironment.projectId, - envId: run.runtimeEnvironment.id, - }, - snapshot.runId - ); - - return; } async #executeReleasedConcurrencyFromQueue( @@ -1371,89 +1118,7 @@ export class RunEngine { isError: boolean; }; }): Promise { - const result = await $transaction( - this.prisma, - async (tx) => { - // 1. Find the TaskRuns blocked by this waitpoint - const affectedTaskRuns = await tx.taskRunWaitpoint.findMany({ - where: { waitpointId: id }, - select: { taskRunId: true, spanIdToComplete: true, createdAt: true }, - }); - - if (affectedTaskRuns.length === 0) { - this.logger.warn(`completeWaitpoint: No TaskRunWaitpoints found for waitpoint`, { - waitpointId: id, - }); - } - - // 2. Update the waitpoint to completed (only if it's pending) - let waitpoint: Waitpoint | null = null; - try { - waitpoint = await tx.waitpoint.update({ - where: { id, status: "PENDING" }, - data: { - status: "COMPLETED", - completedAt: new Date(), - output: output?.value, - outputType: output?.type, - outputIsError: output?.isError, - }, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { - waitpoint = await tx.waitpoint.findFirst({ - where: { id }, - }); - } else { - this.logger.log("completeWaitpoint: error updating waitpoint:", { error }); - throw error; - } - } - - return { waitpoint, affectedTaskRuns }; - }, - (error) => { - this.logger.error(`completeWaitpoint: Error completing waitpoint ${id}, retrying`, { - error, - }); - throw error; - } - ); - - if (!result) { - throw new Error(`Waitpoint couldn't be updated`); - } - - if (!result.waitpoint) { - throw new Error(`Waitpoint ${id} not found`); - } - - //schedule trying to continue the runs - for (const run of result.affectedTaskRuns) { - await this.worker.enqueue({ - //this will debounce the call - id: `continueRunIfUnblocked:${run.taskRunId}`, - job: "continueRunIfUnblocked", - payload: { runId: run.taskRunId }, - //50ms in the future - availableAt: new Date(Date.now() + 50), - }); - - // emit an event to complete associated cached runs - if (run.spanIdToComplete) { - this.eventBus.emit("cachedRunCompleted", { - time: new Date(), - span: { - id: run.spanIdToComplete, - createdAt: run.createdAt, - }, - blockedRunId: run.taskRunId, - hasError: output?.isError ?? false, - }); - } - } - - return result.waitpoint; + return this.waitpointSystem.completeWaitpoint({ id, output }); } /** @@ -1475,130 +1140,13 @@ export class RunEngine { runnerId?: string; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; - - return await this.runLock.lock([runId], 5_000, async () => { - const snapshot = await getLatestExecutionSnapshot(prisma, runId); - if (snapshot.id !== snapshotId) { - this.eventBus.emit("incomingCheckpointDiscarded", { - time: new Date(), - run: { - id: runId, - }, - checkpoint: { - discardReason: "Not the latest snapshot", - metadata: checkpoint, - }, - snapshot: { - id: snapshot.id, - executionStatus: snapshot.executionStatus, - }, - }); - - return { - ok: false as const, - error: "Not the latest snapshot", - }; - } - - if (!isCheckpointable(snapshot.executionStatus)) { - this.logger.error("Tried to createCheckpoint on a run in an invalid state", { - snapshot, - }); - - this.eventBus.emit("incomingCheckpointDiscarded", { - time: new Date(), - run: { - id: runId, - }, - checkpoint: { - discardReason: `Status ${snapshot.executionStatus} is not checkpointable`, - metadata: checkpoint, - }, - snapshot: { - id: snapshot.id, - executionStatus: snapshot.executionStatus, - }, - }); - - return { - ok: false as const, - error: `Status ${snapshot.executionStatus} is not checkpointable`, - }; - } - - // Get the run and update the status - const run = await this.prisma.taskRun.update({ - where: { - id: runId, - }, - data: { - status: "WAITING_TO_RESUME", - }, - select: { - id: true, - status: true, - attemptNumber: true, - runtimeEnvironment: { - select: { - id: true, - projectId: true, - organizationId: true, - }, - }, - }, - }); - - if (!run) { - this.logger.error("Run not found for createCheckpoint", { - snapshot, - }); - - throw new ServiceValidationError("Run not found", 404); - } - - // Create the checkpoint - const taskRunCheckpoint = await prisma.taskRunCheckpoint.create({ - data: { - ...CheckpointId.generate(), - type: checkpoint.type, - location: checkpoint.location, - imageRef: checkpoint.imageRef, - reason: checkpoint.reason, - runtimeEnvironmentId: run.runtimeEnvironment.id, - projectId: run.runtimeEnvironment.projectId, - }, - }); - - //create a new execution snapshot, with the checkpoint - const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { - run, - snapshot: { - executionStatus: "SUSPENDED", - description: "Run was suspended after creating a checkpoint.", - }, - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - checkpointId: taskRunCheckpoint.id, - workerId, - runnerId, - }); - - // Refill the token bucket for the release concurrency queue - await this.releaseConcurrencyQueue.refillTokens( - { - orgId: run.runtimeEnvironment.organizationId, - projectId: run.runtimeEnvironment.projectId, - envId: run.runtimeEnvironment.id, - }, - 1 - ); - - return { - ok: true as const, - ...executionResultFromSnapshot(newSnapshot), - checkpoint: taskRunCheckpoint, - } satisfies CreateCheckpointResult; + return this.checkpointSystem.createCheckpoint({ + runId, + snapshotId, + checkpoint, + workerId, + runnerId, + tx, }); } @@ -1618,61 +1166,12 @@ export class RunEngine { runnerId?: string; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; - - return await this.runLock.lock([runId], 5_000, async () => { - const snapshot = await getLatestExecutionSnapshot(prisma, runId); - - if (snapshot.id !== snapshotId) { - throw new ServiceValidationError("Snapshot ID doesn't match the latest snapshot", 400); - } - - if (!isPendingExecuting(snapshot.executionStatus)) { - throw new ServiceValidationError("Snapshot is not in a valid state to continue", 400); - } - - // Get the run and update the status - const run = await this.prisma.taskRun.update({ - where: { - id: runId, - }, - data: { - status: "EXECUTING", - }, - select: { - id: true, - status: true, - attemptNumber: true, - }, - }); - - if (!run) { - this.logger.error("Run not found for createCheckpoint", { - snapshot, - }); - - throw new ServiceValidationError("Run not found", 404); - } - - const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { - run, - snapshot: { - executionStatus: "EXECUTING", - description: "Run was continued after being suspended", - }, - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - completedWaitpoints: snapshot.completedWaitpoints, - workerId, - runnerId, - }); - - // Let worker know about the new snapshot so it can continue the run - await sendNotificationToWorker({ runId, snapshot: newSnapshot, eventBus: this.eventBus }); - - return { - ...executionResultFromSnapshot(newSnapshot), - } satisfies ExecutionResult; + return this.checkpointSystem.continueRunExecution({ + runId, + snapshotId, + workerId, + runnerId, + tx, }); } @@ -1866,205 +1365,6 @@ export class RunEngine { }); } - //MARK: RunQueue - /** The run can be added to the queue. When it's pulled from the queue it will be executed. */ - async #enqueueRun({ - run, - env, - timestamp, - tx, - snapshot, - batchId, - checkpointId, - completedWaitpoints, - workerId, - runnerId, - }: { - run: TaskRun; - env: MinimalAuthenticatedEnvironment; - timestamp: number; - tx?: PrismaClientOrTransaction; - snapshot?: { - status?: Extract; - description?: string; - }; - batchId?: string; - checkpointId?: string; - completedWaitpoints?: { - id: string; - index?: number; - }[]; - workerId?: string; - runnerId?: string; - }): Promise { - const prisma = tx ?? this.prisma; - - await this.runLock.lock([run.id], 5000, async () => { - const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { - run: run, - snapshot: { - executionStatus: snapshot?.status ?? "QUEUED", - description: snapshot?.description ?? "Run was QUEUED", - }, - batchId, - environmentId: env.id, - environmentType: env.type, - checkpointId, - completedWaitpoints, - workerId, - runnerId, - }); - - const masterQueues = [run.masterQueue]; - if (run.secondaryMasterQueue) { - masterQueues.push(run.secondaryMasterQueue); - } - - await this.runQueue.enqueueMessage({ - env, - masterQueues, - message: { - runId: run.id, - taskIdentifier: run.taskIdentifier, - orgId: env.organization.id, - projectId: env.project.id, - environmentId: env.id, - environmentType: env.type, - queue: run.queue, - concurrencyKey: run.concurrencyKey ?? undefined, - timestamp, - attempt: 0, - }, - }); - }); - } - - async #continueRunIfUnblocked({ runId }: { runId: string }) { - // 1. Get the any blocking waitpoints - const blockingWaitpoints = await this.prisma.taskRunWaitpoint.findMany({ - where: { taskRunId: runId }, - select: { - batchId: true, - batchIndex: true, - waitpoint: { - select: { id: true, status: true }, - }, - }, - }); - - // 2. There are blockers still, so do nothing - if (blockingWaitpoints.some((w) => w.waitpoint.status !== "COMPLETED")) { - return; - } - - // 3. Get the run with environment - const run = await this.prisma.taskRun.findFirst({ - where: { - id: runId, - }, - include: { - runtimeEnvironment: { - select: { - id: true, - type: true, - maximumConcurrencyLimit: true, - project: { select: { id: true } }, - organization: { select: { id: true } }, - }, - }, - }, - }); - - if (!run) { - throw new Error(`#continueRunIfUnblocked: run not found: ${runId}`); - } - - //4. Continue the run whether it's executing or not - await this.runLock.lock([runId], 5000, async () => { - const snapshot = await getLatestExecutionSnapshot(this.prisma, runId); - - //run is still executing, send a message to the worker - if (isExecuting(snapshot.executionStatus)) { - const result = await this.runQueue.reacquireConcurrency( - run.runtimeEnvironment.organization.id, - runId - ); - - if (result) { - const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot( - this.prisma, - { - run: { - id: runId, - status: snapshot.runStatus, - attemptNumber: snapshot.attemptNumber, - }, - snapshot: { - executionStatus: "EXECUTING", - description: "Run was continued, whilst still executing.", - }, - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - batchId: snapshot.batchId ?? undefined, - completedWaitpoints: blockingWaitpoints.map((b) => ({ - id: b.waitpoint.id, - index: b.batchIndex ?? undefined, - })), - } - ); - - await sendNotificationToWorker({ runId, snapshot: newSnapshot, eventBus: this.eventBus }); - } else { - // Because we cannot reacquire the concurrency, we need to enqueue the run again - // and because the run is still executing, we need to set the status to QUEUED_EXECUTING - await this.#enqueueRun({ - run, - env: run.runtimeEnvironment, - timestamp: run.createdAt.getTime() - run.priorityMs, - snapshot: { - status: "QUEUED_EXECUTING", - description: "Run can continue, but is waiting for concurrency", - }, - batchId: snapshot.batchId ?? undefined, - completedWaitpoints: blockingWaitpoints.map((b) => ({ - id: b.waitpoint.id, - index: b.batchIndex ?? undefined, - })), - }); - } - } else { - if (snapshot.executionStatus !== "RUN_CREATED" && !snapshot.checkpointId) { - // TODO: We're screwed, should probably fail the run immediately - throw new Error(`#continueRunIfUnblocked: run has no checkpoint: ${run.id}`); - } - - //put it back in the queue, with the original timestamp (w/ priority) - //this prioritizes dequeuing waiting runs over new runs - await this.#enqueueRun({ - run, - env: run.runtimeEnvironment, - timestamp: run.createdAt.getTime() - run.priorityMs, - snapshot: { - description: "Run was QUEUED, because all waitpoints are completed", - }, - batchId: snapshot.batchId ?? undefined, - completedWaitpoints: blockingWaitpoints.map((b) => ({ - id: b.waitpoint.id, - index: b.batchIndex ?? undefined, - })), - checkpointId: snapshot.checkpointId ?? undefined, - }); - } - }); - - //5. Remove the blocking waitpoints - await this.prisma.taskRunWaitpoint.deleteMany({ - where: { - taskRunId: runId, - }, - }); - } - async #enqueueDelayedRun({ runId }: { runId: string }) { const run = await this.prisma.taskRun.findFirst({ where: { id: runId }, @@ -2083,7 +1383,7 @@ export class RunEngine { } // Now we need to enqueue the run into the RunQueue - await this.#enqueueRun({ + await this.enqueueSystem.enqueueRun({ run, env: run.runtimeEnvironment, timestamp: run.createdAt.getTime() - run.priorityMs, @@ -2167,7 +1467,7 @@ export class RunEngine { status: "PENDING", }, }); - await this.#enqueueRun({ + await this.enqueueSystem.enqueueRun({ run: updatedRun, env: backgroundWorker.runtimeEnvironment, //add to the queue using the original run created time @@ -2184,29 +1484,6 @@ export class RunEngine { } } - //MARK: - Waitpoints - async #createRunAssociatedWaitpoint( - tx: PrismaClientOrTransaction, - { - projectId, - environmentId, - completedByTaskRunId, - }: { projectId: string; environmentId: string; completedByTaskRunId: string } - ) { - return tx.waitpoint.create({ - data: { - ...WaitpointId.generate(), - type: "RUN", - status: "PENDING", - idempotencyKey: nanoid(24), - userProvidedIdempotencyKey: false, - projectId, - environmentId, - completedByTaskRunId, - }, - }); - } - //#endregion //#region Heartbeat @@ -2351,31 +1628,6 @@ export class RunEngine { }); } - //#endregion - - async #getAuthenticatedEnvironmentFromRun(runId: string, tx?: PrismaClientOrTransaction) { - const prisma = tx ?? this.prisma; - const taskRun = await prisma.taskRun.findUnique({ - where: { - id: runId, - }, - include: { - runtimeEnvironment: { - include: { - organization: true, - project: true, - }, - }, - }, - }); - - if (!taskRun) { - return; - } - - return taskRun?.runtimeEnvironment; - } - #environmentMasterQueueKey(environmentId: string) { return `master-env:${environmentId}`; } diff --git a/internal-packages/run-engine/src/engine/systems/batchSystem.ts b/internal-packages/run-engine/src/engine/systems/batchSystem.ts index 65d45ce687..0c09256ebd 100644 --- a/internal-packages/run-engine/src/engine/systems/batchSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/batchSystem.ts @@ -3,29 +3,21 @@ import { Logger } from "@trigger.dev/core/logger"; import { PrismaClient } from "@trigger.dev/database"; import { isFinalRunStatus } from "../statuses.js"; import { EngineWorker } from "../types.js"; +import { SystemResources } from "./systems.js"; export type BatchSystemOptions = { - prisma: PrismaClient; - logger: Logger; - tracer: Tracer; - worker: EngineWorker; + resources: SystemResources; }; export class BatchSystem { - private readonly prisma: PrismaClient; - private readonly logger: Logger; - private readonly tracer: Tracer; - private readonly worker: EngineWorker; + private readonly $: SystemResources; constructor(private readonly options: BatchSystemOptions) { - this.prisma = options.prisma; - this.logger = options.logger; - this.tracer = options.tracer; - this.worker = options.worker; + this.$ = options.resources; } public async scheduleCompleteBatch({ batchId }: { batchId: string }): Promise { - await this.worker.enqueue({ + await this.$.worker.enqueue({ //this will debounce the call id: `tryCompleteBatch:${batchId}`, job: "tryCompleteBatch", @@ -44,8 +36,8 @@ export class BatchSystem { * This isn't used operationally, but it's used for the Batches dashboard page. */ async #tryCompleteBatch({ batchId }: { batchId: string }) { - return startSpan(this.tracer, "#tryCompleteBatch", async (span) => { - const batch = await this.prisma.batchTaskRun.findUnique({ + return startSpan(this.$.tracer, "#tryCompleteBatch", async (span) => { + const batch = await this.$.prisma.batchTaskRun.findUnique({ select: { status: true, runtimeEnvironmentId: true, @@ -56,16 +48,16 @@ export class BatchSystem { }); if (!batch) { - this.logger.error("#tryCompleteBatch batch doesn't exist", { batchId }); + this.$.logger.error("#tryCompleteBatch batch doesn't exist", { batchId }); return; } if (batch.status === "COMPLETED") { - this.logger.debug("#tryCompleteBatch: Batch already completed", { batchId }); + this.$.logger.debug("#tryCompleteBatch: Batch already completed", { batchId }); return; } - const runs = await this.prisma.taskRun.findMany({ + const runs = await this.$.prisma.taskRun.findMany({ select: { id: true, status: true, @@ -77,8 +69,8 @@ export class BatchSystem { }); if (runs.every((r) => isFinalRunStatus(r.status))) { - this.logger.debug("#tryCompleteBatch: All runs are completed", { batchId }); - await this.prisma.batchTaskRun.update({ + this.$.logger.debug("#tryCompleteBatch: All runs are completed", { batchId }); + await this.$.prisma.batchTaskRun.update({ where: { id: batchId, }, @@ -87,7 +79,7 @@ export class BatchSystem { }, }); } else { - this.logger.debug("#tryCompleteBatch: Not all runs are completed", { batchId }); + this.$.logger.debug("#tryCompleteBatch: Not all runs are completed", { batchId }); } }); } diff --git a/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts b/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts new file mode 100644 index 0000000000..1338be3029 --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts @@ -0,0 +1,247 @@ +import { CheckpointInput, CreateCheckpointResult, ExecutionResult } from "@trigger.dev/core/v3"; +import { CheckpointId } from "@trigger.dev/core/v3/isomorphic"; +import { PrismaClientOrTransaction } from "@trigger.dev/database"; +import { sendNotificationToWorker } from "../eventBus.js"; +import { ServiceValidationError } from "../index.js"; +import { isCheckpointable, isPendingExecuting } from "../statuses.js"; +import { + getLatestExecutionSnapshot, + executionResultFromSnapshot, + ExecutionSnapshotSystem, +} from "./executionSnapshotSystem.js"; +import { SystemResources } from "./systems.js"; + +export type CheckpointSystemOptions = { + resources: SystemResources; + executionSnapshotSystem: ExecutionSnapshotSystem; +}; + +export class CheckpointSystem { + private readonly $: SystemResources; + private readonly executionSnapshotSystem: ExecutionSnapshotSystem; + + constructor(private readonly options: CheckpointSystemOptions) { + this.$ = options.resources; + this.executionSnapshotSystem = options.executionSnapshotSystem; + } + + /** + * This gets called AFTER the checkpoint has been created + * The CPU/Memory checkpoint at this point exists in our snapshot storage + */ + async createCheckpoint({ + runId, + snapshotId, + checkpoint, + workerId, + runnerId, + tx, + }: { + runId: string; + snapshotId: string; + checkpoint: CheckpointInput; + workerId?: string; + runnerId?: string; + tx?: PrismaClientOrTransaction; + }): Promise { + const prisma = tx ?? this.$.prisma; + + return await this.$.runLock.lock([runId], 5_000, async () => { + const snapshot = await getLatestExecutionSnapshot(prisma, runId); + if (snapshot.id !== snapshotId) { + this.$.eventBus.emit("incomingCheckpointDiscarded", { + time: new Date(), + run: { + id: runId, + }, + checkpoint: { + discardReason: "Not the latest snapshot", + metadata: checkpoint, + }, + snapshot: { + id: snapshot.id, + executionStatus: snapshot.executionStatus, + }, + }); + + return { + ok: false as const, + error: "Not the latest snapshot", + }; + } + + if (!isCheckpointable(snapshot.executionStatus)) { + this.$.logger.error("Tried to createCheckpoint on a run in an invalid state", { + snapshot, + }); + + this.$.eventBus.emit("incomingCheckpointDiscarded", { + time: new Date(), + run: { + id: runId, + }, + checkpoint: { + discardReason: `Status ${snapshot.executionStatus} is not checkpointable`, + metadata: checkpoint, + }, + snapshot: { + id: snapshot.id, + executionStatus: snapshot.executionStatus, + }, + }); + + return { + ok: false as const, + error: `Status ${snapshot.executionStatus} is not checkpointable`, + }; + } + + // Get the run and update the status + const run = await this.$.prisma.taskRun.update({ + where: { + id: runId, + }, + data: { + status: "WAITING_TO_RESUME", + }, + select: { + id: true, + status: true, + attemptNumber: true, + runtimeEnvironment: { + select: { + id: true, + projectId: true, + organizationId: true, + }, + }, + }, + }); + + if (!run) { + this.$.logger.error("Run not found for createCheckpoint", { + snapshot, + }); + + throw new ServiceValidationError("Run not found", 404); + } + + // Create the checkpoint + const taskRunCheckpoint = await prisma.taskRunCheckpoint.create({ + data: { + ...CheckpointId.generate(), + type: checkpoint.type, + location: checkpoint.location, + imageRef: checkpoint.imageRef, + reason: checkpoint.reason, + runtimeEnvironmentId: run.runtimeEnvironment.id, + projectId: run.runtimeEnvironment.projectId, + }, + }); + + //create a new execution snapshot, with the checkpoint + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run, + snapshot: { + executionStatus: "SUSPENDED", + description: "Run was suspended after creating a checkpoint.", + }, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + checkpointId: taskRunCheckpoint.id, + workerId, + runnerId, + }); + + // Refill the token bucket for the release concurrency queue + await this.$.releaseConcurrencyQueue.refillTokens( + { + orgId: run.runtimeEnvironment.organizationId, + projectId: run.runtimeEnvironment.projectId, + envId: run.runtimeEnvironment.id, + }, + 1 + ); + + return { + ok: true as const, + ...executionResultFromSnapshot(newSnapshot), + checkpoint: taskRunCheckpoint, + } satisfies CreateCheckpointResult; + }); + } + + /** + * This is called when a run has been restored from a checkpoint and is ready to start executing again + */ + async continueRunExecution({ + runId, + snapshotId, + workerId, + runnerId, + tx, + }: { + runId: string; + snapshotId: string; + workerId?: string; + runnerId?: string; + tx?: PrismaClientOrTransaction; + }): Promise { + const prisma = tx ?? this.$.prisma; + + return await this.$.runLock.lock([runId], 5_000, async () => { + const snapshot = await getLatestExecutionSnapshot(prisma, runId); + + if (snapshot.id !== snapshotId) { + throw new ServiceValidationError("Snapshot ID doesn't match the latest snapshot", 400); + } + + if (!isPendingExecuting(snapshot.executionStatus)) { + throw new ServiceValidationError("Snapshot is not in a valid state to continue", 400); + } + + // Get the run and update the status + const run = await this.$.prisma.taskRun.update({ + where: { + id: runId, + }, + data: { + status: "EXECUTING", + }, + select: { + id: true, + status: true, + attemptNumber: true, + }, + }); + + if (!run) { + this.$.logger.error("Run not found for createCheckpoint", { + snapshot, + }); + + throw new ServiceValidationError("Run not found", 404); + } + + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run, + snapshot: { + executionStatus: "EXECUTING", + description: "Run was continued after being suspended", + }, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + completedWaitpoints: snapshot.completedWaitpoints, + workerId, + runnerId, + }); + + // Let worker know about the new snapshot so it can continue the run + await sendNotificationToWorker({ runId, snapshot: newSnapshot, eventBus: this.$.eventBus }); + + return { + ...executionResultFromSnapshot(newSnapshot), + } satisfies ExecutionResult; + }); + } +} diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index 5469297776..8df10b6f8e 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -1,45 +1,31 @@ -import { PrismaClient, PrismaClientOrTransaction } from "@trigger.dev/database"; -import { RunQueue } from "../../run-queue/index.js"; +import { startSpan } from "@internal/tracing"; +import { assertExhaustive } from "@trigger.dev/core"; import { DequeuedMessage, MachineResources, RetryOptions } from "@trigger.dev/core/v3"; -import { Logger } from "@trigger.dev/core/logger"; -import { RunLocker } from "../locking.js"; -import { isDequeueableExecutionStatus } from "../statuses.js"; +import { getMaxDuration, sanitizeQueueName } from "@trigger.dev/core/v3/isomorphic"; +import { PrismaClientOrTransaction } from "@trigger.dev/database"; import { getRunWithBackgroundWorkerTasks } from "../db/worker.js"; -import { assertExhaustive } from "@trigger.dev/core"; import { getMachinePreset } from "../machinePresets.js"; +import { isDequeueableExecutionStatus } from "../statuses.js"; import { RunEngineOptions } from "../types.js"; -import { getMaxDuration, sanitizeQueueName } from "@trigger.dev/core/v3/isomorphic"; import { ExecutionSnapshotSystem, getLatestExecutionSnapshot } from "./executionSnapshotSystem.js"; -import { Tracer, startSpan } from "@internal/tracing"; import { RunAttemptSystem } from "./runAttemptSystem.js"; +import { SystemResources } from "./systems.js"; export type DequeueSystemOptions = { - prisma: PrismaClient; - queue: RunQueue; - runLock: RunLocker; - logger: Logger; + resources: SystemResources; machines: RunEngineOptions["machines"]; - tracer: Tracer; executionSnapshotSystem: ExecutionSnapshotSystem; runAttemptSystem: RunAttemptSystem; }; export class DequeueSystem { - private readonly prisma: PrismaClient; - private readonly runQueue: RunQueue; - private readonly runLock: RunLocker; - private readonly logger: Logger; + private readonly $: SystemResources; private readonly executionSnapshotSystem: ExecutionSnapshotSystem; - private readonly tracer: Tracer; private readonly runAttemptSystem: RunAttemptSystem; constructor(private readonly options: DequeueSystemOptions) { - this.prisma = options.prisma; - this.runQueue = options.queue; - this.runLock = options.runLock; - this.logger = options.logger; + this.$ = options.resources; this.executionSnapshotSystem = options.executionSnapshotSystem; - this.tracer = options.tracer; this.runAttemptSystem = options.runAttemptSystem; } @@ -68,14 +54,14 @@ export class DequeueSystem { runnerId?: string; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; + const prisma = tx ?? this.$.prisma; return startSpan( - this.tracer, + this.$.tracer, "dequeueFromMasterQueue", async (span) => { //gets multiple runs from the queue - const messages = await this.runQueue.dequeueMessageFromMasterQueue( + const messages = await this.$.runQueue.dequeueMessageFromMasterQueue( consumerId, masterQueue, maxRunCount @@ -100,7 +86,7 @@ export class DequeueSystem { //lock the run so nothing else can modify it try { - const dequeuedRun = await this.runLock.lock([runId], 5000, async (signal) => { + const dequeuedRun = await this.$.runLock.lock([runId], 5000, async (signal) => { const snapshot = await getLatestExecutionSnapshot(prisma, runId); if (!isDequeueableExecutionStatus(snapshot.executionStatus)) { @@ -136,7 +122,7 @@ export class DequeueSystem { }, tx: prisma, }); - this.logger.error( + this.$.logger.error( `RunEngine.dequeueFromMasterQueue(): Run is not in a valid state to be dequeued: ${runId}\n ${snapshot.id}:${snapshot.executionStatus}` ); return null; @@ -152,17 +138,17 @@ export class DequeueSystem { switch (result.code) { case "NO_RUN": { //this should not happen, the run is unrecoverable so we'll ack it - this.logger.error("RunEngine.dequeueFromMasterQueue(): No run found", { + this.$.logger.error("RunEngine.dequeueFromMasterQueue(): No run found", { runId, latestSnapshot: snapshot.id, }); - await this.runQueue.acknowledgeMessage(orgId, runId); + await this.$.runQueue.acknowledgeMessage(orgId, runId); return null; } case "NO_WORKER": case "TASK_NEVER_REGISTERED": case "TASK_NOT_IN_LATEST": { - this.logger.warn(`RunEngine.dequeueFromMasterQueue(): ${result.code}`, { + this.$.logger.warn(`RunEngine.dequeueFromMasterQueue(): ${result.code}`, { runId, latestSnapshot: snapshot.id, result, @@ -178,7 +164,7 @@ export class DequeueSystem { return null; } case "BACKGROUND_WORKER_MISMATCH": { - this.logger.warn( + this.$.logger.warn( "RunEngine.dequeueFromMasterQueue(): Background worker mismatch", { runId, @@ -188,7 +174,7 @@ export class DequeueSystem { ); //worker mismatch so put it back in the queue - await this.runQueue.nackMessage({ orgId, messageId: runId }); + await this.$.runQueue.nackMessage({ orgId, messageId: runId }); return null; } @@ -201,7 +187,7 @@ export class DequeueSystem { //check for a valid deployment if it's not a development environment if (result.run.runtimeEnvironment.type !== "DEVELOPMENT") { if (!result.deployment || !result.deployment.imageReference) { - this.logger.warn("RunEngine.dequeueFromMasterQueue(): No deployment found", { + this.$.logger.warn("RunEngine.dequeueFromMasterQueue(): No deployment found", { runId, latestSnapshot: snapshot.id, result, @@ -235,7 +221,7 @@ export class DequeueSystem { consumedResources.cpu > maxResources.cpu || consumedResources.memory > maxResources.memory ) { - this.logger.debug( + this.$.logger.debug( "RunEngine.dequeueFromMasterQueue(): Consumed resources over limit, nacking", { runId, @@ -245,7 +231,7 @@ export class DequeueSystem { ); //put it back in the queue where it was - await this.runQueue.nackMessage({ + await this.$.runQueue.nackMessage({ orgId, messageId: runId, incrementAttemptCount: false, @@ -262,7 +248,7 @@ export class DequeueSystem { if (!maxAttempts) { const retryConfig = result.task.retryConfig; - this.logger.debug( + this.$.logger.debug( "RunEngine.dequeueFromMasterQueue(): maxAttempts not set, using task's retry config", { runId, @@ -274,7 +260,7 @@ export class DequeueSystem { const parsedConfig = RetryOptions.nullable().safeParse(retryConfig); if (!parsedConfig.success) { - this.logger.error("RunEngine.dequeueFromMasterQueue(): Invalid retry config", { + this.$.logger.error("RunEngine.dequeueFromMasterQueue(): Invalid retry config", { runId, task: result.task.id, rawRetryConfig: retryConfig, @@ -294,7 +280,7 @@ export class DequeueSystem { } if (!parsedConfig.data) { - this.logger.error("RunEngine.dequeueFromMasterQueue(): No retry config", { + this.$.logger.error("RunEngine.dequeueFromMasterQueue(): No retry config", { runId, task: result.task.id, rawRetryConfig: retryConfig, @@ -344,7 +330,7 @@ export class DequeueSystem { }); if (!lockedTaskRun) { - this.logger.error("RunEngine.dequeueFromMasterQueue(): Failed to lock task run", { + this.$.logger.error("RunEngine.dequeueFromMasterQueue(): Failed to lock task run", { taskRun: result.run.id, taskIdentifier: result.run.taskIdentifier, deployment: result.deployment?.id, @@ -353,7 +339,7 @@ export class DequeueSystem { runId, }); - await this.runQueue.acknowledgeMessage(orgId, runId); + await this.$.runQueue.acknowledgeMessage(orgId, runId); return null; } @@ -367,7 +353,7 @@ export class DequeueSystem { }); if (!queue) { - this.logger.debug( + this.$.logger.debug( "RunEngine.dequeueFromMasterQueue(): queue not found, so nacking message", { queueMessage: message, @@ -377,7 +363,7 @@ export class DequeueSystem { ); //will auto-retry - const gotRequeued = await this.runQueue.nackMessage({ orgId, messageId: runId }); + const gotRequeued = await this.$.runQueue.nackMessage({ orgId, messageId: runId }); if (!gotRequeued) { await this.runAttemptSystem.systemFailure({ runId, @@ -464,7 +450,7 @@ export class DequeueSystem { dequeuedRuns.push(dequeuedRun); } } catch (error) { - this.logger.error( + this.$.logger.error( "RunEngine.dequeueFromMasterQueue(): Thrown error while preparing run to be run", { error, @@ -481,14 +467,14 @@ export class DequeueSystem { if (!run) { //this isn't ideal because we're not creating a snapshot… but we can't do much else - this.logger.error( + this.$.logger.error( "RunEngine.dequeueFromMasterQueue(): Thrown error, then run not found. Nacking.", { runId, orgId, } ); - await this.runQueue.nackMessage({ orgId, messageId: runId }); + await this.$.runQueue.nackMessage({ orgId, messageId: runId }); continue; } @@ -532,13 +518,13 @@ export class DequeueSystem { reason?: string; tx?: PrismaClientOrTransaction; }) { - const prisma = tx ?? this.prisma; + const prisma = tx ?? this.$.prisma; return startSpan( - this.tracer, + this.$.tracer, "#waitingForDeploy", async (span) => { - return this.runLock.lock([runId], 5_000, async (signal) => { + return this.$.runLock.lock([runId], 5_000, async (signal) => { //mark run as waiting for deploy const run = await prisma.taskRun.update({ where: { id: runId }, @@ -570,7 +556,7 @@ export class DequeueSystem { }); //we ack because when it's deployed it will be requeued - await this.runQueue.acknowledgeMessage(orgId, runId); + await this.$.runQueue.acknowledgeMessage(orgId, runId); }); }, { diff --git a/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts b/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts new file mode 100644 index 0000000000..ce482d3d53 --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts @@ -0,0 +1,90 @@ +import { PrismaClientOrTransaction, TaskRun, TaskRunExecutionStatus } from "@trigger.dev/database"; +import { MinimalAuthenticatedEnvironment } from "../../shared/index.js"; +import { ExecutionSnapshotSystem } from "./executionSnapshotSystem.js"; +import { SystemResources } from "./systems.js"; + +export type EnqueueSystemOptions = { + resources: SystemResources; + executionSnapshotSystem: ExecutionSnapshotSystem; +}; + +export class EnqueueSystem { + private readonly $: SystemResources; + private readonly executionSnapshotSystem: ExecutionSnapshotSystem; + + constructor(private readonly options: EnqueueSystemOptions) { + this.$ = options.resources; + this.executionSnapshotSystem = options.executionSnapshotSystem; + } + + public async enqueueRun({ + run, + env, + timestamp, + tx, + snapshot, + batchId, + checkpointId, + completedWaitpoints, + workerId, + runnerId, + }: { + run: TaskRun; + env: MinimalAuthenticatedEnvironment; + timestamp: number; + tx?: PrismaClientOrTransaction; + snapshot?: { + status?: Extract; + description?: string; + }; + batchId?: string; + checkpointId?: string; + completedWaitpoints?: { + id: string; + index?: number; + }[]; + workerId?: string; + runnerId?: string; + }): Promise { + const prisma = tx ?? this.$.prisma; + + await this.$.runLock.lock([run.id], 5000, async () => { + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run: run, + snapshot: { + executionStatus: snapshot?.status ?? "QUEUED", + description: snapshot?.description ?? "Run was QUEUED", + }, + batchId, + environmentId: env.id, + environmentType: env.type, + checkpointId, + completedWaitpoints, + workerId, + runnerId, + }); + + const masterQueues = [run.masterQueue]; + if (run.secondaryMasterQueue) { + masterQueues.push(run.secondaryMasterQueue); + } + + await this.$.runQueue.enqueueMessage({ + env, + masterQueues, + message: { + runId: run.id, + taskIdentifier: run.taskIdentifier, + orgId: env.organization.id, + projectId: env.project.id, + environmentId: env.id, + environmentType: env.type, + queue: run.queue, + concurrencyKey: run.concurrencyKey ?? undefined, + timestamp, + attempt: 0, + }, + }); + }); + } +} diff --git a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts index 91485d1060..1f4cae5850 100644 --- a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts @@ -1,25 +1,18 @@ +import { CompletedWaitpoint, ExecutionResult } from "@trigger.dev/core/v3"; +import { BatchId, RunId, SnapshotId } from "@trigger.dev/core/v3/isomorphic"; import { - PrismaClient, PrismaClientOrTransaction, RuntimeEnvironmentType, - TaskRunExecutionStatus, - TaskRunStatus, TaskRunCheckpoint, TaskRunExecutionSnapshot, + TaskRunExecutionStatus, + TaskRunStatus, } from "@trigger.dev/database"; -import { EngineWorker, HeartbeatTimeouts } from "../types.js"; -import { EventBus } from "../eventBus.js"; -import { CompletedWaitpoint, ExecutionResult } from "@trigger.dev/core/v3"; -import { BatchId, RunId, SnapshotId } from "@trigger.dev/core/v3/isomorphic"; -import { Logger } from "@trigger.dev/core/logger"; -import { Tracer } from "@internal/tracing"; +import { HeartbeatTimeouts } from "../types.js"; +import { SystemResources } from "./systems.js"; export type ExecutionSnapshotSystemOptions = { - prisma: PrismaClient; - logger: Logger; - tracer: Tracer; - worker: EngineWorker; - eventBus: EventBus; + resources: SystemResources; heartbeatTimeouts: HeartbeatTimeouts; }; @@ -148,20 +141,12 @@ export function executionResultFromSnapshot(snapshot: TaskRunExecutionSnapshot): } export class ExecutionSnapshotSystem { - private readonly worker: EngineWorker; - private readonly eventBus: EventBus; + private readonly $: SystemResources; private readonly heartbeatTimeouts: HeartbeatTimeouts; - private readonly prisma: PrismaClient; - private readonly logger: Logger; - private readonly tracer: Tracer; constructor(private readonly options: ExecutionSnapshotSystemOptions) { - this.worker = options.worker; - this.eventBus = options.eventBus; + this.$ = options.resources; this.heartbeatTimeouts = options.heartbeatTimeouts; - this.prisma = options.prisma; - this.logger = options.logger; - this.tracer = options.tracer; } public async createExecutionSnapshot( @@ -229,7 +214,7 @@ export class ExecutionSnapshotSystem { //set heartbeat (if relevant) const intervalMs = this.#getHeartbeatIntervalMs(newSnapshot.executionStatus); if (intervalMs !== null) { - await this.worker.enqueue({ + await this.$.worker.enqueue({ id: `heartbeatSnapshot.${run.id}`, job: "heartbeatSnapshot", payload: { snapshotId: newSnapshot.id, runId: run.id }, @@ -238,7 +223,7 @@ export class ExecutionSnapshotSystem { } } - this.eventBus.emit("executionSnapshotCreated", { + this.$.eventBus.emit("executionSnapshotCreated", { time: newSnapshot.createdAt, run: { id: newSnapshot.runId, @@ -269,12 +254,12 @@ export class ExecutionSnapshotSystem { runnerId?: string; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; + const prisma = tx ?? this.$.prisma; //we don't need to acquire a run lock for any of this, it's not critical if it happens on an older version const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); if (latestSnapshot.id !== snapshotId) { - this.logger.log("heartbeatRun: no longer the latest snapshot, stopping the heartbeat.", { + this.$.logger.log("heartbeatRun: no longer the latest snapshot, stopping the heartbeat.", { runId, snapshotId, latestSnapshot, @@ -282,12 +267,12 @@ export class ExecutionSnapshotSystem { runnerId, }); - await this.worker.ack(`heartbeatSnapshot.${runId}`); + await this.$.worker.ack(`heartbeatSnapshot.${runId}`); return executionResultFromSnapshot(latestSnapshot); } if (latestSnapshot.workerId !== workerId) { - this.logger.debug("heartbeatRun: worker ID does not match the latest snapshot", { + this.$.logger.debug("heartbeatRun: worker ID does not match the latest snapshot", { runId, snapshotId, latestSnapshot, @@ -307,7 +292,10 @@ export class ExecutionSnapshotSystem { //extending the heartbeat const intervalMs = this.#getHeartbeatIntervalMs(latestSnapshot.executionStatus); if (intervalMs !== null) { - await this.worker.reschedule(`heartbeatSnapshot.${runId}`, new Date(Date.now() + intervalMs)); + await this.$.worker.reschedule( + `heartbeatSnapshot.${runId}`, + new Date(Date.now() + intervalMs) + ); } return executionResultFromSnapshot(latestSnapshot); diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 9ec8e924ba..3bf25b7338 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -36,15 +36,9 @@ import { WaitpointSystem } from "./waitpointSystem.js"; import { MAX_TASK_RUN_ATTEMPTS } from "../consts.js"; import { getMachinePreset } from "../machinePresets.js"; import { parsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; - +import { SystemResources } from "./systems.js"; export type RunAttemptSystemOptions = { - prisma: PrismaClient; - logger: Logger; - tracer: Tracer; - runLock: RunLocker; - eventBus: EventBus; - runQueue: RunQueue; - worker: EngineWorker; + resources: SystemResources; executionSnapshotSystem: ExecutionSnapshotSystem; batchSystem: BatchSystem; waitpointSystem: WaitpointSystem; @@ -53,25 +47,13 @@ export type RunAttemptSystemOptions = { }; export class RunAttemptSystem { - private readonly prisma: PrismaClient; - private readonly logger: Logger; - private readonly tracer: Tracer; - private readonly runLock: RunLocker; - private readonly eventBus: EventBus; - private readonly runQueue: RunQueue; - private readonly worker: EngineWorker; + private readonly $: SystemResources; private readonly executionSnapshotSystem: ExecutionSnapshotSystem; private readonly batchSystem: BatchSystem; private readonly waitpointSystem: WaitpointSystem; constructor(private readonly options: RunAttemptSystemOptions) { - this.prisma = options.prisma; - this.logger = options.logger; - this.tracer = options.tracer; - this.runLock = options.runLock; - this.eventBus = options.eventBus; - this.runQueue = options.runQueue; - this.worker = options.worker; + this.$ = options.resources; this.executionSnapshotSystem = options.executionSnapshotSystem; this.batchSystem = options.batchSystem; this.waitpointSystem = options.waitpointSystem; @@ -92,19 +74,19 @@ export class RunAttemptSystem { isWarmStart?: boolean; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; + const prisma = tx ?? this.$.prisma; return startSpan( - this.tracer, + this.$.tracer, "startRunAttempt", async (span) => { - return this.runLock.lock([runId], 5000, async () => { + return this.$.runLock.lock([runId], 5000, async () => { const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); if (latestSnapshot.id !== snapshotId) { //if there is a big delay between the snapshot and the attempt, the snapshot might have changed //we just want to log because elsewhere it should have been put back into a state where it can be attempted - this.logger.warn( + this.$.logger.warn( "RunEngine.createRunAttempt(): snapshot has changed since the attempt was created, ignoring." ); throw new ServiceValidationError("Snapshot changed", 409); @@ -142,7 +124,7 @@ export class RunAttemptSystem { }, }); - this.logger.debug("Creating a task run attempt", { taskRun }); + this.$.logger.debug("Creating a task run attempt", { taskRun }); if (!taskRun) { throw new ServiceValidationError("Task run not found", 404); @@ -195,7 +177,7 @@ export class RunAttemptSystem { throw new ServiceValidationError("Max attempts reached", 400); } - this.eventBus.emit("runAttemptStarted", { + this.$.eventBus.emit("runAttemptStarted", { time: new Date(), run: { id: taskRun.id, @@ -243,13 +225,13 @@ export class RunAttemptSystem { if (taskRun.ttl) { //don't expire the run, it's going to execute - await this.worker.ack(`expireRun:${taskRun.id}`); + await this.$.worker.ack(`expireRun:${taskRun.id}`); } return { run, snapshot: newSnapshot }; }, (error) => { - this.logger.error("RunEngine.createRunAttempt(): prisma.$transaction error", { + this.$.logger.error("RunEngine.createRunAttempt(): prisma.$transaction error", { code: error.code, meta: error.meta, stack: error.stack, @@ -264,7 +246,7 @@ export class RunAttemptSystem { ); if (!result) { - this.logger.error("RunEngine.createRunAttempt(): failed to create task run attempt", { + this.$.logger.error("RunEngine.createRunAttempt(): failed to create task run attempt", { runId: taskRun.id, nextAttemptNumber, }); @@ -377,7 +359,7 @@ export class RunAttemptSystem { runnerId?: string; }): Promise { if (completion.metadata) { - this.eventBus.emit("runMetadataUpdated", { + this.$.eventBus.emit("runMetadataUpdated", { time: new Date(), run: { id: runId, @@ -392,7 +374,7 @@ export class RunAttemptSystem { runId, snapshotId, completion, - tx: this.prisma, + tx: this.$.prisma, workerId, runnerId, }); @@ -402,7 +384,7 @@ export class RunAttemptSystem { runId, snapshotId, completion, - tx: this.prisma, + tx: this.$.prisma, workerId, runnerId, }); @@ -425,13 +407,13 @@ export class RunAttemptSystem { workerId?: string; runnerId?: string; }): Promise { - const prisma = tx ?? this.prisma; + const prisma = tx ?? this.$.prisma; return startSpan( - this.tracer, + this.$.tracer, "#completeRunAttemptSuccess", async (span) => { - return this.runLock.lock([runId], 5_000, async (signal) => { + return this.$.runLock.lock([runId], 5_000, async (signal) => { const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); if (latestSnapshot.id !== snapshotId) { @@ -487,10 +469,10 @@ export class RunAttemptSystem { }); const newSnapshot = await getLatestExecutionSnapshot(prisma, runId); - await this.runQueue.acknowledgeMessage(run.project.organizationId, runId); + await this.$.runQueue.acknowledgeMessage(run.project.organizationId, runId); // We need to manually emit this as we created the final snapshot as part of the task run update - this.eventBus.emit("executionSnapshotCreated", { + this.$.eventBus.emit("executionSnapshotCreated", { time: newSnapshot.createdAt, run: { id: newSnapshot.runId, @@ -512,7 +494,7 @@ export class RunAttemptSystem { : undefined, }); - this.eventBus.emit("runSucceeded", { + this.$.eventBus.emit("runSucceeded", { time: completedAt, run: { id: runId, @@ -557,13 +539,13 @@ export class RunAttemptSystem { forceRequeue?: boolean; tx: PrismaClientOrTransaction; }): Promise { - const prisma = this.prisma; + const prisma = this.$.prisma; return startSpan( - this.tracer, + this.$.tracer, "completeRunAttemptFailure", async (span) => { - return this.runLock.lock([runId], 5_000, async (signal) => { + return this.$.runLock.lock([runId], 5_000, async (signal) => { const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); if (latestSnapshot.id !== snapshotId) { @@ -575,7 +557,7 @@ export class RunAttemptSystem { //remove waitpoints blocking the run const deletedCount = await this.waitpointSystem.clearBlockingWaitpoints({ runId, tx }); if (deletedCount > 0) { - this.logger.debug("Cleared blocking waitpoints", { runId, deletedCount }); + this.$.logger.debug("Cleared blocking waitpoints", { runId, deletedCount }); } const failedAt = new Date(); @@ -613,7 +595,7 @@ export class RunAttemptSystem { throw new ServiceValidationError("Run not found", 404); } - this.eventBus.emit("runAttemptFailed", { + this.$.eventBus.emit("runAttemptFailed", { time: failedAt, run: { id: runId, @@ -681,7 +663,7 @@ export class RunAttemptSystem { latestSnapshot.attemptNumber === null ? 1 : latestSnapshot.attemptNumber + 1; if (retryResult.wasOOMError) { - this.eventBus.emit("runAttemptFailed", { + this.$.eventBus.emit("runAttemptFailed", { time: failedAt, run: { id: runId, @@ -696,7 +678,7 @@ export class RunAttemptSystem { }); } - this.eventBus.emit("runRetryScheduled", { + this.$.eventBus.emit("runRetryScheduled", { time: failedAt, run: { id: run.id, @@ -766,7 +748,7 @@ export class RunAttemptSystem { await sendNotificationToWorker({ runId, snapshot: newSnapshot, - eventBus: this.eventBus, + eventBus: this.$.eventBus, }); return { @@ -792,10 +774,10 @@ export class RunAttemptSystem { error: TaskRunInternalError; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; + const prisma = tx ?? this.$.prisma; return startSpan( - this.tracer, + this.$.tracer, "systemFailure", async (span) => { const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); @@ -858,11 +840,11 @@ export class RunAttemptSystem { runnerId?: string; tx?: PrismaClientOrTransaction; }): Promise<{ wasRequeued: boolean } & ExecutionResult> { - const prisma = tx ?? this.prisma; + const prisma = tx ?? this.$.prisma; - return await this.runLock.lock([run.id], 5000, async (signal) => { + return await this.$.runLock.lock([run.id], 5000, async (signal) => { //we nack the message, this allows another work to pick up the run - const gotRequeued = await this.runQueue.nackMessage({ + const gotRequeued = await this.$.runQueue.nackMessage({ orgId, messageId: run.id, retryAt: timestamp, @@ -930,11 +912,11 @@ export class RunAttemptSystem { finalizeRun?: boolean; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; + const prisma = tx ?? this.$.prisma; reason = reason ?? "Cancelled by user"; - return startSpan(this.tracer, "cancelRun", async (span) => { - return this.runLock.lock([runId], 5_000, async (signal) => { + return startSpan(this.$.tracer, "cancelRun", async (span) => { + return this.$.runLock.lock([runId], 5_000, async (signal) => { const latestSnapshot = await getLatestExecutionSnapshot(prisma, runId); //already finished, do nothing @@ -947,7 +929,7 @@ export class RunAttemptSystem { await sendNotificationToWorker({ runId, snapshot: latestSnapshot, - eventBus: this.eventBus, + eventBus: this.$.eventBus, }); return executionResultFromSnapshot(latestSnapshot); } @@ -995,7 +977,7 @@ export class RunAttemptSystem { }); //remove it from the queue and release concurrency - await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); + await this.$.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); //if executing, we need to message the worker to cancel the run and put it into `PENDING_CANCEL` status if (isExecuting(latestSnapshot.executionStatus)) { @@ -1015,7 +997,7 @@ export class RunAttemptSystem { await sendNotificationToWorker({ runId, snapshot: newSnapshot, - eventBus: this.eventBus, + eventBus: this.$.eventBus, }); return executionResultFromSnapshot(newSnapshot); } @@ -1043,7 +1025,7 @@ export class RunAttemptSystem { output: { value: JSON.stringify(error), isError: true }, }); - this.eventBus.emit("runCancelled", { + this.$.eventBus.emit("runCancelled", { time: new Date(), run: { id: run.id, @@ -1061,7 +1043,7 @@ export class RunAttemptSystem { //which will recursively cancel all children if they need to be if (run.childRuns.length > 0) { for (const childRun of run.childRuns) { - await this.worker.enqueue({ + await this.$.worker.enqueue({ id: `cancelRun:${childRun.id}`, job: "cancelRun", payload: { runId: childRun.id, completedAt: run.completedAt ?? new Date(), reason }, @@ -1089,9 +1071,9 @@ export class RunAttemptSystem { workerId?: string; runnerId?: string; }): Promise { - const prisma = this.prisma; + const prisma = this.$.prisma; - return startSpan(this.tracer, "permanentlyFailRun", async (span) => { + return startSpan(this.$.tracer, "permanentlyFailRun", async (span) => { const status = runStatusFromError(error); //run permanently failed @@ -1146,14 +1128,14 @@ export class RunAttemptSystem { throw new ServiceValidationError("No associated waitpoint found", 400); } - await this.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); + await this.$.runQueue.acknowledgeMessage(run.runtimeEnvironment.organizationId, runId); await this.waitpointSystem.completeWaitpoint({ id: run.associatedWaitpoint.id, output: { value: JSON.stringify(error), isError: true }, }); - this.eventBus.emit("runFailed", { + this.$.eventBus.emit("runFailed", { time: failedAt, run: { id: runId, @@ -1185,11 +1167,11 @@ export class RunAttemptSystem { } //cancel the heartbeats - await this.worker.ack(`heartbeatSnapshot.${id}`); + await this.$.worker.ack(`heartbeatSnapshot.${id}`); } async #getAuthenticatedEnvironmentFromRun(runId: string, tx?: PrismaClientOrTransaction) { - const prisma = tx ?? this.prisma; + const prisma = tx ?? this.$.prisma; const taskRun = await prisma.taskRun.findUnique({ where: { id: runId, diff --git a/internal-packages/run-engine/src/engine/systems/systems.ts b/internal-packages/run-engine/src/engine/systems/systems.ts new file mode 100644 index 0000000000..8b9d762173 --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/systems.ts @@ -0,0 +1,23 @@ +import { Tracer } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { PrismaClient } from "@trigger.dev/database"; +import { RunQueue } from "../../run-queue/index.js"; +import { EventBus } from "../eventBus.js"; +import { RunLocker } from "../locking.js"; +import { EngineWorker } from "../types.js"; +import { ReleaseConcurrencyTokenBucketQueue } from "../releaseConcurrencyTokenBucketQueue.js"; + +export type SystemResources = { + prisma: PrismaClient; + worker: EngineWorker; + eventBus: EventBus; + logger: Logger; + tracer: Tracer; + runLock: RunLocker; + runQueue: RunQueue; + releaseConcurrencyQueue: ReleaseConcurrencyTokenBucketQueue<{ + orgId: string; + projectId: string; + envId: string; + }>; +}; diff --git a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts index 25b48ce8a0..f61e27b97e 100644 --- a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts @@ -1,36 +1,36 @@ +import { timeoutError } from "@trigger.dev/core/v3"; +import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import { $transaction, Prisma, - PrismaClient, PrismaClientOrTransaction, + TaskRunExecutionSnapshot, + TaskRunExecutionStatus, Waitpoint, } from "@trigger.dev/database"; -import { EventBus } from "../eventBus.js"; -import { EngineWorker } from "../types.js"; -import { Logger } from "@trigger.dev/core/logger"; -import { Tracer } from "@internal/tracing"; +import { nanoid } from "nanoid"; +import { sendNotificationToWorker } from "../eventBus.js"; +import { isExecuting } from "../statuses.js"; +import { EnqueueSystem } from "./enqueueSystem.js"; +import { ExecutionSnapshotSystem, getLatestExecutionSnapshot } from "./executionSnapshotSystem.js"; +import { SystemResources } from "./systems.js"; export type WaitpointSystemOptions = { - prisma: PrismaClient; - worker: EngineWorker; - eventBus: EventBus; - logger: Logger; - tracer: Tracer; + resources: SystemResources; + executionSnapshotSystem: ExecutionSnapshotSystem; + enqueueSystem: EnqueueSystem; }; export class WaitpointSystem { - private readonly prisma: PrismaClient; - private readonly worker: EngineWorker; - private readonly eventBus: EventBus; - private readonly logger: Logger; - private readonly tracer: Tracer; + private readonly $: SystemResources; + private readonly executionSnapshotSystem: ExecutionSnapshotSystem; + + private readonly enqueueSystem: EnqueueSystem; constructor(private readonly options: WaitpointSystemOptions) { - this.prisma = options.prisma; - this.worker = options.worker; - this.eventBus = options.eventBus; - this.logger = options.logger; - this.tracer = options.tracer; + this.$ = options.resources; + this.executionSnapshotSystem = options.executionSnapshotSystem; + this.enqueueSystem = options.enqueueSystem; } public async clearBlockingWaitpoints({ @@ -40,7 +40,7 @@ export class WaitpointSystem { runId: string; tx?: PrismaClientOrTransaction; }) { - const prisma = tx ?? this.prisma; + const prisma = tx ?? this.$.prisma; const deleted = await prisma.taskRunWaitpoint.deleteMany({ where: { taskRunId: runId, @@ -64,7 +64,7 @@ export class WaitpointSystem { }; }): Promise { const result = await $transaction( - this.prisma, + this.$.prisma, async (tx) => { // 1. Find the TaskRuns blocked by this waitpoint const affectedTaskRuns = await tx.taskRunWaitpoint.findMany({ @@ -73,7 +73,7 @@ export class WaitpointSystem { }); if (affectedTaskRuns.length === 0) { - this.logger.warn(`completeWaitpoint: No TaskRunWaitpoints found for waitpoint`, { + this.$.logger.warn(`completeWaitpoint: No TaskRunWaitpoints found for waitpoint`, { waitpointId: id, }); } @@ -97,7 +97,7 @@ export class WaitpointSystem { where: { id }, }); } else { - this.logger.log("completeWaitpoint: error updating waitpoint:", { error }); + this.$.logger.log("completeWaitpoint: error updating waitpoint:", { error }); throw error; } } @@ -105,7 +105,7 @@ export class WaitpointSystem { return { waitpoint, affectedTaskRuns }; }, (error) => { - this.logger.error(`completeWaitpoint: Error completing waitpoint ${id}, retrying`, { + this.$.logger.error(`completeWaitpoint: Error completing waitpoint ${id}, retrying`, { error, }); throw error; @@ -122,7 +122,7 @@ export class WaitpointSystem { //schedule trying to continue the runs for (const run of result.affectedTaskRuns) { - await this.worker.enqueue({ + await this.$.worker.enqueue({ //this will debounce the call id: `continueRunIfUnblocked:${run.taskRunId}`, job: "continueRunIfUnblocked", @@ -133,7 +133,7 @@ export class WaitpointSystem { // emit an event to complete associated cached runs if (run.spanIdToComplete) { - this.eventBus.emit("cachedRunCompleted", { + this.$.eventBus.emit("cachedRunCompleted", { time: new Date(), span: { id: run.spanIdToComplete, @@ -147,4 +147,496 @@ export class WaitpointSystem { return result.waitpoint; } + + /** + * This creates a DATETIME waitpoint, that will be completed automatically when the specified date is reached. + * If you pass an `idempotencyKey`, the waitpoint will be created only if it doesn't already exist. + */ + async createDateTimeWaitpoint({ + projectId, + environmentId, + completedAfter, + idempotencyKey, + idempotencyKeyExpiresAt, + tx, + }: { + projectId: string; + environmentId: string; + completedAfter: Date; + idempotencyKey?: string; + idempotencyKeyExpiresAt?: Date; + tx?: PrismaClientOrTransaction; + }) { + const prisma = tx ?? this.$.prisma; + + const existingWaitpoint = idempotencyKey + ? await prisma.waitpoint.findUnique({ + where: { + environmentId_idempotencyKey: { + environmentId, + idempotencyKey, + }, + }, + }) + : undefined; + + if (existingWaitpoint) { + if ( + existingWaitpoint.idempotencyKeyExpiresAt && + new Date() > existingWaitpoint.idempotencyKeyExpiresAt + ) { + //the idempotency key has expired + //remove the waitpoint idempotencyKey + await prisma.waitpoint.update({ + where: { + id: existingWaitpoint.id, + }, + data: { + idempotencyKey: nanoid(24), + inactiveIdempotencyKey: existingWaitpoint.idempotencyKey, + }, + }); + + //let it fall through to create a new waitpoint + } else { + return { waitpoint: existingWaitpoint, isCached: true }; + } + } + + const waitpoint = await prisma.waitpoint.upsert({ + where: { + environmentId_idempotencyKey: { + environmentId, + idempotencyKey: idempotencyKey ?? nanoid(24), + }, + }, + create: { + ...WaitpointId.generate(), + type: "DATETIME", + idempotencyKey: idempotencyKey ?? nanoid(24), + idempotencyKeyExpiresAt, + userProvidedIdempotencyKey: !!idempotencyKey, + environmentId, + projectId, + completedAfter, + }, + update: {}, + }); + + await this.$.worker.enqueue({ + id: `finishWaitpoint.${waitpoint.id}`, + job: "finishWaitpoint", + payload: { waitpointId: waitpoint.id }, + availableAt: completedAfter, + }); + + return { waitpoint, isCached: false }; + } + + /** This creates a MANUAL waitpoint, that can be explicitly completed (or failed). + * If you pass an `idempotencyKey` and it already exists, it will return the existing waitpoint. + */ + async createManualWaitpoint({ + environmentId, + projectId, + idempotencyKey, + idempotencyKeyExpiresAt, + timeout, + }: { + environmentId: string; + projectId: string; + idempotencyKey?: string; + idempotencyKeyExpiresAt?: Date; + timeout?: Date; + }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { + const existingWaitpoint = idempotencyKey + ? await this.$.prisma.waitpoint.findUnique({ + where: { + environmentId_idempotencyKey: { + environmentId, + idempotencyKey, + }, + }, + }) + : undefined; + + if (existingWaitpoint) { + if ( + existingWaitpoint.idempotencyKeyExpiresAt && + new Date() > existingWaitpoint.idempotencyKeyExpiresAt + ) { + //the idempotency key has expired + //remove the waitpoint idempotencyKey + await this.$.prisma.waitpoint.update({ + where: { + id: existingWaitpoint.id, + }, + data: { + idempotencyKey: nanoid(24), + inactiveIdempotencyKey: existingWaitpoint.idempotencyKey, + }, + }); + + //let it fall through to create a new waitpoint + } else { + return { waitpoint: existingWaitpoint, isCached: true }; + } + } + + const waitpoint = await this.$.prisma.waitpoint.upsert({ + where: { + environmentId_idempotencyKey: { + environmentId, + idempotencyKey: idempotencyKey ?? nanoid(24), + }, + }, + create: { + ...WaitpointId.generate(), + type: "MANUAL", + idempotencyKey: idempotencyKey ?? nanoid(24), + idempotencyKeyExpiresAt, + userProvidedIdempotencyKey: !!idempotencyKey, + environmentId, + projectId, + completedAfter: timeout, + }, + update: {}, + }); + + //schedule the timeout + if (timeout) { + await this.$.worker.enqueue({ + id: `finishWaitpoint.${waitpoint.id}`, + job: "finishWaitpoint", + payload: { + waitpointId: waitpoint.id, + error: JSON.stringify(timeoutError(timeout)), + }, + availableAt: timeout, + }); + } + + return { waitpoint, isCached: false }; + } + + /** + * Prevents a run from continuing until the waitpoint is completed. + */ + async blockRunWithWaitpoint({ + runId, + waitpoints, + projectId, + organizationId, + releaseConcurrency, + timeout, + spanIdToComplete, + batch, + workerId, + runnerId, + tx, + }: { + runId: string; + waitpoints: string | string[]; + projectId: string; + organizationId: string; + releaseConcurrency?: boolean; + timeout?: Date; + spanIdToComplete?: string; + batch?: { id: string; index?: number }; + workerId?: string; + runnerId?: string; + tx?: PrismaClientOrTransaction; + }): Promise { + const prisma = tx ?? this.$.prisma; + + let $waitpoints = typeof waitpoints === "string" ? [waitpoints] : waitpoints; + + return await this.$.runLock.lock([runId], 5000, async () => { + let snapshot: TaskRunExecutionSnapshot = await getLatestExecutionSnapshot(prisma, runId); + + //block the run with the waitpoints, returning how many waitpoints are pending + const insert = await prisma.$queryRaw<{ pending_count: BigInt }[]>` + WITH inserted AS ( + INSERT INTO "TaskRunWaitpoint" ("id", "taskRunId", "waitpointId", "projectId", "createdAt", "updatedAt", "spanIdToComplete", "batchId", "batchIndex") + SELECT + gen_random_uuid(), + ${runId}, + w.id, + ${projectId}, + NOW(), + NOW(), + ${spanIdToComplete ?? null}, + ${batch?.id ?? null}, + ${batch?.index ?? null} + FROM "Waitpoint" w + WHERE w.id IN (${Prisma.join($waitpoints)}) + ON CONFLICT DO NOTHING + RETURNING "waitpointId" + ) + SELECT COUNT(*) as pending_count + FROM inserted i + JOIN "Waitpoint" w ON w.id = i."waitpointId" + WHERE w.status = 'PENDING';`; + + const pendingCount = Number(insert.at(0)?.pending_count ?? 0); + + let newStatus: TaskRunExecutionStatus = "SUSPENDED"; + if ( + snapshot.executionStatus === "EXECUTING" || + snapshot.executionStatus === "EXECUTING_WITH_WAITPOINTS" + ) { + newStatus = "EXECUTING_WITH_WAITPOINTS"; + } + + //if the state has changed, create a new snapshot + if (newStatus !== snapshot.executionStatus) { + snapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run: { + id: snapshot.runId, + status: snapshot.runStatus, + attemptNumber: snapshot.attemptNumber, + }, + snapshot: { + executionStatus: newStatus, + description: "Run was blocked by a waitpoint.", + }, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + batchId: batch?.id ?? snapshot.batchId ?? undefined, + workerId, + runnerId, + }); + + // Let the worker know immediately, so it can suspend the run + await sendNotificationToWorker({ runId, snapshot, eventBus: this.$.eventBus }); + } + + if (timeout) { + for (const waitpoint of $waitpoints) { + await this.$.worker.enqueue({ + id: `finishWaitpoint.${waitpoint}`, + job: "finishWaitpoint", + payload: { + waitpointId: waitpoint, + error: JSON.stringify(timeoutError(timeout)), + }, + availableAt: timeout, + }); + } + } + + //no pending waitpoint, schedule unblocking the run + //debounce if we're rapidly adding waitpoints + if (pendingCount === 0) { + await this.$.worker.enqueue({ + //this will debounce the call + id: `continueRunIfUnblocked:${runId}`, + job: "continueRunIfUnblocked", + payload: { runId: runId }, + //in the near future + availableAt: new Date(Date.now() + 50), + }); + } else { + if (releaseConcurrency) { + //release concurrency + await this.#attemptToReleaseConcurrency(organizationId, snapshot); + } + } + + return snapshot; + }); + } + + public async continueRunIfUnblocked({ runId }: { runId: string }) { + // 1. Get the any blocking waitpoints + const blockingWaitpoints = await this.$.prisma.taskRunWaitpoint.findMany({ + where: { taskRunId: runId }, + select: { + batchId: true, + batchIndex: true, + waitpoint: { + select: { id: true, status: true }, + }, + }, + }); + + // 2. There are blockers still, so do nothing + if (blockingWaitpoints.some((w) => w.waitpoint.status !== "COMPLETED")) { + return; + } + + // 3. Get the run with environment + const run = await this.$.prisma.taskRun.findFirst({ + where: { + id: runId, + }, + include: { + runtimeEnvironment: { + select: { + id: true, + type: true, + maximumConcurrencyLimit: true, + project: { select: { id: true } }, + organization: { select: { id: true } }, + }, + }, + }, + }); + + if (!run) { + throw new Error(`#continueRunIfUnblocked: run not found: ${runId}`); + } + + //4. Continue the run whether it's executing or not + await this.$.runLock.lock([runId], 5000, async () => { + const snapshot = await getLatestExecutionSnapshot(this.$.prisma, runId); + + //run is still executing, send a message to the worker + if (isExecuting(snapshot.executionStatus)) { + const result = await this.$.runQueue.reacquireConcurrency( + run.runtimeEnvironment.organization.id, + runId + ); + + if (result) { + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot( + this.$.prisma, + { + run: { + id: runId, + status: snapshot.runStatus, + attemptNumber: snapshot.attemptNumber, + }, + snapshot: { + executionStatus: "EXECUTING", + description: "Run was continued, whilst still executing.", + }, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + batchId: snapshot.batchId ?? undefined, + completedWaitpoints: blockingWaitpoints.map((b) => ({ + id: b.waitpoint.id, + index: b.batchIndex ?? undefined, + })), + } + ); + + await sendNotificationToWorker({ + runId, + snapshot: newSnapshot, + eventBus: this.$.eventBus, + }); + } else { + // Because we cannot reacquire the concurrency, we need to enqueue the run again + // and because the run is still executing, we need to set the status to QUEUED_EXECUTING + await this.enqueueSystem.enqueueRun({ + run, + env: run.runtimeEnvironment, + timestamp: run.createdAt.getTime() - run.priorityMs, + snapshot: { + status: "QUEUED_EXECUTING", + description: "Run can continue, but is waiting for concurrency", + }, + batchId: snapshot.batchId ?? undefined, + completedWaitpoints: blockingWaitpoints.map((b) => ({ + id: b.waitpoint.id, + index: b.batchIndex ?? undefined, + })), + }); + } + } else { + if (snapshot.executionStatus !== "RUN_CREATED" && !snapshot.checkpointId) { + // TODO: We're screwed, should probably fail the run immediately + throw new Error(`#continueRunIfUnblocked: run has no checkpoint: ${run.id}`); + } + + //put it back in the queue, with the original timestamp (w/ priority) + //this prioritizes dequeuing waiting runs over new runs + await this.enqueueSystem.enqueueRun({ + run, + env: run.runtimeEnvironment, + timestamp: run.createdAt.getTime() - run.priorityMs, + snapshot: { + description: "Run was QUEUED, because all waitpoints are completed", + }, + batchId: snapshot.batchId ?? undefined, + completedWaitpoints: blockingWaitpoints.map((b) => ({ + id: b.waitpoint.id, + index: b.batchIndex ?? undefined, + })), + checkpointId: snapshot.checkpointId ?? undefined, + }); + } + }); + + //5. Remove the blocking waitpoints + await this.$.prisma.taskRunWaitpoint.deleteMany({ + where: { + taskRunId: runId, + }, + }); + } + + public async createRunAssociatedWaitpoint( + tx: PrismaClientOrTransaction, + { + projectId, + environmentId, + completedByTaskRunId, + }: { projectId: string; environmentId: string; completedByTaskRunId: string } + ) { + return tx.waitpoint.create({ + data: { + ...WaitpointId.generate(), + type: "RUN", + status: "PENDING", + idempotencyKey: nanoid(24), + userProvidedIdempotencyKey: false, + projectId, + environmentId, + completedByTaskRunId, + }, + }); + } + + async #attemptToReleaseConcurrency(orgId: string, snapshot: TaskRunExecutionSnapshot) { + // Go ahead and release concurrency immediately if the run is in a development environment + if (snapshot.environmentType === "DEVELOPMENT") { + return await this.$.runQueue.releaseConcurrency(orgId, snapshot.runId); + } + + const run = await this.$.prisma.taskRun.findFirst({ + where: { + id: snapshot.runId, + }, + select: { + runtimeEnvironment: { + select: { + id: true, + projectId: true, + organizationId: true, + }, + }, + }, + }); + + if (!run) { + this.$.logger.error("Run not found for attemptToReleaseConcurrency", { + runId: snapshot.runId, + }); + + return; + } + + await this.$.releaseConcurrencyQueue.attemptToRelease( + { + orgId: run.runtimeEnvironment.organizationId, + projectId: run.runtimeEnvironment.projectId, + envId: run.runtimeEnvironment.id, + }, + snapshot.runId + ); + + return; + } } diff --git a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts index 866d41e71e..6bb2d174e3 100644 --- a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts @@ -102,7 +102,6 @@ describe("RunEngine Waitpoints", () => { const result = await engine.blockRunWithWaitpoint({ runId: run.id, waitpoints: [waitpoint.id], - environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.project.id, organizationId: authenticatedEnvironment.organization.id, releaseConcurrency: true, @@ -216,7 +215,6 @@ describe("RunEngine Waitpoints", () => { const result = await engine.blockRunWithWaitpoint({ runId: run.id, waitpoints: [waitpoint.id], - environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.project.id, organizationId: authenticatedEnvironment.organization.id, }); @@ -358,7 +356,6 @@ describe("RunEngine Waitpoints", () => { await engine.blockRunWithWaitpoint({ runId: run.id, waitpoints: result.waitpoint.id, - environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, organizationId: authenticatedEnvironment.organizationId, }); @@ -498,7 +495,6 @@ describe("RunEngine Waitpoints", () => { await engine.blockRunWithWaitpoint({ runId: run.id, waitpoints: result.waitpoint.id, - environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, organizationId: authenticatedEnvironment.organizationId, }); @@ -625,7 +621,6 @@ describe("RunEngine Waitpoints", () => { engine.blockRunWithWaitpoint({ runId: run.id, waitpoints: result.waitpoint.id, - environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, organizationId: authenticatedEnvironment.organizationId, }) @@ -768,7 +763,6 @@ describe("RunEngine Waitpoints", () => { await engine.blockRunWithWaitpoint({ runId: run.id, waitpoints: result.waitpoint.id, - environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, organizationId: authenticatedEnvironment.organizationId, }); @@ -919,7 +913,6 @@ describe("RunEngine Waitpoints", () => { await engine.blockRunWithWaitpoint({ runId: run.id, waitpoints: result.waitpoint.id, - environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, organizationId: authenticatedEnvironment.organizationId, }); @@ -1080,7 +1073,6 @@ describe("RunEngine Waitpoints", () => { await engine.blockRunWithWaitpoint({ runId: run.id, waitpoints: result.waitpoint.id, - environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, organizationId: authenticatedEnvironment.organizationId, }); From 363f0668b5cef7e317c3800212b9cebb223735ff Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 17:31:08 +0000 Subject: [PATCH 26/38] Delayed run system --- .../run-engine/src/engine/errors.ts | 24 ++++ .../run-engine/src/engine/index.ts | 131 +++--------------- .../src/engine/systems/checkpointSystem.ts | 2 +- .../src/engine/systems/delayedRunSystem.ts | 124 +++++++++++++++++ .../src/engine/systems/runAttemptSystem.ts | 44 +++--- 5 files changed, 187 insertions(+), 138 deletions(-) create mode 100644 internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts diff --git a/internal-packages/run-engine/src/engine/errors.ts b/internal-packages/run-engine/src/engine/errors.ts index 33d9be6961..81e3b598bd 100644 --- a/internal-packages/run-engine/src/engine/errors.ts +++ b/internal-packages/run-engine/src/engine/errors.ts @@ -56,3 +56,27 @@ export function runStatusFromError(error: TaskRunError): TaskRunStatus { assertExhaustive(error.code); } } + +export class ServiceValidationError extends Error { + constructor( + message: string, + public status?: number + ) { + super(message); + this.name = "ServiceValidationError"; + } +} + +export class NotImplementedError extends Error { + constructor(message: string) { + console.error("This isn't implemented", { message }); + super(message); + } +} + +export class RunDuplicateIdempotencyKeyError extends Error { + constructor(message: string) { + super(message); + this.name = "RunDuplicateIdempotencyKeyError"; + } +} diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index bb84256419..df33ab0422 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -52,6 +52,12 @@ import { SystemResources } from "./systems/systems.js"; import { WaitpointSystem } from "./systems/waitpointSystem.js"; import { EngineWorker, HeartbeatTimeouts, RunEngineOptions, TriggerParams } from "./types.js"; import { workerCatalog } from "./workerCatalog.js"; +import { + NotImplementedError, + RunDuplicateIdempotencyKeyError, + ServiceValidationError, +} from "./errors.js"; +import { DelayedRunSystem } from "./systems/delayedRunSystem.js"; export class RunEngine { private runLockRedis: Redis; @@ -75,6 +81,7 @@ export class RunEngine { batchSystem: BatchSystem; enqueueSystem: EnqueueSystem; checkpointSystem: CheckpointSystem; + delayedRunSystem: DelayedRunSystem; constructor(private readonly options: RunEngineOptions) { this.prisma = options.prisma; @@ -159,7 +166,7 @@ export class RunEngine { }); }, enqueueDelayedRun: async ({ payload }) => { - await this.#enqueueDelayedRun({ runId: payload.runId }); + await this.delayedRunSystem.enqueueDelayedRun({ runId: payload.runId }); }, }, }).start(); @@ -248,6 +255,11 @@ export class RunEngine { executionSnapshotSystem: this.executionSnapshotSystem, }); + this.delayedRunSystem = new DelayedRunSystem({ + resources, + enqueueSystem: this.enqueueSystem, + }); + this.waitpointSystem = new WaitpointSystem({ resources, executionSnapshotSystem: this.executionSnapshotSystem, @@ -789,47 +801,11 @@ export class RunEngine { delayUntil: Date; tx?: PrismaClientOrTransaction; }): Promise { - const prisma = tx ?? this.prisma; - return startSpan( - this.tracer, - "rescheduleRun", - async () => { - return await this.runLock.lock([runId], 5_000, async () => { - const snapshot = await getLatestExecutionSnapshot(prisma, runId); - - //if the run isn't just created then we can't reschedule it - if (snapshot.executionStatus !== "RUN_CREATED") { - throw new ServiceValidationError("Cannot reschedule a run that is not delayed"); - } - - const updatedRun = await prisma.taskRun.update({ - where: { - id: runId, - }, - data: { - delayUntil: delayUntil, - executionSnapshots: { - create: { - engine: "V2", - executionStatus: "RUN_CREATED", - description: "Delayed run was rescheduled to a future date", - runStatus: "EXPIRED", - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - }, - }, - }, - }); - - await this.worker.reschedule(`enqueueDelayedRun:${updatedRun.id}`, delayUntil); - - return updatedRun; - }); - }, - { - attributes: { runId }, - } - ); + return this.delayedRunSystem.rescheduleDelayedRun({ + runId, + delayUntil, + tx, + }); } async lengthOfEnvQueue(environment: MinimalAuthenticatedEnvironment): Promise { @@ -1365,53 +1341,6 @@ export class RunEngine { }); } - async #enqueueDelayedRun({ runId }: { runId: string }) { - const run = await this.prisma.taskRun.findFirst({ - where: { id: runId }, - include: { - runtimeEnvironment: { - include: { - project: true, - organization: true, - }, - }, - }, - }); - - if (!run) { - throw new Error(`#enqueueDelayedRun: run not found: ${runId}`); - } - - // Now we need to enqueue the run into the RunQueue - await this.enqueueSystem.enqueueRun({ - run, - env: run.runtimeEnvironment, - timestamp: run.createdAt.getTime() - run.priorityMs, - batchId: run.batchId ?? undefined, - }); - - await this.prisma.taskRun.update({ - where: { id: runId }, - data: { - status: "PENDING", - queuedAt: new Date(), - }, - }); - - if (run.ttl) { - const expireAt = parseNaturalLanguageDuration(run.ttl); - - if (expireAt) { - await this.worker.enqueue({ - id: `expireRun:${runId}`, - job: "expireRun", - payload: { runId }, - availableAt: expireAt, - }); - } - } - } - async #queueRunsWaitingForWorker({ backgroundWorkerId }: { backgroundWorkerId: string }) { //It could be a lot of runs, so we will process them in a batch //if there are still more to process we will enqueue this function again @@ -1636,27 +1565,3 @@ export class RunEngine { return `master-background-worker:${backgroundWorkerId}`; } } - -export class ServiceValidationError extends Error { - constructor( - message: string, - public status?: number - ) { - super(message); - this.name = "ServiceValidationError"; - } -} - -class NotImplementedError extends Error { - constructor(message: string) { - console.error("This isn't implemented", { message }); - super(message); - } -} - -export class RunDuplicateIdempotencyKeyError extends Error { - constructor(message: string) { - super(message); - this.name = "RunDuplicateIdempotencyKeyError"; - } -} diff --git a/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts b/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts index 1338be3029..1f7c6e29a0 100644 --- a/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts @@ -2,7 +2,6 @@ import { CheckpointInput, CreateCheckpointResult, ExecutionResult } from "@trigg import { CheckpointId } from "@trigger.dev/core/v3/isomorphic"; import { PrismaClientOrTransaction } from "@trigger.dev/database"; import { sendNotificationToWorker } from "../eventBus.js"; -import { ServiceValidationError } from "../index.js"; import { isCheckpointable, isPendingExecuting } from "../statuses.js"; import { getLatestExecutionSnapshot, @@ -10,6 +9,7 @@ import { ExecutionSnapshotSystem, } from "./executionSnapshotSystem.js"; import { SystemResources } from "./systems.js"; +import { ServiceValidationError } from "../errors.js"; export type CheckpointSystemOptions = { resources: SystemResources; diff --git a/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts b/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts new file mode 100644 index 0000000000..f4d682765b --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts @@ -0,0 +1,124 @@ +import { startSpan } from "@internal/tracing"; +import { SystemResources } from "./systems.js"; +import { PrismaClientOrTransaction, TaskRun } from "@trigger.dev/database"; +import { getLatestExecutionSnapshot } from "./executionSnapshotSystem.js"; +import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/isomorphic"; +import { EnqueueSystem } from "./enqueueSystem.js"; +import { ServiceValidationError } from "../errors.js"; + +export type DelayedRunSystemOptions = { + resources: SystemResources; + enqueueSystem: EnqueueSystem; +}; + +export class DelayedRunSystem { + private readonly $: SystemResources; + private readonly enqueueSystem: EnqueueSystem; + + constructor(private readonly options: DelayedRunSystemOptions) { + this.$ = options.resources; + this.enqueueSystem = options.enqueueSystem; + } + + /** + * Reschedules a delayed run where the run hasn't been queued yet + */ + async rescheduleDelayedRun({ + runId, + delayUntil, + tx, + }: { + runId: string; + delayUntil: Date; + tx?: PrismaClientOrTransaction; + }): Promise { + const prisma = tx ?? this.$.prisma; + return startSpan( + this.$.tracer, + "rescheduleDelayedRun", + async () => { + return await this.$.runLock.lock([runId], 5_000, async () => { + const snapshot = await getLatestExecutionSnapshot(prisma, runId); + + //if the run isn't just created then we can't reschedule it + if (snapshot.executionStatus !== "RUN_CREATED") { + throw new ServiceValidationError("Cannot reschedule a run that is not delayed"); + } + + const updatedRun = await prisma.taskRun.update({ + where: { + id: runId, + }, + data: { + delayUntil: delayUntil, + executionSnapshots: { + create: { + engine: "V2", + executionStatus: "RUN_CREATED", + description: "Delayed run was rescheduled to a future date", + runStatus: "EXPIRED", + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + }, + }, + }, + }); + + await this.$.worker.reschedule(`enqueueDelayedRun:${updatedRun.id}`, delayUntil); + + return updatedRun; + }); + }, + { + attributes: { runId }, + } + ); + } + + async enqueueDelayedRun({ runId }: { runId: string }) { + const run = await this.$.prisma.taskRun.findFirst({ + where: { id: runId }, + include: { + runtimeEnvironment: { + include: { + project: true, + organization: true, + }, + }, + }, + }); + + if (!run) { + throw new Error(`#enqueueDelayedRun: run not found: ${runId}`); + } + + // Now we need to enqueue the run into the RunQueue + await this.enqueueSystem.enqueueRun({ + run, + env: run.runtimeEnvironment, + timestamp: run.createdAt.getTime() - run.priorityMs, + batchId: run.batchId ?? undefined, + }); + + await this.$.prisma.taskRun.update({ + where: { id: runId }, + data: { + status: "PENDING", + queuedAt: new Date(), + }, + }); + + if (run.ttl) { + const expireAt = parseNaturalLanguageDuration(run.ttl); + + if (expireAt) { + await this.$.worker.enqueue({ + id: `expireRun:${runId}`, + job: "expireRun", + payload: { runId }, + availableAt: expireAt, + }); + } + } + } +} diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 3bf25b7338..9044da039c 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -1,17 +1,4 @@ -import { - $transaction, - PrismaClient, - PrismaClientOrTransaction, - RuntimeEnvironmentType, - TaskRun, -} from "@trigger.dev/database"; -import { Logger } from "@trigger.dev/core/logger"; -import { startSpan, Tracer } from "@internal/tracing"; -import { - executionResultFromSnapshot, - ExecutionSnapshotSystem, - getLatestExecutionSnapshot, -} from "./executionSnapshotSystem.js"; +import { startSpan } from "@internal/tracing"; import { CompleteRunAttemptResult, ExecutionResult, @@ -23,20 +10,29 @@ import { TaskRunInternalError, TaskRunSuccessfulExecutionResult, } from "@trigger.dev/core/v3/schemas"; -import { RunLocker } from "../locking.js"; -import { EventBus, sendNotificationToWorker } from "../eventBus.js"; -import { ServiceValidationError } from "../index.js"; +import { parsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; +import { + $transaction, + PrismaClientOrTransaction, + RuntimeEnvironmentType, + TaskRun, +} from "@trigger.dev/database"; +import { MAX_TASK_RUN_ATTEMPTS } from "../consts.js"; +import { runStatusFromError, ServiceValidationError } from "../errors.js"; +import { sendNotificationToWorker } from "../eventBus.js"; +import { getMachinePreset } from "../machinePresets.js"; import { retryOutcomeFromCompletion } from "../retrying.js"; -import { RunQueue } from "../../run-queue/index.js"; import { isExecuting } from "../statuses.js"; -import { EngineWorker, RunEngineOptions } from "../types.js"; -import { runStatusFromError } from "../errors.js"; +import { RunEngineOptions } from "../types.js"; import { BatchSystem } from "./batchSystem.js"; -import { WaitpointSystem } from "./waitpointSystem.js"; -import { MAX_TASK_RUN_ATTEMPTS } from "../consts.js"; -import { getMachinePreset } from "../machinePresets.js"; -import { parsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; +import { + executionResultFromSnapshot, + ExecutionSnapshotSystem, + getLatestExecutionSnapshot, +} from "./executionSnapshotSystem.js"; import { SystemResources } from "./systems.js"; +import { WaitpointSystem } from "./waitpointSystem.js"; + export type RunAttemptSystemOptions = { resources: SystemResources; executionSnapshotSystem: ExecutionSnapshotSystem; From a4581f1597986baa3a47668f3f58f1b163024da2 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 17:36:51 +0000 Subject: [PATCH 27/38] ttl system --- .../run-engine/src/engine/index.ts | 148 +++--------------- .../src/engine/systems/delayedRunSystem.ts | 9 ++ .../src/engine/systems/ttlSystem.ts | 130 +++++++++++++++ 3 files changed, 161 insertions(+), 126 deletions(-) create mode 100644 internal-packages/run-engine/src/engine/systems/ttlSystem.ts diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index df33ab0422..e7a0664964 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -11,16 +11,9 @@ import { MachineResources, RunExecutionData, StartRunAttemptResult, - TaskRunError, TaskRunExecutionResult, } from "@trigger.dev/core/v3"; -import { - BatchId, - parseNaturalLanguageDuration, - QueueId, - RunId, - WaitpointId, -} from "@trigger.dev/core/v3/isomorphic"; +import { BatchId, QueueId, RunId, WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import { Prisma, PrismaClient, @@ -35,12 +28,18 @@ import { FairQueueSelectionStrategy } from "../run-queue/fairQueueSelectionStrat import { RunQueue } from "../run-queue/index.js"; import { RunQueueFullKeyProducer } from "../run-queue/keyProducer.js"; import { MinimalAuthenticatedEnvironment } from "../shared/index.js"; -import { EventBus, EventBusEvents, sendNotificationToWorker } from "./eventBus.js"; +import { + NotImplementedError, + RunDuplicateIdempotencyKeyError, + ServiceValidationError, +} from "./errors.js"; +import { EventBus, EventBusEvents } from "./eventBus.js"; import { RunLocker } from "./locking.js"; import { ReleaseConcurrencyTokenBucketQueue } from "./releaseConcurrencyTokenBucketQueue.js"; -import { canReleaseConcurrency, isExecuting } from "./statuses.js"; +import { canReleaseConcurrency } from "./statuses.js"; import { BatchSystem } from "./systems/batchSystem.js"; import { CheckpointSystem } from "./systems/checkpointSystem.js"; +import { DelayedRunSystem } from "./systems/delayedRunSystem.js"; import { DequeueSystem } from "./systems/dequeueSystem.js"; import { EnqueueSystem } from "./systems/enqueueSystem.js"; import { @@ -49,15 +48,10 @@ import { } from "./systems/executionSnapshotSystem.js"; import { RunAttemptSystem } from "./systems/runAttemptSystem.js"; import { SystemResources } from "./systems/systems.js"; +import { TtlSystem } from "./systems/ttlSystem.js"; import { WaitpointSystem } from "./systems/waitpointSystem.js"; import { EngineWorker, HeartbeatTimeouts, RunEngineOptions, TriggerParams } from "./types.js"; import { workerCatalog } from "./workerCatalog.js"; -import { - NotImplementedError, - RunDuplicateIdempotencyKeyError, - ServiceValidationError, -} from "./errors.js"; -import { DelayedRunSystem } from "./systems/delayedRunSystem.js"; export class RunEngine { private runLockRedis: Redis; @@ -82,6 +76,7 @@ export class RunEngine { enqueueSystem: EnqueueSystem; checkpointSystem: CheckpointSystem; delayedRunSystem: DelayedRunSystem; + ttlSystem: TtlSystem; constructor(private readonly options: RunEngineOptions) { this.prisma = options.prisma; @@ -131,7 +126,7 @@ export class RunEngine { logger: new Logger("RunEngineWorker", "debug"), jobs: { finishWaitpoint: async ({ payload }) => { - await this.completeWaitpoint({ + await this.waitpointSystem.completeWaitpoint({ id: payload.waitpointId, output: payload.error ? { @@ -145,7 +140,7 @@ export class RunEngine { await this.#handleStalledSnapshot(payload); }, expireRun: async ({ payload }) => { - await this.#expireRun({ runId: payload.runId }); + await this.ttlSystem.expireRun({ runId: payload.runId }); }, cancelRun: async ({ payload }) => { await this.runAttemptSystem.cancelRun({ @@ -266,6 +261,11 @@ export class RunEngine { enqueueSystem: this.enqueueSystem, }); + this.ttlSystem = new TtlSystem({ + resources, + waitpointSystem: this.waitpointSystem, + }); + this.batchSystem = new BatchSystem({ resources, }); @@ -554,11 +554,9 @@ export class RunEngine { if (taskRun.delayUntil) { // Schedule the run to be enqueued at the delayUntil time - await this.worker.enqueue({ - id: `enqueueDelayedRun:${taskRun.id}`, - job: "enqueueDelayedRun", - payload: { runId: taskRun.id }, - availableAt: taskRun.delayUntil, + await this.delayedRunSystem.scheduleDelayedRunEnqueuing({ + runId: taskRun.id, + delayUntil: taskRun.delayUntil, }); } else { await this.enqueueSystem.enqueueRun({ @@ -571,16 +569,7 @@ export class RunEngine { }); if (taskRun.ttl) { - const expireAt = parseNaturalLanguageDuration(taskRun.ttl); - - if (expireAt) { - await this.worker.enqueue({ - id: `expireRun:${taskRun.id}`, - job: "expireRun", - payload: { runId: taskRun.id }, - availableAt: expireAt, - }); - } + await this.ttlSystem.scheduleExpireRun({ runId: taskRun.id, ttl: taskRun.ttl }); } } }); @@ -1248,99 +1237,6 @@ export class RunEngine { } } - async #expireRun({ runId, tx }: { runId: string; tx?: PrismaClientOrTransaction }) { - const prisma = tx ?? this.prisma; - await this.runLock.lock([runId], 5_000, async () => { - const snapshot = await getLatestExecutionSnapshot(prisma, runId); - - //if we're executing then we won't expire the run - if (isExecuting(snapshot.executionStatus)) { - return; - } - - //only expire "PENDING" runs - const run = await prisma.taskRun.findUnique({ where: { id: runId } }); - - if (!run) { - this.logger.debug("Could not find enqueued run to expire", { - runId, - }); - return; - } - - if (run.status !== "PENDING") { - this.logger.debug("Run cannot be expired because it's not in PENDING status", { - run, - }); - return; - } - - if (run.lockedAt) { - this.logger.debug("Run cannot be expired because it's locked, so will run", { - run, - }); - return; - } - - const error: TaskRunError = { - type: "STRING_ERROR", - raw: `Run expired because the TTL (${run.ttl}) was reached`, - }; - - const updatedRun = await prisma.taskRun.update({ - where: { id: runId }, - data: { - status: "EXPIRED", - completedAt: new Date(), - expiredAt: new Date(), - error, - executionSnapshots: { - create: { - engine: "V2", - executionStatus: "FINISHED", - description: "Run was expired because the TTL was reached", - runStatus: "EXPIRED", - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - }, - }, - }, - select: { - id: true, - spanId: true, - ttl: true, - associatedWaitpoint: { - select: { - id: true, - }, - }, - runtimeEnvironment: { - select: { - organizationId: true, - }, - }, - createdAt: true, - completedAt: true, - taskEventStore: true, - parentTaskRunId: true, - }, - }); - - await this.runQueue.acknowledgeMessage(updatedRun.runtimeEnvironment.organizationId, runId); - - if (!updatedRun.associatedWaitpoint) { - throw new ServiceValidationError("No associated waitpoint found", 400); - } - - await this.completeWaitpoint({ - id: updatedRun.associatedWaitpoint.id, - output: { value: JSON.stringify(error), isError: true }, - }); - - this.eventBus.emit("runExpired", { run: updatedRun, time: new Date() }); - }); - } - async #queueRunsWaitingForWorker({ backgroundWorkerId }: { backgroundWorkerId: string }) { //It could be a lot of runs, so we will process them in a batch //if there are still more to process we will enqueue this function again diff --git a/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts b/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts index f4d682765b..bb2aaf308f 100644 --- a/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts @@ -121,4 +121,13 @@ export class DelayedRunSystem { } } } + + async scheduleDelayedRunEnqueuing({ runId, delayUntil }: { runId: string; delayUntil: Date }) { + await this.$.worker.enqueue({ + id: `enqueueDelayedRun:${runId}`, + job: "enqueueDelayedRun", + payload: { runId }, + availableAt: delayUntil, + }); + } } diff --git a/internal-packages/run-engine/src/engine/systems/ttlSystem.ts b/internal-packages/run-engine/src/engine/systems/ttlSystem.ts new file mode 100644 index 0000000000..da6a44b825 --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/ttlSystem.ts @@ -0,0 +1,130 @@ +import { startSpan } from "@internal/tracing"; +import { SystemResources } from "./systems.js"; +import { PrismaClientOrTransaction, TaskRun } from "@trigger.dev/database"; +import { getLatestExecutionSnapshot } from "./executionSnapshotSystem.js"; +import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/isomorphic"; +import { ServiceValidationError } from "../errors.js"; +import { isExecuting } from "../statuses.js"; +import { TaskRunError } from "@trigger.dev/core/v3/schemas"; +import { WaitpointSystem } from "./waitpointSystem.js"; + +export type TtlSystemOptions = { + resources: SystemResources; + waitpointSystem: WaitpointSystem; +}; + +export class TtlSystem { + private readonly $: SystemResources; + private readonly waitpointSystem: WaitpointSystem; + + constructor(private readonly options: TtlSystemOptions) { + this.$ = options.resources; + this.waitpointSystem = options.waitpointSystem; + } + + async expireRun({ runId, tx }: { runId: string; tx?: PrismaClientOrTransaction }) { + const prisma = tx ?? this.$.prisma; + await this.$.runLock.lock([runId], 5_000, async () => { + const snapshot = await getLatestExecutionSnapshot(prisma, runId); + + //if we're executing then we won't expire the run + if (isExecuting(snapshot.executionStatus)) { + return; + } + + //only expire "PENDING" runs + const run = await prisma.taskRun.findUnique({ where: { id: runId } }); + + if (!run) { + this.$.logger.debug("Could not find enqueued run to expire", { + runId, + }); + return; + } + + if (run.status !== "PENDING") { + this.$.logger.debug("Run cannot be expired because it's not in PENDING status", { + run, + }); + return; + } + + if (run.lockedAt) { + this.$.logger.debug("Run cannot be expired because it's locked, so will run", { + run, + }); + return; + } + + const error: TaskRunError = { + type: "STRING_ERROR", + raw: `Run expired because the TTL (${run.ttl}) was reached`, + }; + + const updatedRun = await prisma.taskRun.update({ + where: { id: runId }, + data: { + status: "EXPIRED", + completedAt: new Date(), + expiredAt: new Date(), + error, + executionSnapshots: { + create: { + engine: "V2", + executionStatus: "FINISHED", + description: "Run was expired because the TTL was reached", + runStatus: "EXPIRED", + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + }, + }, + }, + select: { + id: true, + spanId: true, + ttl: true, + associatedWaitpoint: { + select: { + id: true, + }, + }, + runtimeEnvironment: { + select: { + organizationId: true, + }, + }, + createdAt: true, + completedAt: true, + taskEventStore: true, + parentTaskRunId: true, + }, + }); + + await this.$.runQueue.acknowledgeMessage(updatedRun.runtimeEnvironment.organizationId, runId); + + if (!updatedRun.associatedWaitpoint) { + throw new ServiceValidationError("No associated waitpoint found", 400); + } + + await this.waitpointSystem.completeWaitpoint({ + id: updatedRun.associatedWaitpoint.id, + output: { value: JSON.stringify(error), isError: true }, + }); + + this.$.eventBus.emit("runExpired", { run: updatedRun, time: new Date() }); + }); + } + + async scheduleExpireRun({ runId, ttl }: { runId: string; ttl: string }) { + const expireAt = parseNaturalLanguageDuration(ttl); + + if (expireAt) { + await this.$.worker.enqueue({ + id: `expireRun:${runId}`, + job: "expireRun", + payload: { runId }, + availableAt: expireAt, + }); + } + } +} From 7866e9563691729a4144a94849393f3469a082a4 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 17:41:42 +0000 Subject: [PATCH 28/38] waiting for worker system --- .../run-engine/src/engine/index.ts | 89 +++------------ .../engine/systems/waitingForWorkerSystem.ts | 102 ++++++++++++++++++ 2 files changed, 114 insertions(+), 77 deletions(-) create mode 100644 internal-packages/run-engine/src/engine/systems/waitingForWorkerSystem.ts diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index e7a0664964..c966b8769c 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -52,6 +52,7 @@ import { TtlSystem } from "./systems/ttlSystem.js"; import { WaitpointSystem } from "./systems/waitpointSystem.js"; import { EngineWorker, HeartbeatTimeouts, RunEngineOptions, TriggerParams } from "./types.js"; import { workerCatalog } from "./workerCatalog.js"; +import { WaitingForWorkerSystem } from "./systems/waitingForWorkerSystem.js"; export class RunEngine { private runLockRedis: Redis; @@ -77,6 +78,7 @@ export class RunEngine { checkpointSystem: CheckpointSystem; delayedRunSystem: DelayedRunSystem; ttlSystem: TtlSystem; + waitingForWorkerSystem: WaitingForWorkerSystem; constructor(private readonly options: RunEngineOptions) { this.prisma = options.prisma; @@ -150,7 +152,9 @@ export class RunEngine { }); }, queueRunsWaitingForWorker: async ({ payload }) => { - await this.#queueRunsWaitingForWorker({ backgroundWorkerId: payload.backgroundWorkerId }); + await this.waitingForWorkerSystem.enqueueRunsWaitingForWorker({ + backgroundWorkerId: payload.backgroundWorkerId, + }); }, tryCompleteBatch: async ({ payload }) => { await this.batchSystem.performCompleteBatch({ batchId: payload.batchId }); @@ -255,6 +259,11 @@ export class RunEngine { enqueueSystem: this.enqueueSystem, }); + this.waitingForWorkerSystem = new WaitingForWorkerSystem({ + resources, + enqueueSystem: this.enqueueSystem, + }); + this.waitpointSystem = new WaitpointSystem({ resources, executionSnapshotSystem: this.executionSnapshotSystem, @@ -771,10 +780,8 @@ export class RunEngine { }: { backgroundWorkerId: string; }): Promise { - //we want this to happen in the background - await this.worker.enqueue({ - job: "queueRunsWaitingForWorker", - payload: { backgroundWorkerId }, + return this.waitingForWorkerSystem.enqueueRunsWaitingForWorker({ + backgroundWorkerId, }); } @@ -1237,78 +1244,6 @@ export class RunEngine { } } - async #queueRunsWaitingForWorker({ backgroundWorkerId }: { backgroundWorkerId: string }) { - //It could be a lot of runs, so we will process them in a batch - //if there are still more to process we will enqueue this function again - const maxCount = this.options.queueRunsWaitingForWorkerBatchSize ?? 200; - - const backgroundWorker = await this.prisma.backgroundWorker.findFirst({ - where: { - id: backgroundWorkerId, - }, - include: { - runtimeEnvironment: { - include: { - project: true, - organization: true, - }, - }, - tasks: true, - }, - }); - - if (!backgroundWorker) { - this.logger.error("#queueRunsWaitingForWorker: background worker not found", { - id: backgroundWorkerId, - }); - return; - } - - const runsWaitingForDeploy = await this.prisma.taskRun.findMany({ - where: { - runtimeEnvironmentId: backgroundWorker.runtimeEnvironmentId, - projectId: backgroundWorker.projectId, - status: "WAITING_FOR_DEPLOY", - taskIdentifier: { - in: backgroundWorker.tasks.map((task) => task.slug), - }, - }, - orderBy: { - createdAt: "asc", - }, - take: maxCount + 1, - }); - - //none to process - if (!runsWaitingForDeploy.length) return; - - for (const run of runsWaitingForDeploy) { - await this.prisma.$transaction(async (tx) => { - const updatedRun = await tx.taskRun.update({ - where: { - id: run.id, - }, - data: { - status: "PENDING", - }, - }); - await this.enqueueSystem.enqueueRun({ - run: updatedRun, - env: backgroundWorker.runtimeEnvironment, - //add to the queue using the original run created time - //this should ensure they're in the correct order in the queue - timestamp: updatedRun.createdAt.getTime() - updatedRun.priorityMs, - tx, - }); - }); - } - - //enqueue more if needed - if (runsWaitingForDeploy.length > maxCount) { - await this.queueRunsWaitingForWorker({ backgroundWorkerId }); - } - } - //#endregion //#region Heartbeat diff --git a/internal-packages/run-engine/src/engine/systems/waitingForWorkerSystem.ts b/internal-packages/run-engine/src/engine/systems/waitingForWorkerSystem.ts new file mode 100644 index 0000000000..517e1c303d --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/waitingForWorkerSystem.ts @@ -0,0 +1,102 @@ +import { EnqueueSystem } from "./enqueueSystem.js"; +import { SystemResources } from "./systems.js"; + +export type WaitingForWorkerSystemOptions = { + resources: SystemResources; + enqueueSystem: EnqueueSystem; + queueRunsWaitingForWorkerBatchSize?: number; +}; + +export class WaitingForWorkerSystem { + private readonly $: SystemResources; + private readonly enqueueSystem: EnqueueSystem; + + constructor(private readonly options: WaitingForWorkerSystemOptions) { + this.$ = options.resources; + this.enqueueSystem = options.enqueueSystem; + } + + async enqueueRunsWaitingForWorker({ backgroundWorkerId }: { backgroundWorkerId: string }) { + //It could be a lot of runs, so we will process them in a batch + //if there are still more to process we will enqueue this function again + const maxCount = this.options.queueRunsWaitingForWorkerBatchSize ?? 200; + + const backgroundWorker = await this.$.prisma.backgroundWorker.findFirst({ + where: { + id: backgroundWorkerId, + }, + include: { + runtimeEnvironment: { + include: { + project: true, + organization: true, + }, + }, + tasks: true, + }, + }); + + if (!backgroundWorker) { + this.$.logger.error("#queueRunsWaitingForWorker: background worker not found", { + id: backgroundWorkerId, + }); + return; + } + + const runsWaitingForDeploy = await this.$.prisma.taskRun.findMany({ + where: { + runtimeEnvironmentId: backgroundWorker.runtimeEnvironmentId, + projectId: backgroundWorker.projectId, + status: "WAITING_FOR_DEPLOY", + taskIdentifier: { + in: backgroundWorker.tasks.map((task) => task.slug), + }, + }, + orderBy: { + createdAt: "asc", + }, + take: maxCount + 1, + }); + + //none to process + if (!runsWaitingForDeploy.length) return; + + for (const run of runsWaitingForDeploy) { + await this.$.prisma.$transaction(async (tx) => { + const updatedRun = await tx.taskRun.update({ + where: { + id: run.id, + }, + data: { + status: "PENDING", + }, + }); + await this.enqueueSystem.enqueueRun({ + run: updatedRun, + env: backgroundWorker.runtimeEnvironment, + //add to the queue using the original run created time + //this should ensure they're in the correct order in the queue + timestamp: updatedRun.createdAt.getTime() - updatedRun.priorityMs, + tx, + }); + }); + } + + //enqueue more if needed + if (runsWaitingForDeploy.length > maxCount) { + await this.scheduleEnqueueRunsWaitingForWorker({ backgroundWorkerId }); + } + } + + async scheduleEnqueueRunsWaitingForWorker({ + backgroundWorkerId, + }: { + backgroundWorkerId: string; + }): Promise { + //we want this to happen in the background + await this.$.worker.enqueue({ + job: "queueRunsWaitingForWorker", + payload: { backgroundWorkerId }, + }); + } +} From 67d74f066c2862ee223fbaebecddb745fa80753c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 22:23:26 +0000 Subject: [PATCH 29/38] More tests passing, fixed the heartbeat issue --- internal-packages/run-engine/src/engine/index.ts | 14 ++++++++++++++ .../run-engine/src/engine/tests/heartbeats.test.ts | 2 +- .../run-engine/src/engine/tests/locking.test.ts | 2 +- .../src/engine/tests/triggerAndWait.test.ts | 1 - .../run-engine/src/run-queue/index.ts | 3 ++- 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index c966b8769c..30c83b164d 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -1331,6 +1331,20 @@ export class RunEngine { } //it will automatically be requeued X times depending on the queue retry settings + await this.runAttemptSystem.tryNackAndRequeue({ + run, + environment: { + id: latestSnapshot.environmentId, + type: latestSnapshot.environmentType, + }, + orgId: run.runtimeEnvironment.organizationId, + error: { + type: "INTERNAL_ERROR", + code: "TASK_RUN_DEQUEUED_MAX_RETRIES", + message: `Trying to create an attempt failed multiple times, exceeding how many times we retry.`, + }, + tx: prisma, + }); break; } case "EXECUTING": diff --git a/internal-packages/run-engine/src/engine/tests/heartbeats.test.ts b/internal-packages/run-engine/src/engine/tests/heartbeats.test.ts index 518189e9ab..89df4e0726 100644 --- a/internal-packages/run-engine/src/engine/tests/heartbeats.test.ts +++ b/internal-packages/run-engine/src/engine/tests/heartbeats.test.ts @@ -95,7 +95,7 @@ describe("RunEngine heartbeats", () => { assertNonNullable(executionData); expect(executionData.snapshot.executionStatus).toBe("PENDING_EXECUTING"); - await setTimeout(pendingExecutingTimeout * 2); + await setTimeout(pendingExecutingTimeout * 4); //expect it to be pending with 3 consecutiveFailures const executionData2 = await engine.getRunExecutionData({ runId: run.id }); diff --git a/internal-packages/run-engine/src/engine/tests/locking.test.ts b/internal-packages/run-engine/src/engine/tests/locking.test.ts index 5fc8b9832b..17831c2c38 100644 --- a/internal-packages/run-engine/src/engine/tests/locking.test.ts +++ b/internal-packages/run-engine/src/engine/tests/locking.test.ts @@ -1,7 +1,7 @@ import { createRedisClient } from "@internal/redis"; import { redisTest } from "@internal/testcontainers"; import { expect } from "vitest"; -import { RunLocker } from "./locking.js"; +import { RunLocker } from "../locking.js"; describe("RunLocker", () => { redisTest("Test acquiring a lock works", { timeout: 15_000 }, async ({ redisOptions }) => { diff --git a/internal-packages/run-engine/src/engine/tests/triggerAndWait.test.ts b/internal-packages/run-engine/src/engine/tests/triggerAndWait.test.ts index 401d03edcc..7b24a6c27c 100644 --- a/internal-packages/run-engine/src/engine/tests/triggerAndWait.test.ts +++ b/internal-packages/run-engine/src/engine/tests/triggerAndWait.test.ts @@ -372,7 +372,6 @@ describe("RunEngine triggerAndWait", () => { const blockedResult = await engine.blockRunWithWaitpoint({ runId: parentRun2.id, waitpoints: childRunWithWaitpoint.associatedWaitpoint!.id, - environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.project.id, organizationId: authenticatedEnvironment.organizationId, tx: prisma, diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 7965454632..cd4a571da0 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -473,7 +473,8 @@ export class RunQueue { const message = await this.readMessage(orgId, messageId); if (!message) { - throw new MessageNotFoundError(messageId); + // Message not found, it may have already been acknowledged + return; } span.setAttributes({ From 42fb5d05ff8d7aedaa5929c1a460e0315558e5e9 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Mar 2025 22:25:57 +0000 Subject: [PATCH 30/38] Fix more tests --- .../src/run-queue/tests/fairQueueSelectionStrategy.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal-packages/run-engine/src/run-queue/tests/fairQueueSelectionStrategy.test.ts b/internal-packages/run-engine/src/run-queue/tests/fairQueueSelectionStrategy.test.ts index 1461cf43d1..3230ba73a0 100644 --- a/internal-packages/run-engine/src/run-queue/tests/fairQueueSelectionStrategy.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/fairQueueSelectionStrategy.test.ts @@ -211,8 +211,8 @@ describe("FairDequeuingStrategy", () => { console.log("Second distribution took", distribute2Duration, "ms"); - // Make sure the second call is more than 9 times faster than the first - expect(distribute2Duration).toBeLessThan(distribute1Duration / 9); + // Make sure the second call is more than 6 times faster than the first + expect(distribute2Duration).toBeLessThan(distribute1Duration / 6); const startDistribute3 = performance.now(); From 29371e950b9c92e7dc64dc542382771eb540ef29 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 18 Mar 2025 15:59:36 +0000 Subject: [PATCH 31/38] Implement checkpoint tests, handle dequeuing QUEUED_EXECUTING runs --- .../run-engine/src/engine/index.ts | 2 + .../run-engine/src/engine/statuses.ts | 1 + .../src/engine/systems/dequeueSystem.ts | 33 + .../src/engine/tests/checkpoints.test.ts | 750 +++++++++++++++++- .../src/engine/tests/waitpoints.test.ts | 209 +++++ 5 files changed, 992 insertions(+), 3 deletions(-) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 30c83b164d..5bf2c6b99b 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -1021,6 +1021,7 @@ export class RunEngine { batch, workerId, runnerId, + tx, }: { runId: string; waitpoints: string | string[]; @@ -1045,6 +1046,7 @@ export class RunEngine { batch, workerId, runnerId, + tx, }); } diff --git a/internal-packages/run-engine/src/engine/statuses.ts b/internal-packages/run-engine/src/engine/statuses.ts index c21bcb105c..5eb923fa3d 100644 --- a/internal-packages/run-engine/src/engine/statuses.ts +++ b/internal-packages/run-engine/src/engine/statuses.ts @@ -26,6 +26,7 @@ export function isCheckpointable(status: TaskRunExecutionStatus): boolean { //executing "EXECUTING", "EXECUTING_WITH_WAITPOINTS", + "QUEUED_EXECUTING", ]; return checkpointableStatuses.includes(status); } diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index 8df10b6f8e..bb25ab1e6e 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -10,6 +10,7 @@ import { RunEngineOptions } from "../types.js"; import { ExecutionSnapshotSystem, getLatestExecutionSnapshot } from "./executionSnapshotSystem.js"; import { RunAttemptSystem } from "./runAttemptSystem.js"; import { SystemResources } from "./systems.js"; +import { sendNotificationToWorker } from "../eventBus.js"; export type DequeueSystemOptions = { resources: SystemResources; @@ -128,6 +129,38 @@ export class DequeueSystem { return null; } + if (snapshot.executionStatus === "QUEUED_EXECUTING") { + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot( + prisma, + { + run: { + id: runId, + status: snapshot.runStatus, + attemptNumber: snapshot.attemptNumber, + }, + snapshot: { + executionStatus: "EXECUTING", + description: "Run was continued, whilst still executing.", + }, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + batchId: snapshot.batchId ?? undefined, + completedWaitpoints: snapshot.completedWaitpoints.map((waitpoint) => ({ + id: waitpoint.id, + index: waitpoint.index, + })), + } + ); + + await sendNotificationToWorker({ + runId, + snapshot: newSnapshot, + eventBus: this.$.eventBus, + }); + + return null; + } + const result = await getRunWithBackgroundWorkerTasks( prisma, runId, diff --git a/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts b/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts index a5a1a8b3b4..005c96876b 100644 --- a/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts @@ -8,12 +8,756 @@ import { import { trace } from "@internal/tracing"; import { expect } from "vitest"; import { RunEngine } from "../index.js"; -import { setTimeout } from "timers/promises"; +import { setTimeout } from "node:timers/promises"; import { EventBusEventArgs } from "../eventBus.js"; vi.setConfig({ testTimeout: 60_000 }); describe("RunEngine checkpoints", () => { - //todo checkpoint tests - test("empty test", async () => {}); + containerTest("Create checkpoint and continue execution", 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.0005, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + // Create background worker + const backgroundWorker = await setupBackgroundWorker( + prisma, + 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", + queueName: "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 + const attemptResult = await engine.startRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: dequeued[0].snapshot.id, + }); + + //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); + + const checkpointRun = checkpointResult.ok ? checkpointResult.run : null; + assertNonNullable(checkpointRun); + + // Verify checkpoint creation + expect(snapshot.executionStatus).toBe("SUSPENDED"); + expect(checkpointRun.status).toBe("WAITING_TO_RESUME"); + + // Get execution data to verify state + const executionData = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData); + expect(executionData.snapshot.executionStatus).toBe("SUSPENDED"); + expect(executionData.checkpoint).toBeDefined(); + expect(executionData.checkpoint?.type).toBe("DOCKER"); + expect(executionData.checkpoint?.reason).toBe("TEST_CHECKPOINT"); + + //complete the waitpoint + await engine.completeWaitpoint({ + id: waitpointResult.waitpoint.id, + }); + + await setTimeout(500); + + // Dequeue the run again + const dequeuedAgain = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: run.masterQueue, + maxRunCount: 10, + }); + + expect(dequeuedAgain.length).toBe(1); + + // Continue execution from checkpoint + const continueResult = await engine.continueRunExecution({ + runId: run.id, + snapshotId: dequeuedAgain[0].snapshot.id, + }); + + // Verify continuation + expect(continueResult.snapshot.executionStatus).toBe("EXECUTING"); + expect(continueResult.run.status).toBe("EXECUTING"); + + // Complete the run + const result = await engine.completeRunAttempt({ + runId: run.id, + snapshotId: continueResult.snapshot.id, + completion: { + ok: true, + id: run.id, + output: `{"foo":"bar"}`, + outputType: "application/json", + }, + }); + + // Verify final state + expect(result.snapshot.executionStatus).toBe("FINISHED"); + expect(result.run.status).toBe("COMPLETED_SUCCESSFULLY"); + } finally { + await engine.quit(); + } + }); + + containerTest("Failed checkpoint creation", async ({ prisma, redisOptions }) => { + 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.0005, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + // Create background worker + const backgroundWorker = await setupBackgroundWorker( + prisma, + 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", + queueName: "task/test-task", + isTest: false, + tags: [], + }, + prisma + ); + + // Try to create checkpoint with invalid snapshot ID + const result = await engine.createCheckpoint({ + runId: run.id, + snapshotId: "invalid-snapshot-id", + checkpoint: { + type: "DOCKER", + reason: "TEST_CHECKPOINT", + location: "test-location", + imageRef: "test-image-ref", + }, + }); + + const error = !result.ok ? result.error : null; + + expect(error).toBe("Not the latest snapshot"); + + // Verify run is still in initial state + const executionData = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData); + expect(executionData.checkpoint).toBeUndefined(); + } finally { + await engine.quit(); + } + }); + + containerTest("Multiple checkpoints in single run", async ({ prisma, redisOptions }) => { + 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.0005, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + const backgroundWorker = await setupBackgroundWorker( + prisma, + authenticatedEnvironment, + taskIdentifier + ); + + // Trigger 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", + queueName: "task/test-task", + isTest: false, + tags: [], + }, + prisma + ); + + // First checkpoint sequence + const dequeued1 = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: run.masterQueue, + maxRunCount: 10, + }); + + const attemptResult1 = await engine.startRunAttempt({ + runId: dequeued1[0].run.id, + snapshotId: dequeued1[0].snapshot.id, + }); + + // Create waitpoint and block run + const waitpoint1 = await engine.createManualWaitpoint({ + environmentId: authenticatedEnvironment.id, + projectId: authenticatedEnvironment.projectId, + }); + + const blocked1 = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: waitpoint1.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + }); + + // Create first checkpoint + const checkpoint1 = await engine.createCheckpoint({ + runId: run.id, + snapshotId: blocked1.id, + checkpoint: { + type: "DOCKER", + reason: "CHECKPOINT_1", + location: "location-1", + imageRef: "image-1", + }, + }); + + expect(checkpoint1.ok).toBe(true); + const snapshot1 = checkpoint1.ok ? checkpoint1.snapshot : null; + assertNonNullable(snapshot1); + + // Complete first waitpoint + await engine.completeWaitpoint({ + id: waitpoint1.waitpoint.id, + }); + + await setTimeout(500); + + // Dequeue again after waitpoint completion + const dequeued2 = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: run.masterQueue, + maxRunCount: 10, + }); + + // Continue execution from first checkpoint + const continueResult1 = await engine.continueRunExecution({ + runId: run.id, + snapshotId: dequeued2[0].snapshot.id, + }); + + // Second checkpoint sequence + // Create another waitpoint and block run + const waitpoint2 = await engine.createManualWaitpoint({ + environmentId: authenticatedEnvironment.id, + projectId: authenticatedEnvironment.projectId, + }); + + const blocked2 = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: waitpoint2.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + }); + + // Create second checkpoint + const checkpoint2 = await engine.createCheckpoint({ + runId: run.id, + snapshotId: blocked2.id, + checkpoint: { + type: "DOCKER", + reason: "CHECKPOINT_2", + location: "location-2", + imageRef: "image-2", + }, + }); + + expect(checkpoint2.ok).toBe(true); + const snapshot2 = checkpoint2.ok ? checkpoint2.snapshot : null; + assertNonNullable(snapshot2); + + // Complete second waitpoint + await engine.completeWaitpoint({ + id: waitpoint2.waitpoint.id, + }); + + await setTimeout(500); + + // Dequeue again after second waitpoint completion + const dequeued3 = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: run.masterQueue, + maxRunCount: 10, + }); + + expect(dequeued3.length).toBe(1); + + // Verify latest checkpoint + expect(dequeued3[0].checkpoint?.reason).toBe("CHECKPOINT_2"); + expect(dequeued3[0].checkpoint?.location).toBe("location-2"); + + // Continue execution from second checkpoint + const continueResult2 = await engine.continueRunExecution({ + runId: run.id, + snapshotId: dequeued3[0].snapshot.id, + }); + + // Complete the run + const result = await engine.completeRunAttempt({ + runId: run.id, + snapshotId: continueResult2.snapshot.id, + completion: { + ok: true, + id: run.id, + output: `{"foo":"bar"}`, + outputType: "application/json", + }, + }); + + expect(result.snapshot.executionStatus).toBe("FINISHED"); + expect(result.run.status).toBe("COMPLETED_SUCCESSFULLY"); + } finally { + await engine.quit(); + } + }); + + containerTest( + "Checkpoint after waitpoint completion with concurrency reacquisition", + async ({ prisma, redisOptions }) => { + 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.0005, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + // Create background worker + const backgroundWorker = await setupBackgroundWorker( + prisma, + 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", + queueName: "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 + const attemptResult = await engine.startRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: dequeued[0].snapshot.id, + }); + + // Create and block with waitpoint + const waitpointResult = await engine.createManualWaitpoint({ + environmentId: authenticatedEnvironment.id, + projectId: authenticatedEnvironment.projectId, + }); + expect(waitpointResult.waitpoint.status).toBe("PENDING"); + + const blockedResult = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: waitpointResult.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + releaseConcurrency: true, // Important: Release concurrency when blocking + }); + + // Verify run is blocked + const blockedExecutionData = await engine.getRunExecutionData({ runId: run.id }); + expect(blockedExecutionData?.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Complete the waitpoint before checkpoint + await engine.completeWaitpoint({ + id: waitpointResult.waitpoint.id, + }); + + await setTimeout(500); // Wait for continueRunIfUnblocked to process + + // Create checkpoint after waitpoint completion + 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(false); + const error = !checkpointResult.ok ? checkpointResult.error : null; + expect(error).toBe("Not the latest snapshot"); + + // Verify checkpoint state + const executionData = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData); + expect(executionData.snapshot.executionStatus).toBe("EXECUTING"); + + // Complete the run + const result = await engine.completeRunAttempt({ + runId: run.id, + snapshotId: executionData.snapshot.id, + completion: { + ok: true, + id: run.id, + output: `{"foo":"bar"}`, + outputType: "application/json", + }, + }); + + // Verify final state + expect(result.snapshot.executionStatus).toBe("FINISHED"); + expect(result.run.status).toBe("COMPLETED_SUCCESSFULLY"); + } finally { + await engine.quit(); + } + } + ); + + containerTest( + "Cannot create checkpoint in non-checkpointable state", + async ({ prisma, redisOptions }) => { + 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.0005, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + // Create background worker + const backgroundWorker = await setupBackgroundWorker( + prisma, + 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", + queueName: "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 + const attemptResult = await engine.startRunAttempt({ + runId: dequeued[0].run.id, + snapshotId: dequeued[0].snapshot.id, + }); + + // First create a valid checkpoint to get into SUSPENDED state + const checkpoint1 = await engine.createCheckpoint({ + runId: run.id, + snapshotId: attemptResult.snapshot.id, + checkpoint: { + type: "DOCKER", + reason: "FIRST_CHECKPOINT", + location: "test-location-1", + imageRef: "test-image-ref-1", + }, + }); + + expect(checkpoint1.ok).toBe(true); + const snapshot1 = checkpoint1.ok ? checkpoint1.snapshot : null; + assertNonNullable(snapshot1); + + // Verify we're in SUSPENDED state + const executionData = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(executionData); + expect(executionData.snapshot.executionStatus).toBe("SUSPENDED"); + + let event: EventBusEventArgs<"incomingCheckpointDiscarded">[0] | undefined = undefined; + engine.eventBus.on("incomingCheckpointDiscarded", (result) => { + event = result; + }); + + // Try to create another checkpoint while in SUSPENDED state + const checkpoint2 = await engine.createCheckpoint({ + runId: run.id, + snapshotId: snapshot1.id, + checkpoint: { + type: "DOCKER", + reason: "SECOND_CHECKPOINT", + location: "test-location-2", + imageRef: "test-image-ref-2", + }, + }); + + assertNonNullable(event); + + const notificationEvent = event as EventBusEventArgs<"incomingCheckpointDiscarded">[0]; + expect(notificationEvent.run.id).toBe(run.id); + + expect(notificationEvent.run.id).toBe(run.id); + expect(notificationEvent.checkpoint.discardReason).toBe( + "Status SUSPENDED is not checkpointable" + ); + + // Verify the checkpoint creation was rejected + expect(checkpoint2.ok).toBe(false); + const error = !checkpoint2.ok ? checkpoint2.error : null; + expect(error).toBe("Status SUSPENDED is not checkpointable"); + + // Verify the run state hasn't changed + const finalExecutionData = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(finalExecutionData); + expect(finalExecutionData.snapshot.executionStatus).toBe("SUSPENDED"); + expect(finalExecutionData.checkpoint?.reason).toBe("FIRST_CHECKPOINT"); + } finally { + await engine.quit(); + } + } + ); }); diff --git a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts index 6bb2d174e3..3c06cd600b 100644 --- a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts @@ -1133,4 +1133,213 @@ describe("RunEngine Waitpoints", () => { engine.quit(); } }); + + containerTest( + "continueRunIfUnblocked enqueues run when cannot reacquire concurrency", + async ({ prisma, redisOptions }) => { + 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, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + const queueName = "task/test-task-limited"; + + // Create background worker + await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier); + + // Create first run with queue concurrency limit of 1 + const firstRun = await engine.trigger( + { + number: 1, + friendlyId: "run_first", + environment: authenticatedEnvironment, + taskIdentifier, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345-first", + spanId: "s12345-first", + masterQueue: "main", + queueName, + isTest: false, + tags: [], + queue: { concurrencyLimit: 1 }, + }, + prisma + ); + + // Dequeue and start the first run + const dequeuedFirst = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: firstRun.masterQueue, + maxRunCount: 10, + }); + + const firstAttempt = await engine.startRunAttempt({ + runId: dequeuedFirst[0].run.id, + snapshotId: dequeuedFirst[0].snapshot.id, + }); + expect(firstAttempt.snapshot.executionStatus).toBe("EXECUTING"); + + // Create a manual waitpoint for the first run + const waitpoint = await engine.createManualWaitpoint({ + environmentId: authenticatedEnvironment.id, + projectId: authenticatedEnvironment.projectId, + }); + expect(waitpoint.waitpoint.status).toBe("PENDING"); + + // Block the first run with releaseConcurrency set to true + const blockedResult = await engine.blockRunWithWaitpoint({ + runId: firstRun.id, + waitpoints: waitpoint.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + releaseConcurrency: true, + }); + + // Verify first run is blocked + const firstRunData = await engine.getRunExecutionData({ runId: firstRun.id }); + expect(firstRunData?.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Create and start second run on the same queue + const secondRun = await engine.trigger( + { + number: 2, + friendlyId: "run_second", + environment: authenticatedEnvironment, + taskIdentifier, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345-second", + spanId: "s12345-second", + masterQueue: "main", + queueName, + isTest: false, + tags: [], + queue: { concurrencyLimit: 1 }, + }, + prisma + ); + + // Dequeue and start the second run + const dequeuedSecond = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: secondRun.masterQueue, + maxRunCount: 10, + }); + + const secondAttempt = await engine.startRunAttempt({ + runId: dequeuedSecond[0].run.id, + snapshotId: dequeuedSecond[0].snapshot.id, + }); + expect(secondAttempt.snapshot.executionStatus).toBe("EXECUTING"); + + // Now complete the waitpoint for the first run + await engine.completeWaitpoint({ + id: waitpoint.waitpoint.id, + }); + + // Wait for the continueRunIfUnblocked to process + await setTimeout(500); + + // Verify the first run is now in QUEUED_EXECUTING state + const executionDataAfter = await engine.getRunExecutionData({ runId: firstRun.id }); + expect(executionDataAfter?.snapshot.executionStatus).toBe("QUEUED_EXECUTING"); + expect(executionDataAfter?.snapshot.description).toBe( + "Run can continue, but is waiting for concurrency" + ); + + // Verify the waitpoint is no longer blocking the first run + const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ + where: { + taskRunId: firstRun.id, + }, + include: { + waitpoint: true, + }, + }); + expect(runWaitpoint).toBeNull(); + + // Verify the waitpoint itself is completed + const completedWaitpoint = await prisma.waitpoint.findUnique({ + where: { + id: waitpoint.waitpoint.id, + }, + }); + assertNonNullable(completedWaitpoint); + expect(completedWaitpoint.status).toBe("COMPLETED"); + + // Complete the second run so the first run can be dequeued + const result = await engine.completeRunAttempt({ + runId: dequeuedSecond[0].run.id, + snapshotId: secondAttempt.snapshot.id, + completion: { + ok: true, + id: dequeuedSecond[0].run.id, + output: `{"foo":"bar"}`, + outputType: "application/json", + }, + }); + + await setTimeout(500); + + let event: EventBusEventArgs<"workerNotification">[0] | undefined = undefined; + engine.eventBus.on("workerNotification", (result) => { + event = result; + }); + + // Verify the first run is back in the queue + const queuedRun = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: firstRun.masterQueue, + maxRunCount: 10, + }); + + expect(queuedRun.length).toBe(0); + + // Get the latest execution snapshot and make sure it's EXECUTING + const executionData = await engine.getRunExecutionData({ runId: firstRun.id }); + assertNonNullable(executionData); + expect(executionData.snapshot.executionStatus).toBe("EXECUTING"); + + assertNonNullable(event); + const notificationEvent = event as EventBusEventArgs<"workerNotification">[0]; + expect(notificationEvent.run.id).toBe(firstRun.id); + expect(notificationEvent.snapshot.executionStatus).toBe("EXECUTING"); + } finally { + await engine.quit(); + } + } + ); }); From e7c8f94447e9e62269b0e685ef45c79f49ad6245 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 18 Mar 2025 16:58:15 +0000 Subject: [PATCH 32/38] implement the QUEUED_EXECUTING dequeuing, and creating a checkpoint while the run is in QUEUED_EXECUTING state by saving the EXECUTING_WITH_WAITPOINTS snapshotId as the previousSnapshotId on the QUEUED_EXECUTING snapshot --- .../migration.sql | 8 + .../database/prisma/schema.prisma | 3 + .../run-engine/src/engine/index.ts | 5 +- .../src/engine/systems/checkpointSystem.ts | 112 ++++++--- .../src/engine/systems/dequeueSystem.ts | 3 + .../src/engine/systems/enqueueSystem.ts | 9 +- .../engine/systems/executionSnapshotSystem.ts | 3 + .../src/engine/systems/runAttemptSystem.ts | 5 + .../src/engine/systems/waitpointSystem.ts | 3 + .../src/engine/tests/checkpoints.test.ts | 221 ++++++++++++++++++ 10 files changed, 332 insertions(+), 40 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250318163201_add_previous_snapshot_id_to_task_run_execution_snapshot/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250318163201_add_previous_snapshot_id_to_task_run_execution_snapshot/migration.sql b/internal-packages/database/prisma/migrations/20250318163201_add_previous_snapshot_id_to_task_run_execution_snapshot/migration.sql new file mode 100644 index 0000000000..1f979fd2e0 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250318163201_add_previous_snapshot_id_to_task_run_execution_snapshot/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "SecretStore_key_idx"; + +-- AlterTable +ALTER TABLE "TaskRunExecutionSnapshot" ADD COLUMN "previousSnapshotId" TEXT; + +-- CreateIndex +CREATE INDEX "SecretStore_key_idx" ON "SecretStore"("key" text_pattern_ops); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 94f5dcaede..d4b9cb0116 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1965,6 +1965,9 @@ model TaskRunExecutionSnapshot { isValid Boolean @default(true) error String? + /// The previous snapshot ID + previousSnapshotId String? + /// Run runId String run TaskRun @relation(fields: [runId], references: [id]) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 5bf2c6b99b..97be60793d 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -244,14 +244,15 @@ export class RunEngine { heartbeatTimeouts: this.heartbeatTimeouts, }); - this.checkpointSystem = new CheckpointSystem({ + this.enqueueSystem = new EnqueueSystem({ resources, executionSnapshotSystem: this.executionSnapshotSystem, }); - this.enqueueSystem = new EnqueueSystem({ + this.checkpointSystem = new CheckpointSystem({ resources, executionSnapshotSystem: this.executionSnapshotSystem, + enqueueSystem: this.enqueueSystem, }); this.delayedRunSystem = new DelayedRunSystem({ diff --git a/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts b/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts index 1f7c6e29a0..812506fbf1 100644 --- a/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts @@ -10,19 +10,23 @@ import { } from "./executionSnapshotSystem.js"; import { SystemResources } from "./systems.js"; import { ServiceValidationError } from "../errors.js"; +import { EnqueueSystem } from "./enqueueSystem.js"; export type CheckpointSystemOptions = { resources: SystemResources; executionSnapshotSystem: ExecutionSnapshotSystem; + enqueueSystem: EnqueueSystem; }; export class CheckpointSystem { private readonly $: SystemResources; private readonly executionSnapshotSystem: ExecutionSnapshotSystem; + private readonly enqueueSystem: EnqueueSystem; constructor(private readonly options: CheckpointSystemOptions) { this.$ = options.resources; this.executionSnapshotSystem = options.executionSnapshotSystem; + this.enqueueSystem = options.enqueueSystem; } /** @@ -48,7 +52,8 @@ export class CheckpointSystem { return await this.$.runLock.lock([runId], 5_000, async () => { const snapshot = await getLatestExecutionSnapshot(prisma, runId); - if (snapshot.id !== snapshotId) { + + if (snapshot.id !== snapshotId && snapshot.previousSnapshotId !== snapshotId) { this.$.eventBus.emit("incomingCheckpointDiscarded", { time: new Date(), run: { @@ -104,15 +109,11 @@ export class CheckpointSystem { data: { status: "WAITING_TO_RESUME", }, - select: { - id: true, - status: true, - attemptNumber: true, + include: { runtimeEnvironment: { - select: { - id: true, - projectId: true, - organizationId: true, + include: { + project: true, + organization: true, }, }, }, @@ -139,35 +140,73 @@ export class CheckpointSystem { }, }); - //create a new execution snapshot, with the checkpoint - const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { - run, - snapshot: { - executionStatus: "SUSPENDED", - description: "Run was suspended after creating a checkpoint.", - }, - environmentId: snapshot.environmentId, - environmentType: snapshot.environmentType, - checkpointId: taskRunCheckpoint.id, - workerId, - runnerId, - }); + if (snapshot.executionStatus === "QUEUED_EXECUTING") { + // Enqueue the run again + const newSnapshot = await this.enqueueSystem.enqueueRun({ + run, + env: run.runtimeEnvironment, + timestamp: run.createdAt.getTime() - run.priorityMs, + snapshot: { + status: "QUEUED", + description: + "Run was QUEUED, because it was queued and executing and a checkpoint was created", + }, + previousSnapshotId: snapshot.id, + batchId: snapshot.batchId ?? undefined, + completedWaitpoints: snapshot.completedWaitpoints.map((waitpoint) => ({ + id: waitpoint.id, + index: waitpoint.index, + })), + checkpointId: taskRunCheckpoint.id, + }); - // Refill the token bucket for the release concurrency queue - await this.$.releaseConcurrencyQueue.refillTokens( - { - orgId: run.runtimeEnvironment.organizationId, - projectId: run.runtimeEnvironment.projectId, - envId: run.runtimeEnvironment.id, - }, - 1 - ); + // Refill the token bucket for the release concurrency queue + await this.$.releaseConcurrencyQueue.refillTokens( + { + orgId: run.runtimeEnvironment.organizationId, + projectId: run.runtimeEnvironment.projectId, + envId: run.runtimeEnvironment.id, + }, + 1 + ); - return { - ok: true as const, - ...executionResultFromSnapshot(newSnapshot), - checkpoint: taskRunCheckpoint, - } satisfies CreateCheckpointResult; + return { + ok: true as const, + ...executionResultFromSnapshot(newSnapshot), + checkpoint: taskRunCheckpoint, + } satisfies CreateCheckpointResult; + } else { + //create a new execution snapshot, with the checkpoint + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run, + snapshot: { + executionStatus: "SUSPENDED", + description: "Run was suspended after creating a checkpoint.", + }, + previousSnapshotId: snapshot.id, + environmentId: snapshot.environmentId, + environmentType: snapshot.environmentType, + checkpointId: taskRunCheckpoint.id, + workerId, + runnerId, + }); + + // Refill the token bucket for the release concurrency queue + await this.$.releaseConcurrencyQueue.refillTokens( + { + orgId: run.runtimeEnvironment.organizationId, + projectId: run.runtimeEnvironment.projectId, + envId: run.runtimeEnvironment.id, + }, + 1 + ); + + return { + ok: true as const, + ...executionResultFromSnapshot(newSnapshot), + checkpoint: taskRunCheckpoint, + } satisfies CreateCheckpointResult; + } }); } @@ -229,6 +268,7 @@ export class CheckpointSystem { executionStatus: "EXECUTING", description: "Run was continued after being suspended", }, + previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, completedWaitpoints: snapshot.completedWaitpoints, diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index bb25ab1e6e..3a976d897d 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -102,6 +102,7 @@ export class DequeueSystem { description: "Tried to dequeue a run that is not in a valid state to be dequeued.", }, + previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, checkpointId: snapshot.checkpointId ?? undefined, @@ -142,6 +143,7 @@ export class DequeueSystem { executionStatus: "EXECUTING", description: "Run was continued, whilst still executing.", }, + previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, batchId: snapshot.batchId ?? undefined, @@ -427,6 +429,7 @@ export class DequeueSystem { executionStatus: "PENDING_EXECUTING", description: "Run was dequeued for execution", }, + previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, checkpointId: snapshot.checkpointId ?? undefined, diff --git a/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts b/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts index ce482d3d53..842bbeed8b 100644 --- a/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts @@ -23,6 +23,7 @@ export class EnqueueSystem { timestamp, tx, snapshot, + previousSnapshotId, batchId, checkpointId, completedWaitpoints, @@ -37,6 +38,7 @@ export class EnqueueSystem { status?: Extract; description?: string; }; + previousSnapshotId?: string; batchId?: string; checkpointId?: string; completedWaitpoints?: { @@ -45,16 +47,17 @@ export class EnqueueSystem { }[]; workerId?: string; runnerId?: string; - }): Promise { + }) { const prisma = tx ?? this.$.prisma; - await this.$.runLock.lock([run.id], 5000, async () => { + return await this.$.runLock.lock([run.id], 5000, async () => { const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { run: run, snapshot: { executionStatus: snapshot?.status ?? "QUEUED", description: snapshot?.description ?? "Run was QUEUED", }, + previousSnapshotId, batchId, environmentId: env.id, environmentType: env.type, @@ -85,6 +88,8 @@ export class EnqueueSystem { attempt: 0, }, }); + + return newSnapshot; }); } } diff --git a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts index 1f4cae5850..2abc640518 100644 --- a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts @@ -154,6 +154,7 @@ export class ExecutionSnapshotSystem { { run, snapshot, + previousSnapshotId, batchId, environmentId, environmentType, @@ -168,6 +169,7 @@ export class ExecutionSnapshotSystem { executionStatus: TaskRunExecutionStatus; description: string; }; + previousSnapshotId?: string; batchId?: string; environmentId: string; environmentType: RuntimeEnvironmentType; @@ -186,6 +188,7 @@ export class ExecutionSnapshotSystem { engine: "V2", executionStatus: snapshot.executionStatus, description: snapshot.description, + previousSnapshotId, runId: run.id, runStatus: run.status, attemptNumber: run.attemptNumber ?? undefined, diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 9044da039c..d6e6fd6ecc 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -213,6 +213,7 @@ export class RunAttemptSystem { isWarmStart ? " (warm start)" : "" }`, }, + previousSnapshotId: latestSnapshot.id, environmentId: latestSnapshot.environmentId, environmentType: latestSnapshot.environmentType, workerId, @@ -733,6 +734,7 @@ export class RunAttemptSystem { executionStatus: "PENDING_EXECUTING", description: "Attempt failed with a short delay, starting a new attempt", }, + previousSnapshotId: latestSnapshot.id, environmentId: latestSnapshot.environmentId, environmentType: latestSnapshot.environmentType, workerId, @@ -983,6 +985,7 @@ export class RunAttemptSystem { executionStatus: "PENDING_CANCEL", description: "Run was cancelled", }, + previousSnapshotId: latestSnapshot.id, environmentId: latestSnapshot.environmentId, environmentType: latestSnapshot.environmentType, workerId, @@ -1005,6 +1008,7 @@ export class RunAttemptSystem { executionStatus: "FINISHED", description: "Run was cancelled, not finished", }, + previousSnapshotId: latestSnapshot.id, environmentId: latestSnapshot.environmentId, environmentType: latestSnapshot.environmentType, workerId, @@ -1114,6 +1118,7 @@ export class RunAttemptSystem { executionStatus: "FINISHED", description: "Run failed", }, + previousSnapshotId: snapshotId, environmentId: run.runtimeEnvironment.id, environmentType: run.runtimeEnvironment.type, workerId, diff --git a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts index f61e27b97e..3d1a59dca3 100644 --- a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts @@ -400,6 +400,7 @@ export class WaitpointSystem { executionStatus: newStatus, description: "Run was blocked by a waitpoint.", }, + previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, batchId: batch?.id ?? snapshot.batchId ?? undefined, @@ -511,6 +512,7 @@ export class WaitpointSystem { executionStatus: "EXECUTING", description: "Run was continued, whilst still executing.", }, + previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, batchId: snapshot.batchId ?? undefined, @@ -537,6 +539,7 @@ export class WaitpointSystem { status: "QUEUED_EXECUTING", description: "Run can continue, but is waiting for concurrency", }, + previousSnapshotId: snapshot.id, batchId: snapshot.batchId ?? undefined, completedWaitpoints: blockingWaitpoints.map((b) => ({ id: b.waitpoint.id, diff --git a/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts b/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts index 005c96876b..50e6dea856 100644 --- a/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/checkpoints.test.ts @@ -760,4 +760,225 @@ describe("RunEngine checkpoints", () => { } } ); + + containerTest( + "when a checkpoint is created while the run is in QUEUED_EXECUTING state, the run is QUEUED", + async ({ prisma, redisOptions }) => { + 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, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + const queueName = "task/test-task-limited"; + + // Create background worker + await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier); + + // Create first run with queue concurrency limit of 1 + const firstRun = await engine.trigger( + { + number: 1, + friendlyId: "run_first", + environment: authenticatedEnvironment, + taskIdentifier, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345-first", + spanId: "s12345-first", + masterQueue: "main", + queueName, + isTest: false, + tags: [], + queue: { concurrencyLimit: 1 }, + }, + prisma + ); + + // Dequeue and start the first run + const dequeuedFirst = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: firstRun.masterQueue, + maxRunCount: 10, + }); + + const firstAttempt = await engine.startRunAttempt({ + runId: dequeuedFirst[0].run.id, + snapshotId: dequeuedFirst[0].snapshot.id, + }); + expect(firstAttempt.snapshot.executionStatus).toBe("EXECUTING"); + + // Create a manual waitpoint for the first run + const waitpoint = await engine.createManualWaitpoint({ + environmentId: authenticatedEnvironment.id, + projectId: authenticatedEnvironment.projectId, + }); + expect(waitpoint.waitpoint.status).toBe("PENDING"); + + // Block the first run with releaseConcurrency set to true + const blockedResult = await engine.blockRunWithWaitpoint({ + runId: firstRun.id, + waitpoints: waitpoint.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + releaseConcurrency: true, + }); + + // Verify first run is blocked + const firstRunData = await engine.getRunExecutionData({ runId: firstRun.id }); + expect(firstRunData?.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Create and start second run on the same queue + const secondRun = await engine.trigger( + { + number: 2, + friendlyId: "run_second", + environment: authenticatedEnvironment, + taskIdentifier, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345-second", + spanId: "s12345-second", + masterQueue: "main", + queueName, + isTest: false, + tags: [], + queue: { concurrencyLimit: 1 }, + }, + prisma + ); + + // Dequeue and start the second run + const dequeuedSecond = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: secondRun.masterQueue, + maxRunCount: 10, + }); + + const secondAttempt = await engine.startRunAttempt({ + runId: dequeuedSecond[0].run.id, + snapshotId: dequeuedSecond[0].snapshot.id, + }); + expect(secondAttempt.snapshot.executionStatus).toBe("EXECUTING"); + + // Now complete the waitpoint for the first run + await engine.completeWaitpoint({ + id: waitpoint.waitpoint.id, + }); + + // Wait for the continueRunIfUnblocked to process + await setTimeout(500); + + // Verify the first run is now in QUEUED_EXECUTING state + const executionDataAfter = await engine.getRunExecutionData({ runId: firstRun.id }); + expect(executionDataAfter?.snapshot.executionStatus).toBe("QUEUED_EXECUTING"); + expect(executionDataAfter?.snapshot.description).toBe( + "Run can continue, but is waiting for concurrency" + ); + + // Verify the waitpoint is no longer blocking the first run + const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ + where: { + taskRunId: firstRun.id, + }, + include: { + waitpoint: true, + }, + }); + expect(runWaitpoint).toBeNull(); + + // Verify the waitpoint itself is completed + const completedWaitpoint = await prisma.waitpoint.findUnique({ + where: { + id: waitpoint.waitpoint.id, + }, + }); + assertNonNullable(completedWaitpoint); + expect(completedWaitpoint.status).toBe("COMPLETED"); + + // Create checkpoint after waitpoint completion + const checkpointResult = await engine.createCheckpoint({ + runId: firstRun.id, + snapshotId: firstRunData?.snapshot.id!, + checkpoint: { + type: "DOCKER", + reason: "TEST_CHECKPOINT", + location: "test-location", + imageRef: "test-image-ref", + }, + }); + + expect(checkpointResult.ok).toBe(true); + const checkpoint = checkpointResult.ok ? checkpointResult.snapshot : null; + assertNonNullable(checkpoint); + expect(checkpoint.executionStatus).toBe("QUEUED"); + + // Complete the second run so the first run can be dequeued + const result = await engine.completeRunAttempt({ + runId: dequeuedSecond[0].run.id, + snapshotId: secondAttempt.snapshot.id, + completion: { + ok: true, + id: dequeuedSecond[0].run.id, + output: `{"foo":"bar"}`, + outputType: "application/json", + }, + }); + + await setTimeout(500); + + // Verify the first run is back in the queue + const queuedRun = await engine.dequeueFromMasterQueue({ + consumerId: "test_12345", + masterQueue: firstRun.masterQueue, + maxRunCount: 10, + }); + + expect(queuedRun.length).toBe(1); + expect(queuedRun[0].run.id).toBe(firstRun.id); + expect(queuedRun[0].snapshot.executionStatus).toBe("PENDING_EXECUTING"); + + // Now we can continue the run + const continueResult = await engine.continueRunExecution({ + runId: firstRun.id, + snapshotId: queuedRun[0].snapshot.id, + }); + + expect(continueResult.snapshot.executionStatus).toBe("EXECUTING"); + } finally { + await engine.quit(); + } + } + ); }); From b170a62671c7c13698abd5b0d3c298ef0274cfab Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 18 Mar 2025 17:26:13 +0000 Subject: [PATCH 33/38] fixed the create checkpoint valid snapshot logic --- .../src/engine/systems/checkpointSystem.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts b/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts index 812506fbf1..ae38de4348 100644 --- a/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts @@ -53,7 +53,20 @@ export class CheckpointSystem { return await this.$.runLock.lock([runId], 5_000, async () => { const snapshot = await getLatestExecutionSnapshot(prisma, runId); - if (snapshot.id !== snapshotId && snapshot.previousSnapshotId !== snapshotId) { + const isValidSnapshot = + // Case 1: The provided snapshotId matches the current snapshot + snapshot.id === snapshotId || + // Case 2: The provided snapshotId matches the previous snapshot + // AND we're in QUEUED_EXECUTING state (which is valid) + (snapshot.previousSnapshotId === snapshotId && + snapshot.executionStatus === "QUEUED_EXECUTING"); + + if (!isValidSnapshot) { + this.$.logger.error("Tried to createCheckpoint on an invalid snapshot", { + snapshot, + snapshotId, + }); + this.$.eventBus.emit("incomingCheckpointDiscarded", { time: new Date(), run: { From f9c7e95981f0cf47373e7e194ec1baa9a28a77c7 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 18 Mar 2025 17:28:26 +0000 Subject: [PATCH 34/38] Added updated execution states chart and updated readme --- internal-packages/run-engine/README.md | 24 +++++++++++++----- .../run-engine/execution-states.png | Bin 521887 -> 279363 bytes 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/internal-packages/run-engine/README.md b/internal-packages/run-engine/README.md index c49ea7a5d9..499242da21 100644 --- a/internal-packages/run-engine/README.md +++ b/internal-packages/run-engine/README.md @@ -212,6 +212,10 @@ graph TD WS[WaitpointSystem] BS[BatchSystem] ES[EnqueueSystem] + CS[CheckpointSystem] + DRS[DelayedRunSystem] + TS[TtlSystem] + WFS[WaitingForWorkerSystem] %% Core Dependencies RE --> DS @@ -220,6 +224,10 @@ graph TD RE --> WS RE --> BS RE --> ES + RE --> CS + RE --> DRS + RE --> TS + RE --> WFS %% System Dependencies DS --> ESS @@ -234,6 +242,15 @@ graph TD ES --> ESS + CS --> ESS + CS --> ES + + DRS --> ES + + WFS --> ES + + TS --> WS + %% Shared Resources subgraph Resources PRI[(Prisma)] @@ -248,12 +265,7 @@ graph TD %% Resource Dependencies RE -.-> Resources - DS -.-> PRI & LOG & TRC & RQ & RL & EB & WRK - RAS -.-> PRI & LOG & TRC & RL & EB & RQ & WRK - ESS -.-> PRI & LOG & TRC & WRK & EB - WS -.-> PRI & LOG & TRC & WRK & EB & RCQ - BS -.-> PRI & LOG & TRC & WRK - ES -.-> PRI & LOG & TRC & WRK & EB & RQ + DS & RAS & ESS & WS & BS & ES & CS & DRS & TS & WFS -.-> Resources ``` ## System Responsibilities diff --git a/internal-packages/run-engine/execution-states.png b/internal-packages/run-engine/execution-states.png index cc156dd7de74c51c61275d3bbfac8b43820eb2b2..7bc7e0d0f9badeae6425198249bf2bff11f9ad74 100644 GIT binary patch literal 279363 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 xnkF literal 521887 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�kWxu?IHpXpQ?p+L>ay%h<; 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

L{&r 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 uTSgOhXexS-& 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 From 7eaf81abbe23aad13cd730322a13580eb74e0678 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 18 Mar 2025 21:53:24 +0000 Subject: [PATCH 35/38] Use releaserId in case we don't end up using run IDs --- .../releaseConcurrencyTokenBucketQueue.ts | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts b/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts index 74b767667c..212188fb5c 100644 --- a/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts +++ b/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts @@ -15,7 +15,7 @@ export type ReleaseConcurrencyQueueRetryOptions = { export type ReleaseConcurrencyQueueOptions = { redis: RedisOptions; - executor: (releaseQueue: T, runId: string) => Promise; + executor: (releaseQueue: T, releaserId: string) => Promise; keys: { fromDescriptor: (releaseQueue: T) => string; toDescriptor: (releaseQueue: string) => T; @@ -89,7 +89,7 @@ export class ReleaseConcurrencyTokenBucketQueue { * If there is no token available, then we'll add the operation to a queue * and wait until the token is available. */ - public async attemptToRelease(releaseQueueDescriptor: T, runId: string) { + public async attemptToRelease(releaseQueueDescriptor: T, releaserId: string) { const maxTokens = await this.#callMaxTokens(releaseQueueDescriptor); if (maxTokens === 0) { @@ -104,13 +104,13 @@ export class ReleaseConcurrencyTokenBucketQueue { this.#queueKey(releaseQueue), this.#metadataKey(releaseQueue), releaseQueue, - runId, + releaserId, String(maxTokens), String(Date.now()) ); if (!!result) { - await this.#callExecutor(releaseQueueDescriptor, runId, { + await this.#callExecutor(releaseQueueDescriptor, releaserId, { retryCount: 0, lastAttempt: Date.now(), }); @@ -161,28 +161,28 @@ export class ReleaseConcurrencyTokenBucketQueue { } await Promise.all( - result.map(([queue, runId, metadata]) => { + result.map(([queue, releaserId, metadata]) => { const itemMetadata = QueueItemMetadata.parse(JSON.parse(metadata)); const releaseQueueDescriptor = this.keys.toDescriptor(queue); - return this.#callExecutor(releaseQueueDescriptor, runId, itemMetadata); + return this.#callExecutor(releaseQueueDescriptor, releaserId, itemMetadata); }) ); return true; } - async #callExecutor(releaseQueueDescriptor: T, runId: string, metadata: QueueItemMetadata) { + async #callExecutor(releaseQueueDescriptor: T, releaserId: string, metadata: QueueItemMetadata) { try { - this.logger.info("Executing run:", { releaseQueueDescriptor, runId }); + this.logger.info("Executing run:", { releaseQueueDescriptor, releaserId }); - await this.options.executor(releaseQueueDescriptor, runId); + await this.options.executor(releaseQueueDescriptor, releaserId); } catch (error) { this.logger.error("Error executing run:", { error }); if (metadata.retryCount >= this.maxRetries) { this.logger.error("Max retries reached:", { releaseQueueDescriptor, - runId, + releaserId, retryCount: metadata.retryCount, }); @@ -194,10 +194,10 @@ export class ReleaseConcurrencyTokenBucketQueue { this.#queueKey(releaseQueue), this.#metadataKey(releaseQueue), releaseQueue, - runId + releaserId ); - this.logger.info("Returned token:", { releaseQueueDescriptor, runId }); + this.logger.info("Returned token:", { releaseQueueDescriptor, releaserId }); return; } @@ -216,7 +216,7 @@ export class ReleaseConcurrencyTokenBucketQueue { this.#queueKey(releaseQueue), this.#metadataKey(releaseQueue), releaseQueue, - runId, + releaserId, JSON.stringify(updatedMetadata), this.#calculateBackoffScore(updatedMetadata) ); @@ -282,7 +282,7 @@ local queueKey = KEYS[3] local metadataKey = KEYS[4] local releaseQueue = ARGV[1] -local runId = ARGV[2] +local releaserId = ARGV[2] local maxTokens = tonumber(ARGV[3]) local score = ARGV[4] @@ -292,10 +292,10 @@ 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) - redis.call("ZREM", queueKey, runId) + redis.call("ZREM", queueKey, releaserId) -- Clean up metadata when successfully consuming - redis.call("HDEL", metadataKey, runId) + redis.call("HDEL", metadataKey, releaserId) -- Get queue length after removing the item local queueLength = redis.call("ZCARD", queueKey) @@ -311,14 +311,14 @@ if currentTokens >= 1 then end -- If we don't have enough tokens, then we need to add the operation to the queue -redis.call("ZADD", queueKey, score, runId) +redis.call("ZADD", queueKey, score, releaserId) -- Initialize or update metadata local metadata = cjson.encode({ retryCount = 0, lastAttempt = tonumber(score) }) -redis.call("HSET", metadataKey, runId, metadata) +redis.call("HSET", metadataKey, releaserId, metadata) -- Remove from the master queue redis.call("ZREM", masterQueuesKey, releaseQueue) @@ -400,14 +400,14 @@ redis.call("SET", bucketKey, currentTokens - itemsToProcess) -- Remove the items from the queue and add to results for i = 1, itemsToProcess do - local runId = items[i] - redis.call("ZREM", queueKey, runId) + local releaserId = items[i] + redis.call("ZREM", queueKey, releaserId) -- Get metadata before removing it - local metadata = redis.call("HGET", metadataKey, runId) - redis.call("HDEL", metadataKey, runId) + local metadata = redis.call("HGET", metadataKey, releaserId) + redis.call("HDEL", metadataKey, releaserId) - table.insert(results, { queueName, runId, metadata }) + table.insert(results, { queueName, releaserId, metadata }) end -- Get remaining queue length @@ -434,7 +434,7 @@ local queueKey = KEYS[3] local metadataKey = KEYS[4] local releaseQueue = ARGV[1] -local runId = ARGV[2] +local releaserId = ARGV[2] local metadata = ARGV[3] local score = ARGV[4] @@ -444,10 +444,10 @@ local remainingTokens = currentTokens + 1 redis.call("SET", bucketKey, remainingTokens) -- Add the item back to the queue -redis.call("ZADD", queueKey, score, runId) +redis.call("ZADD", queueKey, score, releaserId) -- Add the metadata back to the item -redis.call("HSET", metadataKey, runId, metadata) +redis.call("HSET", metadataKey, releaserId, metadata) -- Update the master queue local queueLength = redis.call("ZCARD", queueKey) @@ -470,7 +470,7 @@ local queueKey = KEYS[3] local metadataKey = KEYS[4] local releaseQueue = ARGV[1] -local runId = ARGV[2] +local releaserId = ARGV[2] -- Return the token to the bucket local currentTokens = tonumber(redis.call("GET", bucketKey)) @@ -478,7 +478,7 @@ local remainingTokens = currentTokens + 1 redis.call("SET", bucketKey, remainingTokens) -- Clean up metadata -redis.call("HDEL", metadataKey, runId) +redis.call("HDEL", metadataKey, releaserId) -- Update the master queue based on remaining queue length local queueLength = redis.call("ZCARD", queueKey) @@ -502,7 +502,7 @@ declare module "@internal/redis" { queueKey: string, metadataKey: string, releaseQueue: string, - runId: string, + releaserId: string, maxTokens: string, score: string, callback?: Callback @@ -532,7 +532,7 @@ declare module "@internal/redis" { queueKey: string, metadataKey: string, releaseQueue: string, - runId: string, + releaserId: string, metadata: string, score: string, callback?: Callback @@ -544,7 +544,7 @@ declare module "@internal/redis" { queueKey: string, metadataKey: string, releaseQueue: string, - runId: string, + releaserId: string, callback?: Callback ): Result; } From 28b3ed0496c137d1809d070e6b1ffaef35295506 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 19 Mar 2025 16:29:09 +0000 Subject: [PATCH 36/38] Implement release concurrency system --- ...ne.v1.runs.$runFriendlyId.wait.duration.ts | 3 +- ...points.tokens.$waitpointFriendlyId.wait.ts | 3 +- .../app/v3/services/triggerTaskV2.server.ts | 3 +- .../migration.sql | 5 + .../migration.sql | 26 + .../migration.sql | 5 + .../migration.sql | 5 + .../database/prisma/schema.prisma | 18 +- .../run-engine/src/engine/index.ts | 59 +- .../releaseConcurrencyTokenBucketQueue.ts | 52 +- .../run-engine/src/engine/retrying.ts | 2 +- .../src/engine/systems/batchSystem.ts | 5 +- .../src/engine/systems/checkpointSystem.ts | 29 +- .../src/engine/systems/delayedRunSystem.ts | 2 + .../src/engine/systems/dequeueSystem.ts | 89 +- .../src/engine/systems/enqueueSystem.ts | 11 +- .../engine/systems/executionSnapshotSystem.ts | 9 + .../systems/releaseConcurrencySystem.ts | 161 +++ .../src/engine/systems/runAttemptSystem.ts | 23 + .../src/engine/systems/ttlSystem.ts | 2 + .../src/engine/systems/waitpointSystem.ts | 67 +- .../engine/tests/releaseConcurrency.test.ts | 1094 +++++++++++++++++ .../engine/tests/releasingConcurrency.test.ts | 10 - .../src/engine/tests/waitpoints.test.ts | 2 +- .../run-engine/src/engine/types.ts | 1 + internal-packages/run-engine/src/index.ts | 3 +- .../run-engine/src/run-queue/index.test.ts | 10 +- .../run-engine/src/run-queue/index.ts | 80 +- .../tests/reacquireConcurrency.test.ts | 2 +- .../tests/releaseConcurrency.test.ts | 4 +- internal-packages/testcontainers/src/setup.ts | 15 +- packages/core/src/v3/schemas/api.ts | 4 + packages/core/src/v3/types/tasks.ts | 12 +- packages/trigger-sdk/src/v3/shared.ts | 1 + packages/trigger-sdk/src/v3/wait.ts | 15 + references/hello-world/package.json | 3 + references/hello-world/src/trigger/example.ts | 7 +- references/hello-world/src/trigger/waits.ts | 7 +- 38 files changed, 1659 insertions(+), 190 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250319103257_add_release_concurrency_on_waitpoint_to_task_queue/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20250319110754_add_org_and_project_to_execution_snapshots/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20250319114436_add_metadata_to_task_run_execution_snapshots/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20250319131807_add_locked_queue_id_to_task_run/migration.sql create mode 100644 internal-packages/run-engine/src/engine/systems/releaseConcurrencySystem.ts create mode 100644 internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts delete mode 100644 internal-packages/run-engine/src/engine/tests/releasingConcurrency.test.ts diff --git a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts index 5ffdd138b7..24aa181404 100644 --- a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts +++ b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts @@ -48,10 +48,9 @@ const { action } = createActionApiRoute( const waitResult = await engine.blockRunWithWaitpoint({ runId: run.id, waitpoints: waitpoint.id, - environmentId: authentication.environment.id, projectId: authentication.environment.project.id, organizationId: authentication.environment.organization.id, - releaseConcurrency: true, + releaseConcurrency: body.releaseConcurrency, }); return json({ diff --git a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts index c0e19c1f48..e9bd27d693 100644 --- a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts +++ b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts @@ -34,13 +34,12 @@ const { action } = createActionApiRoute( throw json({ error: "Waitpoint not found" }, { status: 404 }); } + // TODO: Add releaseConcurrency from the body const result = await engine.blockRunWithWaitpoint({ runId, waitpoints: [waitpointId], - environmentId: authentication.environment.id, projectId: authentication.environment.project.id, organizationId: authentication.environment.organization.id, - releaseConcurrency: true, }); return json( diff --git a/apps/webapp/app/v3/services/triggerTaskV2.server.ts b/apps/webapp/app/v3/services/triggerTaskV2.server.ts index a1d431b650..4a3c5efe57 100644 --- a/apps/webapp/app/v3/services/triggerTaskV2.server.ts +++ b/apps/webapp/app/v3/services/triggerTaskV2.server.ts @@ -167,10 +167,10 @@ export class TriggerTaskServiceV2 extends WithRunEngine { index: options.batchIndex ?? 0, } : undefined, - environmentId: environment.id, projectId: environment.projectId, organizationId: environment.organizationId, tx: this._prisma, + releaseConcurrency: body.options?.releaseConcurrency, }); } ); @@ -373,6 +373,7 @@ export class TriggerTaskServiceV2 extends WithRunEngine { : undefined, machine: body.options?.machine, priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined, + releaseConcurrency: body.options?.releaseConcurrency, }, this._prisma ); diff --git a/internal-packages/database/prisma/migrations/20250319103257_add_release_concurrency_on_waitpoint_to_task_queue/migration.sql b/internal-packages/database/prisma/migrations/20250319103257_add_release_concurrency_on_waitpoint_to_task_queue/migration.sql new file mode 100644 index 0000000000..66cea8acdd --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250319103257_add_release_concurrency_on_waitpoint_to_task_queue/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE + "TaskQueue" +ADD + COLUMN "releaseConcurrencyOnWaitpoint" BOOLEAN NOT NULL DEFAULT false; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250319110754_add_org_and_project_to_execution_snapshots/migration.sql b/internal-packages/database/prisma/migrations/20250319110754_add_org_and_project_to_execution_snapshots/migration.sql new file mode 100644 index 0000000000..afdb979e87 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250319110754_add_org_and_project_to_execution_snapshots/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - Added the required column `organizationId` to the `TaskRunExecutionSnapshot` table without a default value. This is not possible if the table is not empty. + - Added the required column `projectId` to the `TaskRunExecutionSnapshot` table without a default value. This is not possible if the table is not empty. + + */ +-- AlterTable +ALTER TABLE + "TaskRunExecutionSnapshot" +ADD + COLUMN "organizationId" TEXT NOT NULL, +ADD + COLUMN "projectId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE + "TaskRunExecutionSnapshot" +ADD + CONSTRAINT "TaskRunExecutionSnapshot_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE + "TaskRunExecutionSnapshot" +ADD + CONSTRAINT "TaskRunExecutionSnapshot_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250319114436_add_metadata_to_task_run_execution_snapshots/migration.sql b/internal-packages/database/prisma/migrations/20250319114436_add_metadata_to_task_run_execution_snapshots/migration.sql new file mode 100644 index 0000000000..d4121ed929 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250319114436_add_metadata_to_task_run_execution_snapshots/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE + "TaskRunExecutionSnapshot" +ADD + COLUMN "metadata" JSONB; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250319131807_add_locked_queue_id_to_task_run/migration.sql b/internal-packages/database/prisma/migrations/20250319131807_add_locked_queue_id_to_task_run/migration.sql new file mode 100644 index 0000000000..b1bf829b7e --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250319131807_add_locked_queue_id_to_task_run/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE + "TaskRun" +ADD + COLUMN "lockedQueueId" TEXT; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index d4b9cb0116..8b92c6448b 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -164,6 +164,7 @@ model Organization { organizationIntegrations OrganizationIntegration[] workerGroups WorkerInstanceGroup[] workerInstances WorkerInstance[] + executionSnapshots TaskRunExecutionSnapshot[] } model ExternalAccount { @@ -504,6 +505,7 @@ model Project { waitpoints Waitpoint[] taskRunWaitpoints TaskRunWaitpoint[] taskRunCheckpoints TaskRunCheckpoint[] + executionSnapshots TaskRunExecutionSnapshot[] } enum ProjectVersion { @@ -1724,7 +1726,9 @@ model TaskRun { projectId String // The specific queue this run is in - queue String + queue String + // The queueId is set when the run is locked to a specific queue + lockedQueueId String? /// The main queue that this run is part of masterQueue String @default("main") @@ -1985,6 +1989,12 @@ model TaskRunExecutionSnapshot { environment RuntimeEnvironment @relation(fields: [environmentId], references: [id]) environmentType RuntimeEnvironmentType + projectId String + project Project @relation(fields: [projectId], references: [id]) + + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + /// Waitpoints that have been completed for this execution completedWaitpoints Waitpoint[] @relation("completedWaitpoints") @@ -2006,6 +2016,9 @@ model TaskRunExecutionSnapshot { lastHeartbeatAt DateTime? + /// Metadata used by various systems in the run engine + metadata Json? + /// Used to get the latest valid snapshot quickly @@index([runId, isValid, createdAt(sort: Desc)]) } @@ -2531,6 +2544,9 @@ model TaskQueue { paused Boolean @default(false) + /// If true, when a run is paused and waiting for waitpoints to be completed, the run will release the concurrency capacity. + releaseConcurrencyOnWaitpoint Boolean @default(false) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 97be60793d..210a3b766b 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -36,7 +36,6 @@ import { import { EventBus, EventBusEvents } from "./eventBus.js"; import { RunLocker } from "./locking.js"; import { ReleaseConcurrencyTokenBucketQueue } from "./releaseConcurrencyTokenBucketQueue.js"; -import { canReleaseConcurrency } from "./statuses.js"; import { BatchSystem } from "./systems/batchSystem.js"; import { CheckpointSystem } from "./systems/checkpointSystem.js"; import { DelayedRunSystem } from "./systems/delayedRunSystem.js"; @@ -46,13 +45,14 @@ import { ExecutionSnapshotSystem, getLatestExecutionSnapshot, } from "./systems/executionSnapshotSystem.js"; +import { ReleaseConcurrencySystem } from "./systems/releaseConcurrencySystem.js"; import { RunAttemptSystem } from "./systems/runAttemptSystem.js"; import { SystemResources } from "./systems/systems.js"; import { TtlSystem } from "./systems/ttlSystem.js"; +import { WaitingForWorkerSystem } from "./systems/waitingForWorkerSystem.js"; import { WaitpointSystem } from "./systems/waitpointSystem.js"; import { EngineWorker, HeartbeatTimeouts, RunEngineOptions, TriggerParams } from "./types.js"; import { workerCatalog } from "./workerCatalog.js"; -import { WaitingForWorkerSystem } from "./systems/waitingForWorkerSystem.js"; export class RunEngine { private runLockRedis: Redis; @@ -63,7 +63,7 @@ export class RunEngine { private logger = new Logger("RunEngine", "debug"); private tracer: Tracer; private heartbeatTimeouts: HeartbeatTimeouts; - private releaseConcurrencyQueue: ReleaseConcurrencyTokenBucketQueue<{ + releaseConcurrencyQueue: ReleaseConcurrencyTokenBucketQueue<{ orgId: string; projectId: string; envId: string; @@ -79,6 +79,7 @@ export class RunEngine { delayedRunSystem: DelayedRunSystem; ttlSystem: TtlSystem; waitingForWorkerSystem: WaitingForWorkerSystem; + releaseConcurrencySystem: ReleaseConcurrencySystem; constructor(private readonly options: RunEngineOptions) { this.prisma = options.prisma; @@ -188,7 +189,7 @@ export class RunEngine { redis: { ...options.queue.redis, // Use base queue redis options ...options.releaseConcurrency?.redis, // Allow overrides - keyPrefix: `${options.queue.redis.keyPrefix}release-concurrency:`, + keyPrefix: `${options.queue.redis.keyPrefix ?? ""}release-concurrency:`, }, retry: { maxRetries: options.releaseConcurrency?.maxRetries ?? 5, @@ -201,8 +202,8 @@ export class RunEngine { consumersCount: options.releaseConcurrency?.consumersCount ?? 1, pollInterval: options.releaseConcurrency?.pollInterval ?? 1000, batchSize: options.releaseConcurrency?.batchSize ?? 10, - executor: async (descriptor, runId) => { - await this.#executeReleasedConcurrencyFromQueue(descriptor, runId); + executor: async (descriptor, snapshotId) => { + await this.releaseConcurrencySystem.executeReleaseConcurrencyForSnapshot(snapshotId); }, maxTokens: async (descriptor) => { const environment = await this.prisma.runtimeEnvironment.findFirstOrThrow({ @@ -239,6 +240,10 @@ export class RunEngine { releaseConcurrencyQueue: this.releaseConcurrencyQueue, }; + this.releaseConcurrencySystem = new ReleaseConcurrencySystem({ + resources, + }); + this.executionSnapshotSystem = new ExecutionSnapshotSystem({ resources, heartbeatTimeouts: this.heartbeatTimeouts, @@ -251,6 +256,7 @@ export class RunEngine { this.checkpointSystem = new CheckpointSystem({ resources, + releaseConcurrencySystem: this.releaseConcurrencySystem, executionSnapshotSystem: this.executionSnapshotSystem, enqueueSystem: this.enqueueSystem, }); @@ -269,6 +275,7 @@ export class RunEngine { resources, executionSnapshotSystem: this.executionSnapshotSystem, enqueueSystem: this.enqueueSystem, + releaseConcurrencySystem: this.releaseConcurrencySystem, }); this.ttlSystem = new TtlSystem({ @@ -344,6 +351,7 @@ export class RunEngine { machine, workerId, runnerId, + releaseConcurrency, }: TriggerParams, tx?: PrismaClientOrTransaction ): Promise { @@ -435,6 +443,8 @@ export class RunEngine { runStatus: status, environmentId: environment.id, environmentType: environment.type, + projectId: environment.project.id, + organizationId: environment.organization.id, workerId, runnerId, }, @@ -490,12 +500,11 @@ export class RunEngine { runId: parentTaskRunId, waitpoints: associatedWaitpoint.id, projectId: associatedWaitpoint.projectId, - organizationId: environment.organization.id, batch, workerId, runnerId, tx: prisma, - releaseConcurrency: true, // TODO: This needs to use the release concurrency system + releaseConcurrency, }); } @@ -1015,7 +1024,6 @@ export class RunEngine { runId, waitpoints, projectId, - organizationId, releaseConcurrency, timeout, spanIdToComplete, @@ -1040,7 +1048,6 @@ export class RunEngine { runId, waitpoints, projectId, - organizationId, releaseConcurrency, timeout, spanIdToComplete, @@ -1051,35 +1058,6 @@ export class RunEngine { }); } - async #executeReleasedConcurrencyFromQueue( - descriptor: { orgId: string; projectId: string; envId: string }, - runId: string - ) { - this.logger.debug("Executing released concurrency", { - descriptor, - runId, - }); - - // - 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([runId], 5_000, async () => { - const snapshot = await getLatestExecutionSnapshot(this.prisma, runId); - - if (!canReleaseConcurrency(snapshot.executionStatus)) { - this.logger.debug("Run is not in a state to release concurrency", { - runId, - snapshot, - }); - - return; - } - - return await this.runQueue.releaseConcurrency(descriptor.orgId, snapshot.runId); - }); - } - /** This completes a waitpoint and updates all entries so the run isn't blocked, * if they're no longer blocked. This doesn't suffer from race conditions. */ async completeWaitpoint({ @@ -1340,7 +1318,8 @@ export class RunEngine { id: latestSnapshot.environmentId, type: latestSnapshot.environmentType, }, - orgId: run.runtimeEnvironment.organizationId, + orgId: latestSnapshot.organizationId, + projectId: latestSnapshot.projectId, error: { type: "INTERNAL_ERROR", code: "TASK_RUN_DEQUEUED_MAX_RETRIES", diff --git a/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts b/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts index 212188fb5c..fcdfb774e3 100644 --- a/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts +++ b/internal-packages/run-engine/src/engine/releaseConcurrencyTokenBucketQueue.ts @@ -114,9 +114,59 @@ export class ReleaseConcurrencyTokenBucketQueue { retryCount: 0, lastAttempt: Date.now(), }); + } else { + this.logger.info("No token available, adding to queue", { + releaseQueueDescriptor, + releaserId, + maxTokens, + }); } } + /** + * Consume a token from the token bucket for a release queue. + * + * This is mainly used for testing purposes + */ + public async consumeToken(releaseQueueDescriptor: T, releaserId: string) { + const maxTokens = await this.#callMaxTokens(releaseQueueDescriptor); + + if (maxTokens === 0) { + return; + } + + const releaseQueue = this.keys.fromDescriptor(releaseQueueDescriptor); + + await this.redis.consumeToken( + this.masterQueuesKey, + this.#bucketKey(releaseQueue), + this.#queueKey(releaseQueue), + this.#metadataKey(releaseQueue), + releaseQueue, + releaserId, + String(maxTokens), + String(Date.now()) + ); + } + + /** + * Return a token to the token bucket for a release queue. + * + * This is mainly used for testing purposes + */ + public async returnToken(releaseQueueDescriptor: T, releaserId: string) { + const releaseQueue = this.keys.fromDescriptor(releaseQueueDescriptor); + + await this.redis.returnTokenOnly( + this.masterQueuesKey, + this.#bucketKey(releaseQueue), + this.#queueKey(releaseQueue), + this.#metadataKey(releaseQueue), + releaseQueue, + releaserId + ); + } + /** * Refill the token bucket for a release queue. * @@ -384,7 +434,7 @@ local queueKey = keyPrefix .. queueName .. ":queue" local metadataKey = keyPrefix .. queueName .. ":metadata" -- Get the oldest item from the queue -local items = redis.call("ZRANGEBYSCORE", queueKey, 0, currentTime, "LIMIT", 0, batchSize - 1) +local items = redis.call("ZRANGEBYSCORE", queueKey, 0, currentTime, "LIMIT", 0, batchSize) if #items == 0 then -- No items ready to be processed yet return nil diff --git a/internal-packages/run-engine/src/engine/retrying.ts b/internal-packages/run-engine/src/engine/retrying.ts index f214738ade..a621552e92 100644 --- a/internal-packages/run-engine/src/engine/retrying.ts +++ b/internal-packages/run-engine/src/engine/retrying.ts @@ -10,7 +10,7 @@ import { } from "@trigger.dev/core/v3"; import { PrismaClientOrTransaction } from "@trigger.dev/database"; import { MAX_TASK_RUN_ATTEMPTS } from "./consts.js"; -import { ServiceValidationError } from "./index.js"; +import { ServiceValidationError } from "./errors.js"; type Params = { runId: string; diff --git a/internal-packages/run-engine/src/engine/systems/batchSystem.ts b/internal-packages/run-engine/src/engine/systems/batchSystem.ts index 0c09256ebd..5f1948a831 100644 --- a/internal-packages/run-engine/src/engine/systems/batchSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/batchSystem.ts @@ -1,8 +1,5 @@ -import { Tracer, startSpan } from "@internal/tracing"; -import { Logger } from "@trigger.dev/core/logger"; -import { PrismaClient } from "@trigger.dev/database"; +import { startSpan } from "@internal/tracing"; import { isFinalRunStatus } from "../statuses.js"; -import { EngineWorker } from "../types.js"; import { SystemResources } from "./systems.js"; export type BatchSystemOptions = { diff --git a/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts b/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts index ae38de4348..de06fca524 100644 --- a/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts @@ -11,22 +11,25 @@ import { import { SystemResources } from "./systems.js"; import { ServiceValidationError } from "../errors.js"; import { EnqueueSystem } from "./enqueueSystem.js"; - +import { ReleaseConcurrencySystem } from "./releaseConcurrencySystem.js"; export type CheckpointSystemOptions = { resources: SystemResources; executionSnapshotSystem: ExecutionSnapshotSystem; enqueueSystem: EnqueueSystem; + releaseConcurrencySystem: ReleaseConcurrencySystem; }; export class CheckpointSystem { private readonly $: SystemResources; private readonly executionSnapshotSystem: ExecutionSnapshotSystem; private readonly enqueueSystem: EnqueueSystem; + private readonly releaseConcurrencySystem: ReleaseConcurrencySystem; constructor(private readonly options: CheckpointSystemOptions) { this.$ = options.resources; this.executionSnapshotSystem = options.executionSnapshotSystem; this.enqueueSystem = options.enqueueSystem; + this.releaseConcurrencySystem = options.releaseConcurrencySystem; } /** @@ -163,6 +166,7 @@ export class CheckpointSystem { status: "QUEUED", description: "Run was QUEUED, because it was queued and executing and a checkpoint was created", + metadata: snapshot.metadata, }, previousSnapshotId: snapshot.id, batchId: snapshot.batchId ?? undefined, @@ -174,14 +178,7 @@ export class CheckpointSystem { }); // Refill the token bucket for the release concurrency queue - await this.$.releaseConcurrencyQueue.refillTokens( - { - orgId: run.runtimeEnvironment.organizationId, - projectId: run.runtimeEnvironment.projectId, - envId: run.runtimeEnvironment.id, - }, - 1 - ); + await this.releaseConcurrencySystem.checkpointCreatedOnEnvironment(run.runtimeEnvironment); return { ok: true as const, @@ -195,24 +192,20 @@ export class CheckpointSystem { snapshot: { executionStatus: "SUSPENDED", description: "Run was suspended after creating a checkpoint.", + metadata: snapshot.metadata, }, previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, + projectId: snapshot.projectId, + organizationId: snapshot.organizationId, checkpointId: taskRunCheckpoint.id, workerId, runnerId, }); // Refill the token bucket for the release concurrency queue - await this.$.releaseConcurrencyQueue.refillTokens( - { - orgId: run.runtimeEnvironment.organizationId, - projectId: run.runtimeEnvironment.projectId, - envId: run.runtimeEnvironment.id, - }, - 1 - ); + await this.releaseConcurrencySystem.checkpointCreatedOnEnvironment(run.runtimeEnvironment); return { ok: true as const, @@ -284,6 +277,8 @@ export class CheckpointSystem { previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, + projectId: snapshot.projectId, + organizationId: snapshot.organizationId, completedWaitpoints: snapshot.completedWaitpoints, workerId, runnerId, diff --git a/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts b/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts index bb2aaf308f..c954a8d7e1 100644 --- a/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/delayedRunSystem.ts @@ -59,6 +59,8 @@ export class DelayedRunSystem { runStatus: "EXPIRED", environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, + projectId: snapshot.projectId, + organizationId: snapshot.organizationId, }, }, }, diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index 3a976d897d..33bdb56563 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -105,6 +105,8 @@ export class DequeueSystem { previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, + projectId: snapshot.projectId, + organizationId: snapshot.organizationId, checkpointId: snapshot.checkpointId ?? undefined, completedWaitpoints: snapshot.completedWaitpoints, error: `Tried to dequeue a run that is not in a valid state to be dequeued.`, @@ -146,6 +148,8 @@ export class DequeueSystem { previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, + projectId: snapshot.projectId, + organizationId: snapshot.organizationId, batchId: snapshot.batchId ?? undefined, completedWaitpoints: snapshot.completedWaitpoints.map((waitpoint) => ({ id: waitpoint.id, @@ -337,6 +341,42 @@ export class DequeueSystem { maxAttempts = parsedConfig.data.maxAttempts; } + const queue = await prisma.taskQueue.findUnique({ + where: { + runtimeEnvironmentId_name: { + runtimeEnvironmentId: result.run.runtimeEnvironmentId, + name: sanitizeQueueName(result.run.queue), + }, + }, + }); + + if (!queue) { + this.$.logger.debug( + "RunEngine.dequeueFromMasterQueue(): queue not found, so nacking message", + { + queueMessage: message, + taskRunQueue: result.run.queue, + runtimeEnvironmentId: result.run.runtimeEnvironmentId, + } + ); + + //will auto-retry + const gotRequeued = await this.$.runQueue.nackMessage({ orgId, messageId: runId }); + if (!gotRequeued) { + await this.runAttemptSystem.systemFailure({ + runId, + error: { + type: "INTERNAL_ERROR", + code: "TASK_DEQUEUED_QUEUE_NOT_FOUND", + message: `Tried to dequeue the run but the queue doesn't exist: ${result.run.queue}`, + }, + tx: prisma, + }); + } + + return null; + } + //update the run const lockedTaskRun = await prisma.taskRun.update({ where: { @@ -346,6 +386,7 @@ export class DequeueSystem { lockedAt: new Date(), lockedById: result.task.id, lockedToVersionId: result.worker.id, + lockedQueueId: queue.id, startedAt: result.run.startedAt ?? new Date(), baseCostInCents: this.options.machines.baseCostInCents, machinePreset: machinePreset.name, @@ -378,42 +419,6 @@ export class DequeueSystem { return null; } - const queue = await prisma.taskQueue.findUnique({ - where: { - runtimeEnvironmentId_name: { - runtimeEnvironmentId: lockedTaskRun.runtimeEnvironmentId, - name: sanitizeQueueName(lockedTaskRun.queue), - }, - }, - }); - - if (!queue) { - this.$.logger.debug( - "RunEngine.dequeueFromMasterQueue(): queue not found, so nacking message", - { - queueMessage: message, - taskRunQueue: lockedTaskRun.queue, - runtimeEnvironmentId: lockedTaskRun.runtimeEnvironmentId, - } - ); - - //will auto-retry - const gotRequeued = await this.$.runQueue.nackMessage({ orgId, messageId: runId }); - if (!gotRequeued) { - await this.runAttemptSystem.systemFailure({ - runId, - error: { - type: "INTERNAL_ERROR", - code: "TASK_DEQUEUED_QUEUE_NOT_FOUND", - message: `Tried to dequeue the run but the queue doesn't exist: ${lockedTaskRun.queue}`, - }, - tx: prisma, - }); - } - - return null; - } - const currentAttemptNumber = lockedTaskRun.attemptNumber ?? 0; const nextAttemptNumber = currentAttemptNumber + 1; @@ -432,6 +437,8 @@ export class DequeueSystem { previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, + projectId: snapshot.projectId, + organizationId: snapshot.organizationId, checkpointId: snapshot.checkpointId ?? undefined, completedWaitpoints: snapshot.completedWaitpoints, workerId, @@ -519,6 +526,7 @@ export class DequeueSystem { run, environment: run.runtimeEnvironment, orgId, + projectId: run.runtimeEnvironment.projectId, error: { type: "INTERNAL_ERROR", code: "TASK_RUN_DEQUEUED_MAX_RETRIES", @@ -572,7 +580,12 @@ export class DequeueSystem { status: true, attemptNumber: true, runtimeEnvironment: { - select: { id: true, type: true }, + select: { + id: true, + type: true, + projectId: true, + project: { select: { id: true, organizationId: true } }, + }, }, }, }); @@ -587,6 +600,8 @@ export class DequeueSystem { }, environmentId: run.runtimeEnvironment.id, environmentType: run.runtimeEnvironment.type, + projectId: run.runtimeEnvironment.projectId, + organizationId: run.runtimeEnvironment.project.organizationId, workerId, runnerId, }); diff --git a/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts b/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts index 842bbeed8b..0ed309792e 100644 --- a/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/enqueueSystem.ts @@ -1,4 +1,9 @@ -import { PrismaClientOrTransaction, TaskRun, TaskRunExecutionStatus } from "@trigger.dev/database"; +import { + Prisma, + PrismaClientOrTransaction, + TaskRun, + TaskRunExecutionStatus, +} from "@trigger.dev/database"; import { MinimalAuthenticatedEnvironment } from "../../shared/index.js"; import { ExecutionSnapshotSystem } from "./executionSnapshotSystem.js"; import { SystemResources } from "./systems.js"; @@ -37,6 +42,7 @@ export class EnqueueSystem { snapshot?: { status?: Extract; description?: string; + metadata?: Prisma.JsonValue; }; previousSnapshotId?: string; batchId?: string; @@ -56,11 +62,14 @@ export class EnqueueSystem { snapshot: { executionStatus: snapshot?.status ?? "QUEUED", description: snapshot?.description ?? "Run was QUEUED", + metadata: snapshot?.metadata ?? undefined, }, previousSnapshotId, batchId, environmentId: env.id, environmentType: env.type, + projectId: env.project.id, + organizationId: env.organization.id, checkpointId, completedWaitpoints, workerId, diff --git a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts index 2abc640518..25320697b0 100644 --- a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts @@ -1,6 +1,7 @@ import { CompletedWaitpoint, ExecutionResult } from "@trigger.dev/core/v3"; import { BatchId, RunId, SnapshotId } from "@trigger.dev/core/v3/isomorphic"; import { + Prisma, PrismaClientOrTransaction, RuntimeEnvironmentType, TaskRunCheckpoint, @@ -158,6 +159,8 @@ export class ExecutionSnapshotSystem { batchId, environmentId, environmentType, + projectId, + organizationId, checkpointId, workerId, runnerId, @@ -168,11 +171,14 @@ export class ExecutionSnapshotSystem { snapshot: { executionStatus: TaskRunExecutionStatus; description: string; + metadata?: Prisma.JsonValue; }; previousSnapshotId?: string; batchId?: string; environmentId: string; environmentType: RuntimeEnvironmentType; + projectId: string; + organizationId: string; checkpointId?: string; workerId?: string; runnerId?: string; @@ -195,9 +201,12 @@ export class ExecutionSnapshotSystem { batchId, environmentId, environmentType, + projectId, + organizationId, checkpointId, workerId, runnerId, + metadata: snapshot.metadata ?? undefined, completedWaitpoints: { connect: completedWaitpoints?.map((w) => ({ id: w.id })), }, diff --git a/internal-packages/run-engine/src/engine/systems/releaseConcurrencySystem.ts b/internal-packages/run-engine/src/engine/systems/releaseConcurrencySystem.ts new file mode 100644 index 0000000000..cf29e115c9 --- /dev/null +++ b/internal-packages/run-engine/src/engine/systems/releaseConcurrencySystem.ts @@ -0,0 +1,161 @@ +import { RuntimeEnvironment, TaskRunExecutionSnapshot } from "@trigger.dev/database"; +import { SystemResources } from "./systems.js"; +import { getLatestExecutionSnapshot } from "./executionSnapshotSystem.js"; +import { canReleaseConcurrency } from "../statuses.js"; +import { z } from "zod"; + +const ReleaseConcurrencyMetadata = z.object({ + releaseConcurrency: z.boolean().optional(), +}); + +type ReleaseConcurrencyMetadata = z.infer; + +export type ReleaseConcurrencySystemOptions = { + resources: SystemResources; +}; + +export class ReleaseConcurrencySystem { + private readonly $: SystemResources; + + constructor(private readonly options: ReleaseConcurrencySystemOptions) { + this.$ = options.resources; + } + + public async checkpointCreatedOnEnvironment(environment: RuntimeEnvironment) { + await this.$.releaseConcurrencyQueue.refillTokens( + { + orgId: environment.organizationId, + projectId: environment.projectId, + envId: environment.id, + }, + 1 + ); + } + + public async releaseConcurrencyForSnapshot(snapshot: TaskRunExecutionSnapshot) { + // Go ahead and release concurrency immediately if the run is in a development environment + if (snapshot.environmentType === "DEVELOPMENT") { + return await this.executeReleaseConcurrencyForSnapshot(snapshot.id); + } + + await this.$.releaseConcurrencyQueue.attemptToRelease( + { + orgId: snapshot.organizationId, + projectId: snapshot.projectId, + envId: snapshot.environmentId, + }, + snapshot.id + ); + } + + public async executeReleaseConcurrencyForSnapshot(snapshotId: string) { + this.$.logger.debug("Executing released concurrency", { + snapshotId, + }); + + // Fetch the snapshot + const snapshot = await this.$.prisma.taskRunExecutionSnapshot.findFirst({ + where: { id: snapshotId }, + select: { + id: true, + previousSnapshotId: true, + executionStatus: true, + organizationId: true, + metadata: true, + runId: true, + run: { + select: { + lockedQueueId: true, + }, + }, + }, + }); + + if (!snapshot) { + this.$.logger.error("Snapshot not found", { + snapshotId, + }); + + return; + } + + // - 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 () => { + const latestSnapshot = await getLatestExecutionSnapshot(this.$.prisma, snapshot.runId); + + const isValidSnapshot = + latestSnapshot.id === snapshot.id || + // Case 2: The provided snapshotId matches the previous snapshot + // AND we're in SUSPENDED state (which is valid) + (latestSnapshot.previousSnapshotId === snapshot.id && + latestSnapshot.executionStatus === "SUSPENDED"); + + if (!isValidSnapshot) { + this.$.logger.error("Tried to release concurrency on an invalid snapshot", { + latestSnapshot, + snapshot, + }); + + return; + } + + if (!canReleaseConcurrency(latestSnapshot.executionStatus)) { + this.$.logger.debug("Run is not in a state to release concurrency", { + runId: snapshot.runId, + snapshot: latestSnapshot, + }); + + return; + } + + const metadata = this.#parseMetadata(snapshot.metadata); + + if (typeof metadata.releaseConcurrency === "boolean") { + if (metadata.releaseConcurrency) { + return await this.$.runQueue.releaseAllConcurrency( + snapshot.organizationId, + snapshot.runId + ); + } + + return await this.$.runQueue.releaseEnvConcurrency(snapshot.organizationId, snapshot.runId); + } + + // Get the locked queue + const taskQueue = snapshot.run.lockedQueueId + ? await this.$.prisma.taskQueue.findFirst({ + where: { + id: snapshot.run.lockedQueueId, + }, + }) + : undefined; + + if ( + taskQueue && + (typeof taskQueue.concurrencyLimit === "undefined" || + taskQueue.releaseConcurrencyOnWaitpoint) + ) { + return await this.$.runQueue.releaseAllConcurrency(snapshot.organizationId, snapshot.runId); + } + + return await this.$.runQueue.releaseEnvConcurrency(snapshot.organizationId, snapshot.runId); + }); + } + + #parseMetadata(metadata?: unknown): ReleaseConcurrencyMetadata { + if (!metadata) { + return {}; + } + + const result = ReleaseConcurrencyMetadata.safeParse(metadata); + + if (!result.success) { + return {}; + } + + return result.data; + } +} diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index d6e6fd6ecc..0ba498d773 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -216,6 +216,8 @@ export class RunAttemptSystem { previousSnapshotId: latestSnapshot.id, environmentId: latestSnapshot.environmentId, environmentType: latestSnapshot.environmentType, + projectId: latestSnapshot.projectId, + organizationId: latestSnapshot.organizationId, workerId, runnerId, }); @@ -436,6 +438,8 @@ export class RunAttemptSystem { attemptNumber: latestSnapshot.attemptNumber, environmentId: latestSnapshot.environmentId, environmentType: latestSnapshot.environmentType, + projectId: latestSnapshot.projectId, + organizationId: latestSnapshot.organizationId, workerId, runnerId, }, @@ -706,6 +710,7 @@ export class RunAttemptSystem { run, environment: run.runtimeEnvironment, orgId: run.runtimeEnvironment.organizationId, + projectId: run.runtimeEnvironment.project.id, timestamp: retryAt.getTime(), error: { type: "INTERNAL_ERROR", @@ -737,6 +742,8 @@ export class RunAttemptSystem { previousSnapshotId: latestSnapshot.id, environmentId: latestSnapshot.environmentId, environmentType: latestSnapshot.environmentType, + projectId: latestSnapshot.projectId, + organizationId: latestSnapshot.organizationId, workerId, runnerId, } @@ -820,6 +827,7 @@ export class RunAttemptSystem { run, environment, orgId, + projectId, timestamp, error, workerId, @@ -832,6 +840,7 @@ export class RunAttemptSystem { type: RuntimeEnvironmentType; }; orgId: string; + projectId: string; timestamp?: number; error: TaskRunInternalError; workerId?: string; @@ -865,6 +874,8 @@ export class RunAttemptSystem { }, environmentId: environment.id, environmentType: environment.type, + projectId: projectId, + organizationId: orgId, workerId, runnerId, }); @@ -988,6 +999,8 @@ export class RunAttemptSystem { previousSnapshotId: latestSnapshot.id, environmentId: latestSnapshot.environmentId, environmentType: latestSnapshot.environmentType, + projectId: latestSnapshot.projectId, + organizationId: latestSnapshot.organizationId, workerId, runnerId, }); @@ -1011,6 +1024,8 @@ export class RunAttemptSystem { previousSnapshotId: latestSnapshot.id, environmentId: latestSnapshot.environmentId, environmentType: latestSnapshot.environmentType, + projectId: latestSnapshot.projectId, + organizationId: latestSnapshot.organizationId, workerId, runnerId, }); @@ -1104,6 +1119,12 @@ export class RunAttemptSystem { id: true, type: true, organizationId: true, + project: { + select: { + id: true, + organizationId: true, + }, + }, }, }, taskEventStore: true, @@ -1121,6 +1142,8 @@ export class RunAttemptSystem { previousSnapshotId: snapshotId, environmentId: run.runtimeEnvironment.id, environmentType: run.runtimeEnvironment.type, + projectId: run.runtimeEnvironment.project.id, + organizationId: run.runtimeEnvironment.project.organizationId, workerId, runnerId, }); diff --git a/internal-packages/run-engine/src/engine/systems/ttlSystem.ts b/internal-packages/run-engine/src/engine/systems/ttlSystem.ts index da6a44b825..12910f4634 100644 --- a/internal-packages/run-engine/src/engine/systems/ttlSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/ttlSystem.ts @@ -76,6 +76,8 @@ export class TtlSystem { runStatus: "EXPIRED", environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, + projectId: snapshot.projectId, + organizationId: snapshot.organizationId, }, }, }, diff --git a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts index 3d1a59dca3..ee5d79895d 100644 --- a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts @@ -14,23 +14,26 @@ import { isExecuting } from "../statuses.js"; import { EnqueueSystem } from "./enqueueSystem.js"; import { ExecutionSnapshotSystem, getLatestExecutionSnapshot } from "./executionSnapshotSystem.js"; import { SystemResources } from "./systems.js"; +import { ReleaseConcurrencySystem } from "./releaseConcurrencySystem.js"; export type WaitpointSystemOptions = { resources: SystemResources; executionSnapshotSystem: ExecutionSnapshotSystem; enqueueSystem: EnqueueSystem; + releaseConcurrencySystem: ReleaseConcurrencySystem; }; export class WaitpointSystem { private readonly $: SystemResources; private readonly executionSnapshotSystem: ExecutionSnapshotSystem; - + private readonly releaseConcurrencySystem: ReleaseConcurrencySystem; private readonly enqueueSystem: EnqueueSystem; constructor(private readonly options: WaitpointSystemOptions) { this.$ = options.resources; this.executionSnapshotSystem = options.executionSnapshotSystem; this.enqueueSystem = options.enqueueSystem; + this.releaseConcurrencySystem = options.releaseConcurrencySystem; } public async clearBlockingWaitpoints({ @@ -326,7 +329,6 @@ export class WaitpointSystem { runId, waitpoints, projectId, - organizationId, releaseConcurrency, timeout, spanIdToComplete, @@ -338,7 +340,6 @@ export class WaitpointSystem { runId: string; waitpoints: string | string[]; projectId: string; - organizationId: string; releaseConcurrency?: boolean; timeout?: Date; spanIdToComplete?: string; @@ -378,7 +379,7 @@ export class WaitpointSystem { JOIN "Waitpoint" w ON w.id = i."waitpointId" WHERE w.status = 'PENDING';`; - const pendingCount = Number(insert.at(0)?.pending_count ?? 0); + const isRunBlocked = Number(insert.at(0)?.pending_count ?? 0) > 0; let newStatus: TaskRunExecutionStatus = "SUSPENDED"; if ( @@ -399,10 +400,15 @@ export class WaitpointSystem { snapshot: { executionStatus: newStatus, description: "Run was blocked by a waitpoint.", + metadata: { + releaseConcurrency, + }, }, previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, + projectId: snapshot.projectId, + organizationId: snapshot.organizationId, batchId: batch?.id ?? snapshot.batchId ?? undefined, workerId, runnerId, @@ -428,7 +434,10 @@ export class WaitpointSystem { //no pending waitpoint, schedule unblocking the run //debounce if we're rapidly adding waitpoints - if (pendingCount === 0) { + if (isRunBlocked) { + //release concurrency + await this.releaseConcurrencySystem.releaseConcurrencyForSnapshot(snapshot); + } else { await this.$.worker.enqueue({ //this will debounce the call id: `continueRunIfUnblocked:${runId}`, @@ -437,11 +446,6 @@ export class WaitpointSystem { //in the near future availableAt: new Date(Date.now() + 50), }); - } else { - if (releaseConcurrency) { - //release concurrency - await this.#attemptToReleaseConcurrency(organizationId, snapshot); - } } return snapshot; @@ -515,6 +519,8 @@ export class WaitpointSystem { previousSnapshotId: snapshot.id, environmentId: snapshot.environmentId, environmentType: snapshot.environmentType, + projectId: snapshot.projectId, + organizationId: snapshot.organizationId, batchId: snapshot.batchId ?? undefined, completedWaitpoints: blockingWaitpoints.map((b) => ({ id: b.waitpoint.id, @@ -601,45 +607,4 @@ export class WaitpointSystem { }, }); } - - async #attemptToReleaseConcurrency(orgId: string, snapshot: TaskRunExecutionSnapshot) { - // Go ahead and release concurrency immediately if the run is in a development environment - if (snapshot.environmentType === "DEVELOPMENT") { - return await this.$.runQueue.releaseConcurrency(orgId, snapshot.runId); - } - - const run = await this.$.prisma.taskRun.findFirst({ - where: { - id: snapshot.runId, - }, - select: { - runtimeEnvironment: { - select: { - id: true, - projectId: true, - organizationId: true, - }, - }, - }, - }); - - if (!run) { - this.$.logger.error("Run not found for attemptToReleaseConcurrency", { - runId: snapshot.runId, - }); - - return; - } - - await this.$.releaseConcurrencyQueue.attemptToRelease( - { - orgId: run.runtimeEnvironment.organizationId, - projectId: run.runtimeEnvironment.projectId, - envId: run.runtimeEnvironment.id, - }, - snapshot.runId - ); - - return; - } } diff --git a/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts b/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts new file mode 100644 index 0000000000..d975351456 --- /dev/null +++ b/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts @@ -0,0 +1,1094 @@ +import { + assertNonNullable, + containerTest, + setupAuthenticatedEnvironment, + setupBackgroundWorker, +} from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { RunEngine } from "../index.js"; +import { setTimeout } from "node:timers/promises"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("RunEngine Releasing Concurrency", () => { + containerTest("defaults to releasing env concurrency only", 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, + batchSize: 1, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + const taskIdentifier = "test-task"; + + await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier); + + 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", + queueName: `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, not specifying any release concurrency option + const executingWithWaitpointSnapshot = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: result.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + }); + + expect(executingWithWaitpointSnapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Now confirm the queue has the same concurrency as before + const queueConcurrencyAfter = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfter).toBe(1); + + // Now confirm the environment has a concurrency of 0 + const envConcurrencyAfter = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfter).toBe(0); + + await engine.completeWaitpoint({ + id: result.waitpoint.id, + }); + + await setTimeout(500); + + // Test that we've reacquired the queue concurrency + const queueConcurrencyAfterWaitpoint = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfterWaitpoint).toBe(1); + + // Test that we've reacquired the environment concurrency + const envConcurrencyAfterWaitpoint = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfterWaitpoint).toBe(1); + + // Now we are going to block with another waitpoint, this time specifiying we want to release the concurrency in the waitpoint + const result2 = await engine.createManualWaitpoint({ + environmentId: authenticatedEnvironment.id, + projectId: authenticatedEnvironment.projectId, + }); + + const executingWithWaitpointSnapshot2 = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: result2.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + releaseConcurrency: true, + }); + + expect(executingWithWaitpointSnapshot2.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Test that we've released the queue concurrency + const queueConcurrencyAfterWaitpoint2 = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfterWaitpoint2).toBe(0); + + // Test that we've released the environment concurrency + const envConcurrencyAfterWaitpoint2 = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfterWaitpoint2).toBe(0); + + // Complete the waitpoint and make sure the run reacquires the queue and environment concurrency + await engine.completeWaitpoint({ + id: result2.waitpoint.id, + }); + + await setTimeout(500); + + // Test that we've reacquired the queue concurrency + const queueConcurrencyAfterWaitpoint3 = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfterWaitpoint3).toBe(1); + }); + + containerTest( + "releases all concurrency when configured on queue", + 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, + batchSize: 1, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + const taskIdentifier = "test-task"; + + await setupBackgroundWorker( + prisma, + authenticatedEnvironment, + taskIdentifier, + undefined, + undefined, + { + releaseConcurrencyOnWaitpoint: true, + } + ); + + 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", + queueName: `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, not specifying any release concurrency option + const executingWithWaitpointSnapshot = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: result.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + }); + + expect(executingWithWaitpointSnapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Now confirm the queue has the same concurrency as before + const queueConcurrencyAfter = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfter).toBe(0); + + // Now confirm the environment has a concurrency of 0 + const envConcurrencyAfter = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfter).toBe(0); + + // Complete the waitpoint and make sure the run reacquires the queue and environment concurrency + await engine.completeWaitpoint({ + id: result.waitpoint.id, + }); + + await setTimeout(500); + + // Test that we've reacquired the queue concurrency + const queueConcurrencyAfterWaitpoint = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfterWaitpoint).toBe(1); + + // Test that we've reacquired the environment concurrency + const envConcurrencyAfterWaitpoint = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfterWaitpoint).toBe(1); + + // Now we are going to block with another waitpoint, this time specifiying we dont want to release the concurrency in the waitpoint + const result2 = await engine.createManualWaitpoint({ + environmentId: authenticatedEnvironment.id, + projectId: authenticatedEnvironment.projectId, + }); + + const executingWithWaitpointSnapshot2 = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: result2.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + releaseConcurrency: false, + }); + + expect(executingWithWaitpointSnapshot2.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Test that we've not released the queue concurrency + const queueConcurrencyAfterWaitpoint2 = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfterWaitpoint2).toBe(1); + + // Test that we've still released the environment concurrency since we always release env concurrency + const envConcurrencyAfterWaitpoint2 = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfterWaitpoint2).toBe(0); + } + ); + + containerTest( + "releases all concurrency for unlimited queues", + 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, + batchSize: 1, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + const taskIdentifier = "test-task"; + + await setupBackgroundWorker( + prisma, + authenticatedEnvironment, + taskIdentifier, + undefined, + undefined, + { + releaseConcurrencyOnWaitpoint: true, + concurrencyLimit: null, + } + ); + + 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", + queueName: `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, not specifying any release concurrency option + const executingWithWaitpointSnapshot = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: result.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + }); + + expect(executingWithWaitpointSnapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Now confirm the queue has the same concurrency as before + const queueConcurrencyAfter = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfter).toBe(0); + + // Now confirm the environment has a concurrency of 0 + const envConcurrencyAfter = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfter).toBe(0); + + // Complete the waitpoint and make sure the run reacquires the queue and environment concurrency + await engine.completeWaitpoint({ + id: result.waitpoint.id, + }); + + await setTimeout(500); + + // Test that we've reacquired the queue concurrency + const queueConcurrencyAfterWaitpoint = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfterWaitpoint).toBe(1); + + // Test that we've reacquired the environment concurrency + const envConcurrencyAfterWaitpoint = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfterWaitpoint).toBe(1); + + // Now we are going to block with another waitpoint, this time specifiying we dont want to release the concurrency in the waitpoint + const result2 = await engine.createManualWaitpoint({ + environmentId: authenticatedEnvironment.id, + projectId: authenticatedEnvironment.projectId, + }); + + const executingWithWaitpointSnapshot2 = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: result2.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + releaseConcurrency: false, + }); + + expect(executingWithWaitpointSnapshot2.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Test that we've not released the queue concurrency + const queueConcurrencyAfterWaitpoint2 = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfterWaitpoint2).toBe(1); + + // Test that we've still released the environment concurrency since we always release env concurrency + const envConcurrencyAfterWaitpoint2 = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfterWaitpoint2).toBe(0); + } + ); + + containerTest( + "delays env concurrency release when token unavailable", + 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: 0.1, // 10% of the concurrency limit = 1 token + maxRetries: 3, + consumersCount: 1, + pollInterval: 500, + batchSize: 1, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + const taskIdentifier = "test-task"; + + await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier); + + 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", + queueName: `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, + }); + + await engine.releaseConcurrencyQueue.consumeToken( + { + orgId: authenticatedEnvironment.organizationId, + projectId: authenticatedEnvironment.projectId, + envId: authenticatedEnvironment.id, + }, + "test_12345" + ); + + // Block the run, not specifying any release concurrency option + const executingWithWaitpointSnapshot = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: result.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + }); + + expect(executingWithWaitpointSnapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Now confirm the queue has the same concurrency as before + const queueConcurrencyAfter = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfter).toBe(1); + + // Now confirm the environment is the same as before + const envConcurrencyAfter = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfter).toBe(1); + + // Now we return the token so the concurrency can be released + await engine.releaseConcurrencyQueue.returnToken( + { + orgId: authenticatedEnvironment.organizationId, + projectId: authenticatedEnvironment.projectId, + envId: authenticatedEnvironment.id, + }, + "test_12345" + ); + + // Wait until the token is released + await setTimeout(1_000); + + // Now the environment should have a concurrency of 0 + const envConcurrencyAfterReturn = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfterReturn).toBe(0); + + // and the queue should have a concurrency of 1 + const queueConcurrencyAfterReturn = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfterReturn).toBe(1); + } + ); + + containerTest( + "delays env concurrency release after checkpoint", + 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: 0.1, // 10% of the concurrency limit = 1 token + maxRetries: 3, + consumersCount: 1, + pollInterval: 500, + batchSize: 1, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + const taskIdentifier = "test-task"; + + await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier); + + 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", + queueName: `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, + }); + + await engine.releaseConcurrencyQueue.consumeToken( + { + orgId: authenticatedEnvironment.organizationId, + projectId: authenticatedEnvironment.projectId, + envId: authenticatedEnvironment.id, + }, + "test_12345" + ); + + // Block the run, not specifying any release concurrency option + const executingWithWaitpointSnapshot = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: result.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + }); + + expect(executingWithWaitpointSnapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Now confirm the queue has the same concurrency as before + const queueConcurrencyAfter = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfter).toBe(1); + + // Now confirm the environment is the same as before + const envConcurrencyAfter = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfter).toBe(1); + + const checkpointResult = await engine.createCheckpoint({ + runId: run.id, + snapshotId: executingWithWaitpointSnapshot.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); + + console.log("Snapshot", snapshot); + + expect(snapshot.executionStatus).toBe("SUSPENDED"); + + // Now we return the token so the concurrency can be released + await engine.releaseConcurrencyQueue.returnToken( + { + orgId: authenticatedEnvironment.organizationId, + projectId: authenticatedEnvironment.projectId, + envId: authenticatedEnvironment.id, + }, + "test_12345" + ); + + // Wait until the token is released + await setTimeout(1_000); + + // Now the environment should have a concurrency of 0 + const envConcurrencyAfterReturn = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfterReturn).toBe(0); + + // and the queue should have a concurrency of 1 + const queueConcurrencyAfterReturn = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfterReturn).toBe(1); + } + ); + + containerTest( + "maintains concurrency after waitpoint completion", + 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: 0.1, // 10% of the concurrency limit = 1 token + maxRetries: 3, + consumersCount: 1, + pollInterval: 500, + batchSize: 1, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + const taskIdentifier = "test-task"; + + await setupBackgroundWorker(prisma, authenticatedEnvironment, taskIdentifier); + + 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", + queueName: `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, + }); + + await engine.releaseConcurrencyQueue.consumeToken( + { + orgId: authenticatedEnvironment.organizationId, + projectId: authenticatedEnvironment.projectId, + envId: authenticatedEnvironment.id, + }, + "test_12345" + ); + + // Block the run, not specifying any release concurrency option + const executingWithWaitpointSnapshot = await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: result.waitpoint.id, + projectId: authenticatedEnvironment.projectId, + organizationId: authenticatedEnvironment.organizationId, + }); + + expect(executingWithWaitpointSnapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + // Now confirm the queue has the same concurrency as before + const queueConcurrencyAfter = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfter).toBe(1); + + // Now confirm the environment is the same as before + const envConcurrencyAfter = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfter).toBe(1); + + // Complete the waitpoint + await engine.completeWaitpoint({ + id: result.waitpoint.id, + }); + + await setTimeout(1_000); + + // Verify the first run is now in EXECUTING state + const executionDataAfter = await engine.getRunExecutionData({ runId: run.id }); + expect(executionDataAfter?.snapshot.executionStatus).toBe("EXECUTING"); + + // Now we return the token so the concurrency can be released + await engine.releaseConcurrencyQueue.returnToken( + { + orgId: authenticatedEnvironment.organizationId, + projectId: authenticatedEnvironment.projectId, + envId: authenticatedEnvironment.id, + }, + "test_12345" + ); + + // give the release concurrency system time to run + await setTimeout(1_000); + + // Now the environment should have a concurrency of 1 + const envConcurrencyAfterReturn = await engine.runQueue.currentConcurrencyOfEnvironment( + authenticatedEnvironment + ); + + expect(envConcurrencyAfterReturn).toBe(1); + + // and the queue should have a concurrency of 1 + const queueConcurrencyAfterReturn = await engine.runQueue.currentConcurrencyOfQueue( + authenticatedEnvironment, + `task/${taskIdentifier}` + ); + + expect(queueConcurrencyAfterReturn).toBe(1); + } + ); +}); diff --git a/internal-packages/run-engine/src/engine/tests/releasingConcurrency.test.ts b/internal-packages/run-engine/src/engine/tests/releasingConcurrency.test.ts deleted file mode 100644 index 7d5c2e89e7..0000000000 --- a/internal-packages/run-engine/src/engine/tests/releasingConcurrency.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { containerTest } from "@internal/testcontainers"; - -vi.setConfig({ testTimeout: 60_000 }); - -describe("RunEngine Releasing Concurrency", () => { - containerTest( - "blocking a run with a waitpoint with releasing concurrency", - async ({ prisma, redisOptions }) => {} - ); -}); diff --git a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts index 3c06cd600b..519212aedf 100644 --- a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts @@ -7,7 +7,7 @@ import { import { trace } from "@internal/tracing"; import { expect } from "vitest"; import { RunEngine } from "../index.js"; -import { setTimeout } from "timers/promises"; +import { setTimeout } from "node:timers/promises"; import { EventBusEventArgs } from "../eventBus.js"; import { isWaitpointOutputTimeout } from "@trigger.dev/core/v3"; diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index 2d548f1bae..f823c80b13 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -105,6 +105,7 @@ export type TriggerParams = { machine?: MachinePresetName; workerId?: string; runnerId?: string; + releaseConcurrency?: boolean; }; export type EngineWorker = Worker; diff --git a/internal-packages/run-engine/src/index.ts b/internal-packages/run-engine/src/index.ts index 89bd08196d..8d77c66a04 100644 --- a/internal-packages/run-engine/src/index.ts +++ b/internal-packages/run-engine/src/index.ts @@ -1,2 +1,3 @@ -export { RunEngine, RunDuplicateIdempotencyKeyError } from "./engine/index.js"; +export { RunEngine } from "./engine/index.js"; +export { RunDuplicateIdempotencyKeyError } from "./engine/errors.js"; export type { EventBusEventArgs } from "./engine/eventBus.js"; diff --git a/internal-packages/run-engine/src/run-queue/index.test.ts b/internal-packages/run-engine/src/run-queue/index.test.ts index 4085d87dfd..dbbb574bfc 100644 --- a/internal-packages/run-engine/src/run-queue/index.test.ts +++ b/internal-packages/run-engine/src/run-queue/index.test.ts @@ -690,7 +690,10 @@ describe("RunQueue", () => { expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); //release the concurrency - await queue.releaseConcurrency(authenticatedEnvProd.organization.id, messages[0].messageId); + await queue.releaseAllConcurrency( + authenticatedEnvProd.organization.id, + messages[0].messageId + ); //concurrencies expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( @@ -708,7 +711,10 @@ describe("RunQueue", () => { expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); //release the concurrency (with the queue this time) - await queue.releaseConcurrency(authenticatedEnvProd.organization.id, messages[0].messageId); + await queue.releaseAllConcurrency( + authenticatedEnvProd.organization.id, + messages[0].messageId + ); //concurrencies expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index cd4a571da0..a5aacd957f 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -561,14 +561,17 @@ export class RunQueue { ); } - public async releaseConcurrency(orgId: string, messageId: string) { + /** + * Release all concurrency for a message, including environment and queue concurrency + */ + public async releaseAllConcurrency(orgId: string, messageId: string) { return this.#trace( - "releaseConcurrency", + "releaseAllConcurrency", async (span) => { const message = await this.readMessage(orgId, messageId); if (!message) { - this.logger.log(`[${this.name}].acknowledgeMessage() message not found`, { + this.logger.log(`[${this.name}].releaseAllConcurrency() message not found`, { messageId, service: this.name, }); @@ -591,7 +594,44 @@ export class RunQueue { { kind: SpanKind.CONSUMER, attributes: { - [SEMATTRS_MESSAGING_OPERATION]: "releaseConcurrency", + [SEMATTRS_MESSAGING_OPERATION]: "releaseAllConcurrency", + [SEMATTRS_MESSAGE_ID]: messageId, + [SEMATTRS_MESSAGING_SYSTEM]: "runqueue", + }, + } + ); + } + + public async releaseEnvConcurrency(orgId: string, messageId: string) { + return this.#trace( + "releaseEnvConcurrency", + async (span) => { + const message = await this.readMessage(orgId, messageId); + + if (!message) { + this.logger.log(`[${this.name}].releaseEnvConcurrency() message not found`, { + messageId, + service: this.name, + }); + return; + } + + span.setAttributes({ + [SemanticAttributes.QUEUE]: message.queue, + [SemanticAttributes.ORG_ID]: message.orgId, + [SemanticAttributes.RUN_ID]: messageId, + [SemanticAttributes.CONCURRENCY_KEY]: message.concurrencyKey, + }); + + return this.redis.releaseEnvConcurrency( + this.keys.envCurrentConcurrencyKeyFromQueue(message.queue), + messageId + ); + }, + { + kind: SpanKind.CONSUMER, + attributes: { + [SEMATTRS_MESSAGING_OPERATION]: "releaseEnvConcurrency", [SEMATTRS_MESSAGE_ID]: messageId, [SEMATTRS_MESSAGING_SYSTEM]: "runqueue", }, @@ -1242,6 +1282,20 @@ redis.call('SREM', envCurrentConcurrencyKey, messageId) `, }); + this.redis.defineCommand("releaseEnvConcurrency", { + numberOfKeys: 1, + lua: ` +-- Keys: +local envCurrentConcurrencyKey = KEYS[1] + +-- Args: +local messageId = ARGV[1] + +-- Update the concurrency keys +redis.call('SREM', envCurrentConcurrencyKey, messageId) +`, + }); + this.redis.defineCommand("reacquireConcurrency", { numberOfKeys: 4, lua: ` @@ -1274,12 +1328,14 @@ if envCurrentConcurrency >= totalEnvConcurrencyLimit then end -- Check current queue concurrency against the limit -local queueCurrentConcurrency = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') -local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) -local totalQueueConcurrencyLimit = queueConcurrencyLimit +if not isInQueueConcurrency then + local queueCurrentConcurrency = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') + local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) + local totalQueueConcurrencyLimit = queueConcurrencyLimit -if queueCurrentConcurrency >= totalQueueConcurrencyLimit then - return false + if queueCurrentConcurrency >= totalQueueConcurrencyLimit then + return false + end end -- Update the concurrency keys @@ -1390,6 +1446,12 @@ declare module "@internal/redis" { callback?: Callback ): Result; + releaseEnvConcurrency( + envCurrentConcurrencyKey: string, + messageId: string, + callback?: Callback + ): Result; + reacquireConcurrency( queueCurrentConcurrencyKey: string, envCurrentConcurrencyKey: string, diff --git a/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts b/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts index 6261e48780..a9c0386ca5 100644 --- a/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/reacquireConcurrency.test.ts @@ -87,7 +87,7 @@ describe("RunQueue.reacquireConcurrency", () => { expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); // First, release the concurrency - await queue.releaseConcurrency(authenticatedEnvProd.organization.id, messageProd.runId); + await queue.releaseAllConcurrency(authenticatedEnvProd.organization.id, messageProd.runId); //reacquire the concurrency const result = await queue.reacquireConcurrency( diff --git a/internal-packages/run-engine/src/run-queue/tests/releaseConcurrency.test.ts b/internal-packages/run-engine/src/run-queue/tests/releaseConcurrency.test.ts index 47be728c60..63873a54b3 100644 --- a/internal-packages/run-engine/src/run-queue/tests/releaseConcurrency.test.ts +++ b/internal-packages/run-engine/src/run-queue/tests/releaseConcurrency.test.ts @@ -81,7 +81,7 @@ describe("RunQueue.releaseConcurrency", () => { expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); //release the concurrency - await queue.releaseConcurrency(authenticatedEnvProd.organization.id, messageProd.runId); + await queue.releaseAllConcurrency(authenticatedEnvProd.organization.id, messageProd.runId); //concurrencies expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( @@ -137,7 +137,7 @@ describe("RunQueue.releaseConcurrency", () => { expect(await queue.currentConcurrencyOfEnvironment(authenticatedEnvProd)).toBe(1); //release the concurrency - await queue.releaseConcurrency(authenticatedEnvProd.organization.id, "r1235"); + await queue.releaseAllConcurrency(authenticatedEnvProd.organization.id, "r1235"); //concurrencies expect(await queue.currentConcurrencyOfQueue(authenticatedEnvProd, messageProd.queue)).toBe( diff --git a/internal-packages/testcontainers/src/setup.ts b/internal-packages/testcontainers/src/setup.ts index a51e24eadd..b77663f062 100644 --- a/internal-packages/testcontainers/src/setup.ts +++ b/internal-packages/testcontainers/src/setup.ts @@ -69,7 +69,11 @@ export async function setupBackgroundWorker( environment: AuthenticatedEnvironment, taskIdentifier: string | string[], machineConfig?: MachineConfig, - retryOptions?: RetryOptions + retryOptions?: RetryOptions, + queueOptions?: { + releaseConcurrencyOnWaitpoint?: boolean; + concurrencyLimit?: number | null; + } ) { const worker = await prisma.backgroundWorker.create({ data: { @@ -115,10 +119,17 @@ export async function setupBackgroundWorker( data: { friendlyId: generateFriendlyId("queue"), name: queueName, - concurrencyLimit: 10, + concurrencyLimit: + typeof queueOptions?.concurrencyLimit === "undefined" + ? 10 + : queueOptions.concurrencyLimit, runtimeEnvironmentId: worker.runtimeEnvironmentId, projectId: worker.projectId, type: "VIRTUAL", + releaseConcurrencyOnWaitpoint: + typeof queueOptions?.releaseConcurrencyOnWaitpoint === "boolean" + ? queueOptions.releaseConcurrencyOnWaitpoint + : undefined, }, }); } diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index ff2028f7c3..48be14e602 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -119,6 +119,7 @@ export const TriggerTaskRequestBody = z.object({ test: z.boolean().optional(), ttl: z.string().or(z.number().nonnegative().int()).optional(), priority: z.number().optional(), + releaseConcurrency: z.boolean().optional(), }) .optional(), }); @@ -956,6 +957,9 @@ export const WaitForDurationRequestBody = z.object({ * This means after that time if you pass the same idempotency key again, you will get a new waitpoint. */ idempotencyKeyTTL: z.string().optional(), + + releaseConcurrency: z.boolean().optional(), + date: z.coerce.date(), }); export type WaitForDurationRequestBody = z.infer; diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index 921712ec3e..7a4f279151 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -824,8 +824,16 @@ export type TriggerOptions = { version?: string; }; -export type TriggerAndWaitOptions = Omit; - +export type TriggerAndWaitOptions = Omit & { + /** + * If set to true, this will cause the waitpoint to release the current run from the queue's concurrency. + * + * This is useful if you want to allow other runs to execute while the child task is executing + * + * @default false + */ + releaseConcurrency?: boolean; +}; export type BatchTriggerOptions = { /** * If no idempotencyKey is set on an individual item in the batch, it will use this key on each item + the array index. diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index d2e083aba3..6d4b4606d5 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -1345,6 +1345,7 @@ async function triggerAndWait_internal { logger.log("Hello, world from the parent", { payload }); - await childTask.triggerAndWait({ message: "Hello, world!" }); + await childTask.triggerAndWait( + { message: "Hello, world!" }, + { + releaseConcurrency: true, + } + ); }, }); diff --git a/references/hello-world/src/trigger/waits.ts b/references/hello-world/src/trigger/waits.ts index 0749cde17d..f3a84a7a52 100644 --- a/references/hello-world/src/trigger/waits.ts +++ b/references/hello-world/src/trigger/waits.ts @@ -73,7 +73,12 @@ export const waitForDuration = task({ }) => { const idempotency = idempotencyKey ? await idempotencyKeys.create(idempotencyKey) : undefined; - await wait.for({ seconds: duration, idempotencyKey: idempotency, idempotencyKeyTTL }); + await wait.for({ + seconds: duration, + idempotencyKey: idempotency, + idempotencyKeyTTL, + releaseConcurrency: true, + }); await wait.until({ date: new Date(Date.now() + duration * 1000) }); await retry.fetch("https://example.com/404", { method: "GET" }); From e5ea9cb2d7acd27d3c8deef1a2c5c083d8b46ee0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 19 Mar 2025 16:51:04 +0000 Subject: [PATCH 37/38] move the release concurrency queue into the release concurrency system, make it disabled by default, configure the run engine in the webapp with env vars --- apps/webapp/app/env.server.ts | 7 ++ apps/webapp/app/v3/runEngine.server.ts | 21 +++- .../run-engine/src/engine/index.ts | 104 +++++++++--------- .../systems/releaseConcurrencySystem.ts | 64 ++++++++++- .../run-engine/src/engine/systems/systems.ts | 6 - .../engine/tests/releaseConcurrency.test.ts | 12 +- .../run-engine/src/engine/types.ts | 1 + 7 files changed, 146 insertions(+), 69 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 4ff67c0439..e6c209c814 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -565,6 +565,13 @@ const EnvironmentSchema = z.object({ RUN_ENGINE_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"), RUN_ENGINE_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"), + RUN_ENGINE_RELEASE_CONCURRENCY_ENABLED: z.string().default("0"), + RUN_ENGINE_RELEASE_CONCURRENCY_MAX_TOKENS_RATIO: z.coerce.number().default(1), + 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), + RUN_ENGINE_RELEASE_CONCURRENCY_BATCH_SIZE: z.coerce.number().int().default(10), + /** How long should the presence ttl last */ DEV_PRESENCE_TTL_MS: z.coerce.number().int().default(30_000), DEV_PRESENCE_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000), diff --git a/apps/webapp/app/v3/runEngine.server.ts b/apps/webapp/app/v3/runEngine.server.ts index 382302ff16..4c70511e02 100644 --- a/apps/webapp/app/v3/runEngine.server.ts +++ b/apps/webapp/app/v3/runEngine.server.ts @@ -1,10 +1,10 @@ import { RunEngine } from "@internal/run-engine"; +import { defaultMachine } from "@trigger.dev/platform/v3"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { tracer } from "./tracer.server"; import { singleton } from "~/utils/singleton"; -import { defaultMachine, machines } from "@trigger.dev/platform/v3"; import { allMachines } from "./machinePresets.server"; +import { tracer } from "./tracer.server"; export const engine = singleton("RunEngine", createRunEngine); @@ -73,6 +73,23 @@ function createRunEngine() { EXECUTING: env.RUN_ENGINE_TIMEOUT_EXECUTING, EXECUTING_WITH_WAITPOINTS: env.RUN_ENGINE_TIMEOUT_EXECUTING_WITH_WAITPOINTS, }, + releaseConcurrency: { + disabled: env.RUN_ENGINE_RELEASE_CONCURRENCY_ENABLED === "0", + maxTokensRatio: env.RUN_ENGINE_RELEASE_CONCURRENCY_MAX_TOKENS_RATIO, + maxRetries: env.RUN_ENGINE_RELEASE_CONCURRENCY_MAX_RETRIES, + consumersCount: env.RUN_ENGINE_RELEASE_CONCURRENCY_CONSUMERS_COUNT, + pollInterval: env.RUN_ENGINE_RELEASE_CONCURRENCY_POLL_INTERVAL, + batchSize: env.RUN_ENGINE_RELEASE_CONCURRENCY_BATCH_SIZE, + redis: { + keyPrefix: "engine:", + port: env.RUN_ENGINE_RUN_QUEUE_REDIS_PORT ?? undefined, + host: env.RUN_ENGINE_RUN_QUEUE_REDIS_HOST ?? undefined, + username: env.RUN_ENGINE_RUN_QUEUE_REDIS_USERNAME ?? undefined, + password: env.RUN_ENGINE_RUN_QUEUE_REDIS_PASSWORD ?? undefined, + enableAutoPipelining: true, + ...(env.RUN_ENGINE_RUN_QUEUE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), + }, + }, }); return engine; diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 210a3b766b..1e5a949349 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -35,7 +35,6 @@ import { } from "./errors.js"; import { EventBus, EventBusEvents } from "./eventBus.js"; import { RunLocker } from "./locking.js"; -import { ReleaseConcurrencyTokenBucketQueue } from "./releaseConcurrencyTokenBucketQueue.js"; import { BatchSystem } from "./systems/batchSystem.js"; import { CheckpointSystem } from "./systems/checkpointSystem.js"; import { DelayedRunSystem } from "./systems/delayedRunSystem.js"; @@ -63,11 +62,6 @@ export class RunEngine { private logger = new Logger("RunEngine", "debug"); private tracer: Tracer; private heartbeatTimeouts: HeartbeatTimeouts; - releaseConcurrencyQueue: ReleaseConcurrencyTokenBucketQueue<{ - orgId: string; - projectId: string; - envId: string; - }>; eventBus: EventBus = new EventEmitter(); executionSnapshotSystem: ExecutionSnapshotSystem; runAttemptSystem: RunAttemptSystem; @@ -184,51 +178,6 @@ export class RunEngine { ...(options.heartbeatTimeoutsMs ?? {}), }; - // Initialize the ReleaseConcurrencyQueue - this.releaseConcurrencyQueue = new ReleaseConcurrencyTokenBucketQueue({ - redis: { - ...options.queue.redis, // Use base queue redis options - ...options.releaseConcurrency?.redis, // Allow overrides - keyPrefix: `${options.queue.redis.keyPrefix ?? ""}release-concurrency:`, - }, - retry: { - maxRetries: options.releaseConcurrency?.maxRetries ?? 5, - backoff: { - minDelay: options.releaseConcurrency?.backoff?.minDelay ?? 1000, - maxDelay: options.releaseConcurrency?.backoff?.maxDelay ?? 10000, - factor: options.releaseConcurrency?.backoff?.factor ?? 2, - }, - }, - consumersCount: options.releaseConcurrency?.consumersCount ?? 1, - pollInterval: options.releaseConcurrency?.pollInterval ?? 1000, - batchSize: options.releaseConcurrency?.batchSize ?? 10, - executor: async (descriptor, snapshotId) => { - 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, - }); - const resources: SystemResources = { prisma: this.prisma, worker: this.worker, @@ -237,11 +186,60 @@ export class RunEngine { tracer: this.tracer, runLock: this.runLock, runQueue: this.runQueue, - releaseConcurrencyQueue: this.releaseConcurrencyQueue, }; this.releaseConcurrencySystem = new ReleaseConcurrencySystem({ resources, + queueOptions: + typeof options.releaseConcurrency?.disabled === "boolean" && + options.releaseConcurrency.disabled + ? undefined + : { + redis: { + ...options.queue.redis, // Use base queue redis options + ...options.releaseConcurrency?.redis, // Allow overrides + keyPrefix: `${options.queue.redis.keyPrefix ?? ""}release-concurrency:`, + }, + retry: { + maxRetries: options.releaseConcurrency?.maxRetries ?? 5, + backoff: { + minDelay: options.releaseConcurrency?.backoff?.minDelay ?? 1000, + maxDelay: options.releaseConcurrency?.backoff?.maxDelay ?? 10000, + factor: options.releaseConcurrency?.backoff?.factor ?? 2, + }, + }, + consumersCount: options.releaseConcurrency?.consumersCount ?? 1, + pollInterval: options.releaseConcurrency?.pollInterval ?? 1000, + batchSize: options.releaseConcurrency?.batchSize ?? 10, + executor: async (descriptor, snapshotId) => { + 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, + }, }); this.executionSnapshotSystem = new ExecutionSnapshotSystem({ @@ -1213,7 +1211,7 @@ export class RunEngine { async quit() { try { //stop the run queue - await this.releaseConcurrencyQueue.quit(); + await this.releaseConcurrencySystem.quit(); await this.runQueue.quit(); await this.worker.stop(); await this.runLock.quit(); diff --git a/internal-packages/run-engine/src/engine/systems/releaseConcurrencySystem.ts b/internal-packages/run-engine/src/engine/systems/releaseConcurrencySystem.ts index cf29e115c9..bac9be1412 100644 --- a/internal-packages/run-engine/src/engine/systems/releaseConcurrencySystem.ts +++ b/internal-packages/run-engine/src/engine/systems/releaseConcurrencySystem.ts @@ -3,6 +3,10 @@ import { SystemResources } from "./systems.js"; import { getLatestExecutionSnapshot } from "./executionSnapshotSystem.js"; import { canReleaseConcurrency } from "../statuses.js"; import { z } from "zod"; +import { + ReleaseConcurrencyQueueOptions, + ReleaseConcurrencyTokenBucketQueue, +} from "../releaseConcurrencyTokenBucketQueue.js"; const ReleaseConcurrencyMetadata = z.object({ releaseConcurrency: z.boolean().optional(), @@ -12,17 +16,65 @@ type ReleaseConcurrencyMetadata = z.infer; export type ReleaseConcurrencySystemOptions = { resources: SystemResources; + queueOptions?: ReleaseConcurrencyQueueOptions<{ + orgId: string; + projectId: string; + envId: string; + }>; }; export class ReleaseConcurrencySystem { private readonly $: SystemResources; + releaseConcurrencyQueue?: ReleaseConcurrencyTokenBucketQueue<{ + orgId: string; + projectId: string; + envId: string; + }>; constructor(private readonly options: ReleaseConcurrencySystemOptions) { this.$ = options.resources; + + if (options.queueOptions) { + this.releaseConcurrencyQueue = new ReleaseConcurrencyTokenBucketQueue(options.queueOptions); + } + } + + public async consumeToken( + descriptor: { orgId: string; projectId: string; envId: string }, + releaserId: string + ) { + if (!this.releaseConcurrencyQueue) { + return; + } + + await this.releaseConcurrencyQueue.consumeToken(descriptor, releaserId); + } + + public async returnToken( + descriptor: { orgId: string; projectId: string; envId: string }, + releaserId: string + ) { + if (!this.releaseConcurrencyQueue) { + return; + } + + await this.releaseConcurrencyQueue.returnToken(descriptor, releaserId); + } + + public async quit() { + if (!this.releaseConcurrencyQueue) { + return; + } + + await this.releaseConcurrencyQueue.quit(); } public async checkpointCreatedOnEnvironment(environment: RuntimeEnvironment) { - await this.$.releaseConcurrencyQueue.refillTokens( + if (!this.releaseConcurrencyQueue) { + return; + } + + await this.releaseConcurrencyQueue.refillTokens( { orgId: environment.organizationId, projectId: environment.projectId, @@ -33,12 +85,16 @@ export class ReleaseConcurrencySystem { } public async releaseConcurrencyForSnapshot(snapshot: TaskRunExecutionSnapshot) { + if (!this.releaseConcurrencyQueue) { + return; + } + // Go ahead and release concurrency immediately if the run is in a development environment if (snapshot.environmentType === "DEVELOPMENT") { return await this.executeReleaseConcurrencyForSnapshot(snapshot.id); } - await this.$.releaseConcurrencyQueue.attemptToRelease( + await this.releaseConcurrencyQueue.attemptToRelease( { orgId: snapshot.organizationId, projectId: snapshot.projectId, @@ -49,6 +105,10 @@ export class ReleaseConcurrencySystem { } public async executeReleaseConcurrencyForSnapshot(snapshotId: string) { + if (!this.releaseConcurrencyQueue) { + return; + } + this.$.logger.debug("Executing released concurrency", { snapshotId, }); diff --git a/internal-packages/run-engine/src/engine/systems/systems.ts b/internal-packages/run-engine/src/engine/systems/systems.ts index 8b9d762173..85ccb014ee 100644 --- a/internal-packages/run-engine/src/engine/systems/systems.ts +++ b/internal-packages/run-engine/src/engine/systems/systems.ts @@ -5,7 +5,6 @@ import { RunQueue } from "../../run-queue/index.js"; import { EventBus } from "../eventBus.js"; import { RunLocker } from "../locking.js"; import { EngineWorker } from "../types.js"; -import { ReleaseConcurrencyTokenBucketQueue } from "../releaseConcurrencyTokenBucketQueue.js"; export type SystemResources = { prisma: PrismaClient; @@ -15,9 +14,4 @@ export type SystemResources = { tracer: Tracer; runLock: RunLocker; runQueue: RunQueue; - releaseConcurrencyQueue: ReleaseConcurrencyTokenBucketQueue<{ - orgId: string; - projectId: string; - envId: string; - }>; }; diff --git a/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts b/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts index d975351456..27d8e07172 100644 --- a/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts +++ b/internal-packages/run-engine/src/engine/tests/releaseConcurrency.test.ts @@ -673,7 +673,7 @@ describe("RunEngine Releasing Concurrency", () => { projectId: authenticatedEnvironment.projectId, }); - await engine.releaseConcurrencyQueue.consumeToken( + await engine.releaseConcurrencySystem.consumeToken( { orgId: authenticatedEnvironment.organizationId, projectId: authenticatedEnvironment.projectId, @@ -708,7 +708,7 @@ describe("RunEngine Releasing Concurrency", () => { expect(envConcurrencyAfter).toBe(1); // Now we return the token so the concurrency can be released - await engine.releaseConcurrencyQueue.returnToken( + await engine.releaseConcurrencySystem.returnToken( { orgId: authenticatedEnvironment.organizationId, projectId: authenticatedEnvironment.projectId, @@ -835,7 +835,7 @@ describe("RunEngine Releasing Concurrency", () => { projectId: authenticatedEnvironment.projectId, }); - await engine.releaseConcurrencyQueue.consumeToken( + await engine.releaseConcurrencySystem.consumeToken( { orgId: authenticatedEnvironment.organizationId, projectId: authenticatedEnvironment.projectId, @@ -890,7 +890,7 @@ describe("RunEngine Releasing Concurrency", () => { expect(snapshot.executionStatus).toBe("SUSPENDED"); // Now we return the token so the concurrency can be released - await engine.releaseConcurrencyQueue.returnToken( + await engine.releaseConcurrencySystem.returnToken( { orgId: authenticatedEnvironment.organizationId, projectId: authenticatedEnvironment.projectId, @@ -1017,7 +1017,7 @@ describe("RunEngine Releasing Concurrency", () => { projectId: authenticatedEnvironment.projectId, }); - await engine.releaseConcurrencyQueue.consumeToken( + await engine.releaseConcurrencySystem.consumeToken( { orgId: authenticatedEnvironment.organizationId, projectId: authenticatedEnvironment.projectId, @@ -1063,7 +1063,7 @@ describe("RunEngine Releasing Concurrency", () => { expect(executionDataAfter?.snapshot.executionStatus).toBe("EXECUTING"); // Now we return the token so the concurrency can be released - await engine.releaseConcurrencyQueue.returnToken( + await engine.releaseConcurrencySystem.returnToken( { orgId: authenticatedEnvironment.organizationId, projectId: authenticatedEnvironment.projectId, diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index f823c80b13..59274daf89 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -37,6 +37,7 @@ export type RunEngineOptions = { queueRunsWaitingForWorkerBatchSize?: number; tracer: Tracer; releaseConcurrency?: { + disabled?: boolean; maxTokensRatio?: number; redis?: Partial; maxRetries?: number; From 38e188749575f327dbd504d114d88e0cc080cc9c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 19 Mar 2025 17:36:03 +0000 Subject: [PATCH 38/38] Fixed failing redis worker tests --- .../redis-worker/src/worker.test.ts | 168 ++++++++---------- 1 file changed, 76 insertions(+), 92 deletions(-) diff --git a/internal-packages/redis-worker/src/worker.test.ts b/internal-packages/redis-worker/src/worker.test.ts index 2a138b49a0..1768f39107 100644 --- a/internal-packages/redis-worker/src/worker.test.ts +++ b/internal-packages/redis-worker/src/worker.test.ts @@ -36,25 +36,21 @@ describe("Worker", () => { logger: new Logger("test", "log"), }).start(); - try { - // Enqueue 10 items - for (let i = 0; i < 10; i++) { - await worker.enqueue({ - id: `item-${i}`, - job: "testJob", - payload: { value: i }, - visibilityTimeoutMs: 5000, - }); - } + // Enqueue 10 items + for (let i = 0; i < 10; i++) { + await worker.enqueue({ + id: `item-${i}`, + job: "testJob", + payload: { value: i }, + visibilityTimeoutMs: 5000, + }); + } - // Wait for items to be processed - await new Promise((resolve) => setTimeout(resolve, 2000)); + // Wait for items to be processed + await new Promise((resolve) => setTimeout(resolve, 2000)); - expect(processedItems.length).toBe(10); - expect(new Set(processedItems).size).toBe(10); // Ensure all items were processed uniquely - } finally { - worker.stop(); - } + expect(processedItems.length).toBe(10); + expect(new Set(processedItems).size).toBe(10); // Ensure all items were processed uniquely }); redisTest( @@ -97,25 +93,21 @@ describe("Worker", () => { logger: new Logger("test", "error"), }).start(); - try { - // Enqueue 10 items - for (let i = 0; i < 10; i++) { - await worker.enqueue({ - id: `item-${i}`, - job: "testJob", - payload: { value: i }, - visibilityTimeoutMs: 5000, - }); - } + // Enqueue 10 items + for (let i = 0; i < 10; i++) { + await worker.enqueue({ + id: `item-${i}`, + job: "testJob", + payload: { value: i }, + visibilityTimeoutMs: 5000, + }); + } - // Wait for items to be processed - await new Promise((resolve) => setTimeout(resolve, 500)); + // Wait for items to be processed + await new Promise((resolve) => setTimeout(resolve, 500)); - expect(processedItems.length).toBe(10); - expect(new Set(processedItems).size).toBe(10); // Ensure all items were processed uniquely - } finally { - worker.stop(); - } + expect(processedItems.length).toBe(10); + expect(new Set(processedItems).size).toBe(10); // Ensure all items were processed uniquely } ); @@ -156,33 +148,29 @@ describe("Worker", () => { logger: new Logger("test", "error"), }).start(); - try { - // Enqueue the item that will permanently fail - await worker.enqueue({ - id: failedItemId, - job: "testJob", - payload: { value: 999 }, - }); - - // Enqueue a normal item - await worker.enqueue({ - id: "normal-item", - job: "testJob", - payload: { value: 1 }, - }); - - // Wait for items to be processed and retried - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Check that the normal item was processed - expect(processedItems).toEqual([1]); - - // Check that the failed item is in the DLQ - const dlqSize = await worker.queue.sizeOfDeadLetterQueue(); - expect(dlqSize).toBe(1); - } finally { - worker.stop(); - } + // Enqueue the item that will permanently fail + await worker.enqueue({ + id: failedItemId, + job: "testJob", + payload: { value: 999 }, + }); + + // Enqueue a normal item + await worker.enqueue({ + id: "normal-item", + job: "testJob", + payload: { value: 1 }, + }); + + // Wait for items to be processed and retried + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Check that the normal item was processed + expect(processedItems).toEqual([1]); + + // Check that the failed item is in the DLQ + const dlqSize = await worker.queue.sizeOfDeadLetterQueue(); + expect(dlqSize).toBe(1); } ); @@ -225,45 +213,41 @@ describe("Worker", () => { logger: new Logger("test", "error"), }).start(); - try { - // Enqueue the item that will fail 3 times - await worker.enqueue({ - id: failedItemId, - job: "testJob", - payload: { value: 999 }, - }); + // Enqueue the item that will fail 3 times + await worker.enqueue({ + id: failedItemId, + job: "testJob", + payload: { value: 999 }, + }); - // Wait for the item to be processed and moved to DLQ - await new Promise((resolve) => setTimeout(resolve, 1000)); + // Wait for the item to be processed and moved to DLQ + await new Promise((resolve) => setTimeout(resolve, 1000)); - // Check that the item is in the DLQ - let dlqSize = await worker.queue.sizeOfDeadLetterQueue(); - expect(dlqSize).toBe(1); + // Check that the item is in the DLQ + let dlqSize = await worker.queue.sizeOfDeadLetterQueue(); + expect(dlqSize).toBe(1); - // Create a Redis client to publish the redrive message - const redisClient = createRedisClient({ - host: redisContainer.getHost(), - port: redisContainer.getPort(), - password: redisContainer.getPassword(), - }); + // Create a Redis client to publish the redrive message + const redisClient = createRedisClient({ + host: redisContainer.getHost(), + port: redisContainer.getPort(), + password: redisContainer.getPassword(), + }); - // Publish redrive message - await redisClient.publish("test-worker:redrive", JSON.stringify({ id: failedItemId })); + // Publish redrive message + await redisClient.publish("test-worker:redrive", JSON.stringify({ id: failedItemId })); - // Wait for the item to be redrived and processed - await new Promise((resolve) => setTimeout(resolve, 1000)); + // Wait for the item to be redrived and processed + await new Promise((resolve) => setTimeout(resolve, 1000)); - // Check that the item was processed successfully - expect(processedItems).toEqual([999]); + // Check that the item was processed successfully + expect(processedItems).toEqual([999]); - // Check that the DLQ is now empty - dlqSize = await worker.queue.sizeOfDeadLetterQueue(); - expect(dlqSize).toBe(0); + // Check that the DLQ is now empty + dlqSize = await worker.queue.sizeOfDeadLetterQueue(); + expect(dlqSize).toBe(0); - await redisClient.quit(); - } finally { - worker.stop(); - } + await redisClient.quit(); } ); });