375 lines
14 KiB
TypeScript
Executable File
375 lines
14 KiB
TypeScript
Executable File
#!/usr/bin/env tsx
|
||
|
||
/**
|
||
* Recovery script for runs stuck in currentConcurrency with QUEUED execution status
|
||
*
|
||
* PROBLEM:
|
||
* During high database load, runs can get dequeued from Redis (added to currentConcurrency)
|
||
* but fail to update their execution status in the database. This leaves them stuck in an
|
||
* inconsistent state where they won't be re-dequeued because they're marked as "in progress"
|
||
* in Redis, but their database state still shows QUEUED.
|
||
*
|
||
* SOLUTION:
|
||
* This script identifies and recovers these stuck runs by:
|
||
* 1. Reading from the environment currentConcurrency Redis set
|
||
* 2. Checking which runs have QUEUED execution status (inconsistent state)
|
||
* 3. Re-adding them to their specific queue sorted sets
|
||
* 4. Removing them from the queue-specific currentConcurrency sets
|
||
* 5. Removing them from the environment-level currentConcurrency set
|
||
*
|
||
* SAFETY:
|
||
* - Dry-run mode when no write Redis URL is provided (read-only, no writes)
|
||
* - Uses separate Redis connections for reads and writes
|
||
* - Write connection only created when redisWriteUrl is provided
|
||
*
|
||
* ARGUMENTS:
|
||
* <environmentId> The Trigger.dev environment ID (e.g., env_abc123)
|
||
* <postgresUrl> PostgreSQL connection string
|
||
* <redisReadUrl> Redis connection string for reads (redis:// or rediss://)
|
||
* [redisWriteUrl] Optional Redis connection string for writes (omit for dry-run)
|
||
*
|
||
* USAGE:
|
||
* tsx scripts/recover-stuck-runs.ts <environmentId> <postgresUrl> <redisReadUrl> [redisWriteUrl]
|
||
*
|
||
* EXAMPLES:
|
||
*
|
||
* Dry-run mode (safe, no writes):
|
||
* tsx scripts/recover-stuck-runs.ts env_1234567890 \
|
||
* "postgresql://user:pass@localhost:5432/triggerdev" \
|
||
* "redis://readonly.example.com:6379"
|
||
*
|
||
* Execute mode (makes actual changes):
|
||
* tsx scripts/recover-stuck-runs.ts env_1234567890 \
|
||
* "postgresql://user:pass@localhost:5432/triggerdev" \
|
||
* "redis://readonly.example.com:6379" \
|
||
* "redis://writeonly.example.com:6379"
|
||
*/
|
||
|
||
import type { TaskRunExecutionStatus } from "@trigger.dev/database";
|
||
import { PrismaClient } from "@trigger.dev/database";
|
||
import { createRedisClient } from "@internal/redis";
|
||
|
||
interface StuckRun {
|
||
runId: string;
|
||
orgId: string;
|
||
projectId: string;
|
||
environmentId: string;
|
||
queue: string;
|
||
concurrencyKey: string | null;
|
||
executionStatus: TaskRunExecutionStatus;
|
||
snapshotCreatedAt: Date;
|
||
taskIdentifier: string;
|
||
}
|
||
|
||
interface RedisOperation {
|
||
type: "ZADD" | "SREM";
|
||
key: string;
|
||
args: (string | number)[];
|
||
description: string;
|
||
}
|
||
|
||
async function main() {
|
||
const [environmentId, postgresUrl, redisReadUrl, redisWriteUrl] = process.argv.slice(2);
|
||
|
||
if (!environmentId || !postgresUrl || !redisReadUrl) {
|
||
console.error(
|
||
"Usage: tsx scripts/recover-stuck-runs.ts <environmentId> <postgresUrl> <redisReadUrl> [redisWriteUrl]"
|
||
);
|
||
console.error("");
|
||
console.error("Dry-run mode when no redisWriteUrl is provided (read-only).");
|
||
console.error("Execute mode when redisWriteUrl is provided (makes actual changes).");
|
||
console.error("");
|
||
console.error("Example (dry-run):");
|
||
console.error(" tsx scripts/recover-stuck-runs.ts env_1234567890 \\");
|
||
console.error(' "postgresql://user:pass@localhost:5432/triggerdev" \\');
|
||
console.error(' "redis://readonly.example.com:6379"');
|
||
console.error("");
|
||
console.error("Example (execute):");
|
||
console.error(" tsx scripts/recover-stuck-runs.ts env_1234567890 \\");
|
||
console.error(' "postgresql://user:pass@localhost:5432/triggerdev" \\');
|
||
console.error(' "redis://readonly.example.com:6379" \\');
|
||
console.error(' "redis://writeonly.example.com:6379"');
|
||
process.exit(1);
|
||
}
|
||
|
||
const executeMode = !!redisWriteUrl;
|
||
|
||
if (executeMode) {
|
||
console.log("⚠️ EXECUTE MODE - Changes will be made to Redis\n");
|
||
} else {
|
||
console.log("🔍 DRY RUN MODE - No changes will be made to Redis\n");
|
||
}
|
||
|
||
console.log(`🔍 Scanning for stuck runs in environment: ${environmentId}`);
|
||
|
||
// Create Prisma client with the provided connection URL
|
||
const prisma = new PrismaClient({
|
||
datasources: {
|
||
db: {
|
||
url: postgresUrl,
|
||
},
|
||
},
|
||
});
|
||
|
||
try {
|
||
// Get environment details
|
||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||
where: { id: environmentId },
|
||
include: {
|
||
organization: true,
|
||
project: true,
|
||
},
|
||
});
|
||
|
||
if (!environment) {
|
||
console.error(`❌ Environment not found: ${environmentId}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(`📍 Environment: ${environment.slug} (${environment.type})`);
|
||
console.log(`📍 Organization: ${environment.organization.slug}`);
|
||
console.log(`📍 Project: ${environment.project.slug}`);
|
||
|
||
// Parse Redis read URL
|
||
const redisReadUrlObj = new URL(redisReadUrl);
|
||
const redisReadOptions = {
|
||
host: redisReadUrlObj.hostname,
|
||
port: parseInt(redisReadUrlObj.port || "6379"),
|
||
username: redisReadUrlObj.username || undefined,
|
||
password: redisReadUrlObj.password || undefined,
|
||
enableAutoPipelining: false,
|
||
...(redisReadUrlObj.protocol === "rediss:"
|
||
? {
|
||
tls: {
|
||
// If connecting via localhost tunnel to a remote Redis, disable cert verification
|
||
rejectUnauthorized: redisReadUrlObj.hostname === "localhost" ? false : true,
|
||
},
|
||
}
|
||
: {}),
|
||
};
|
||
|
||
// Create Redis read client
|
||
const redisRead = createRedisClient(redisReadOptions);
|
||
|
||
// Create Redis write client if redisWriteUrl is provided
|
||
let redisWrite = null;
|
||
if (redisWriteUrl) {
|
||
const redisWriteUrlObj = new URL(redisWriteUrl);
|
||
const redisWriteOptions = {
|
||
host: redisWriteUrlObj.hostname,
|
||
port: parseInt(redisWriteUrlObj.port || "6379"),
|
||
username: redisWriteUrlObj.username || undefined,
|
||
password: redisWriteUrlObj.password || undefined,
|
||
enableAutoPipelining: false,
|
||
...(redisWriteUrlObj.protocol === "rediss:"
|
||
? {
|
||
tls: {
|
||
// If connecting via localhost tunnel to a remote Redis, disable cert verification
|
||
rejectUnauthorized: redisWriteUrlObj.hostname === "localhost" ? false : true,
|
||
},
|
||
}
|
||
: {}),
|
||
};
|
||
redisWrite = createRedisClient(redisWriteOptions);
|
||
}
|
||
|
||
try {
|
||
// Build the Redis key for environment-level currentConcurrency set
|
||
// Format: engine:runqueue:{org:X}:proj:Y:env:Z:currentConcurrency
|
||
const envConcurrencyKey = `engine:runqueue:{org:${environment.organizationId}}:proj:${environment.projectId}:env:${environmentId}:currentConcurrency`;
|
||
|
||
console.log(`\n🔑 Checking Redis key: ${envConcurrencyKey}`);
|
||
|
||
// Get all run IDs in the environment's currentConcurrency set
|
||
const runIds = await redisRead.smembers(envConcurrencyKey);
|
||
|
||
if (runIds.length === 0) {
|
||
console.log(`✅ No runs in currentConcurrency set`);
|
||
return;
|
||
}
|
||
|
||
console.log(`📊 Found ${runIds.length} runs in currentConcurrency set`);
|
||
|
||
// Query database for latest snapshots and queue info of these runs.
|
||
// NOTE: raw join of TaskRunExecutionSnapshot to TaskRun, the one TaskRun read not behind
|
||
// RunStore (a join, not a by-id read, in an ops script). Revisit at table cutover.
|
||
const runInfo = await prisma.$queryRaw<
|
||
Array<{
|
||
runId: string;
|
||
executionStatus: TaskRunExecutionStatus;
|
||
snapshotCreatedAt: Date;
|
||
organizationId: string;
|
||
projectId: string;
|
||
environmentId: string;
|
||
taskIdentifier: string;
|
||
queue: string;
|
||
concurrencyKey: string | null;
|
||
}>
|
||
>`
|
||
SELECT DISTINCT ON (s."runId")
|
||
s."runId",
|
||
s."executionStatus",
|
||
s."createdAt" as "snapshotCreatedAt",
|
||
r."organizationId",
|
||
r."projectId",
|
||
r."runtimeEnvironmentId" as "environmentId",
|
||
r."taskIdentifier",
|
||
r."queue",
|
||
r."concurrencyKey"
|
||
FROM "TaskRunExecutionSnapshot" s
|
||
INNER JOIN "TaskRun" r ON r.id = s."runId"
|
||
WHERE s."runId" = ANY(${runIds})
|
||
AND s."isValid" = true
|
||
ORDER BY s."runId", s."createdAt" DESC
|
||
`;
|
||
|
||
const stuckRuns: StuckRun[] = [];
|
||
|
||
// Find runs with QUEUED execution status (inconsistent state)
|
||
for (const info of runInfo) {
|
||
if (info.executionStatus === "QUEUED") {
|
||
stuckRuns.push({
|
||
runId: info.runId,
|
||
orgId: info.organizationId,
|
||
projectId: info.projectId,
|
||
environmentId: info.environmentId,
|
||
queue: info.queue,
|
||
concurrencyKey: info.concurrencyKey,
|
||
executionStatus: info.executionStatus,
|
||
snapshotCreatedAt: info.snapshotCreatedAt,
|
||
taskIdentifier: info.taskIdentifier,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (stuckRuns.length === 0) {
|
||
console.log(`✅ No stuck runs found (all runs have progressed beyond QUEUED state)`);
|
||
return;
|
||
}
|
||
|
||
console.log(`\n⚠️ Found ${stuckRuns.length} stuck runs in QUEUED state:`);
|
||
console.log(`════════════════════════════════════════════════════════════════`);
|
||
|
||
for (const run of stuckRuns) {
|
||
const age = Date.now() - run.snapshotCreatedAt.getTime();
|
||
const ageMinutes = Math.floor(age / 1000 / 60);
|
||
console.log(` • Run: ${run.runId}`);
|
||
console.log(` Task: ${run.taskIdentifier}`);
|
||
console.log(` Queue: ${run.queue}`);
|
||
console.log(` Concurrency Key: ${run.concurrencyKey || "(none)"}`);
|
||
console.log(` Status: ${run.executionStatus}`);
|
||
console.log(` Stuck for: ${ageMinutes} minutes`);
|
||
console.log(` Snapshot created: ${run.snapshotCreatedAt.toISOString()}`);
|
||
console.log();
|
||
}
|
||
|
||
// Prepare recovery operations
|
||
console.log(
|
||
`\n⚡ ${executeMode ? "Executing" : "Planning"} recovery for ${stuckRuns.length} stuck runs`
|
||
);
|
||
console.log(`This will:`);
|
||
console.log(` 1. Add each run back to its specific queue sorted set`);
|
||
console.log(` 2. Remove each run from the queue-specific currentConcurrency set`);
|
||
console.log(` 3. Remove each run from the env-level currentConcurrency set`);
|
||
console.log();
|
||
|
||
let successCount = 0;
|
||
let failureCount = 0;
|
||
|
||
const currentTimestamp = Date.now();
|
||
|
||
for (const run of stuckRuns) {
|
||
try {
|
||
// Build queue key: engine:runqueue:{org:X}:proj:Y:env:Z:queue:QUEUENAME
|
||
// Build queue currentConcurrency key: engine:runqueue:{org:X}:proj:Y:env:Z:queue:QUEUENAME:currentConcurrency
|
||
const queueKey = run.concurrencyKey
|
||
? `engine:runqueue:{org:${run.orgId}}:proj:${run.projectId}:env:${run.environmentId}:queue:${run.queue}:ck:${run.concurrencyKey}`
|
||
: `engine:runqueue:{org:${run.orgId}}:proj:${run.projectId}:env:${run.environmentId}:queue:${run.queue}`;
|
||
|
||
const queueConcurrencyKey = `${queueKey}:currentConcurrency`;
|
||
|
||
const operations: RedisOperation[] = [
|
||
{
|
||
type: "ZADD",
|
||
key: queueKey,
|
||
args: [currentTimestamp, run.runId],
|
||
description: `Add run to queue sorted set with score ${currentTimestamp}`,
|
||
},
|
||
{
|
||
type: "SREM",
|
||
key: queueConcurrencyKey,
|
||
args: [run.runId],
|
||
description: `Remove run from queue currentConcurrency set`,
|
||
},
|
||
{
|
||
type: "SREM",
|
||
key: envConcurrencyKey,
|
||
args: [run.runId],
|
||
description: `Remove run from env currentConcurrency set`,
|
||
},
|
||
];
|
||
|
||
if (executeMode && redisWrite) {
|
||
// Execute operations using the write client
|
||
await redisWrite.zadd(queueKey, currentTimestamp, run.runId);
|
||
const removedFromQueue = await redisWrite.srem(queueConcurrencyKey, run.runId);
|
||
const removedFromEnv = await redisWrite.srem(envConcurrencyKey, run.runId);
|
||
|
||
console.log(` ✓ Recovered run ${run.runId} (${run.taskIdentifier})`);
|
||
if (removedFromQueue === 0) {
|
||
console.log(` ⚠ Run was not in queue currentConcurrency set`);
|
||
}
|
||
if (removedFromEnv === 0) {
|
||
console.log(` ⚠ Run was not in env currentConcurrency set`);
|
||
}
|
||
successCount++;
|
||
} else {
|
||
// Dry run - just show what would be done
|
||
console.log(` 📝 Would recover run ${run.runId} (${run.taskIdentifier}):`);
|
||
for (const op of operations) {
|
||
console.log(` ${op.type} ${op.key}`);
|
||
console.log(` Args: ${JSON.stringify(op.args)}`);
|
||
console.log(` (${op.description})`);
|
||
}
|
||
successCount++;
|
||
}
|
||
} catch (error) {
|
||
console.error(` ✗ Failed to recover run ${run.runId}:`, error);
|
||
failureCount++;
|
||
}
|
||
}
|
||
|
||
console.log(`\n═══════════════════════════════════════════════════════════════`);
|
||
if (executeMode) {
|
||
console.log(`✅ Recovery complete!`);
|
||
console.log(` Recovered: ${successCount}`);
|
||
console.log(` Failed: ${failureCount}`);
|
||
console.log();
|
||
console.log(`ℹ️ Note: The recovered runs should be automatically dequeued`);
|
||
console.log(` by the master queue consumers within a few seconds.`);
|
||
} else {
|
||
console.log(`📋 Dry run complete - no changes were made`);
|
||
console.log(` Would recover: ${successCount}`);
|
||
console.log(` Would fail: ${failureCount}`);
|
||
console.log();
|
||
console.log(`💡 To execute these changes, run again with a redisWriteUrl argument`);
|
||
}
|
||
} finally {
|
||
await redisRead.quit();
|
||
if (redisWrite) {
|
||
await redisWrite.quit();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ Error during recovery:", error);
|
||
throw error;
|
||
} finally {
|
||
await prisma.$disconnect();
|
||
}
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error("Fatal error:", error);
|
||
process.exit(1);
|
||
});
|