From c9a3eb7c24689fbd04525ff2cb11342e9be73d29 Mon Sep 17 00:00:00 2001 From: Emilia <100027345+sovietspaceship@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:24:23 +0100 Subject: [PATCH] fix(core): Stop SQLite reusing durable scheduler row ids (no-changelog) Co-Authored-By: Claude Opus 5.5 --- docs/generated/sqlite-schema/scheduled_job.md | 2 +- .../generated/sqlite-schema/scheduled_task.md | 2 +- ...1792761455090-AutoincrementSchedulerIds.ts | 58 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/sqlite/1792761455090-AutoincrementSchedulerIds.ts diff --git a/docs/generated/sqlite-schema/scheduled_job.md b/docs/generated/sqlite-schema/scheduled_job.md index d1ebc711b2a..5032d93df6b 100644 --- a/docs/generated/sqlite-schema/scheduled_job.md +++ b/docs/generated/sqlite-schema/scheduled_job.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "scheduled_job" ("id" integer PRIMARY KEY NOT NULL, "name" varchar(255) NOT NULL, "taskType" varchar(128) NOT NULL, "payload" text NOT NULL DEFAULT ('{}'), "kind" varchar(16) NOT NULL, "cronExpression" varchar(255), "timezone" varchar(64), "intervalSeconds" integer, "fireAt" datetime(3), "enabled" boolean NOT NULL DEFAULT (true), "nextRunAt" datetime(3), "lastFiredAt" datetime(3), "maxAttempts" integer NOT NULL DEFAULT (1), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "recurrenceUnit" varchar(16), "recurrenceSize" int, "misfirePolicy" varchar(16) NOT NULL DEFAULT ('coalesce'), "misfireGraceSeconds" int NOT NULL DEFAULT (60), "ownerType" varchar(32) NOT NULL, "ownerId" varchar(255) NOT NULL, "ownerMemberId" varchar(36), "orphanedAt" datetime(3), "concurrencyLimit" int, "timeoutSeconds" int NOT NULL DEFAULT (300), "leaseSeconds" int, CONSTRAINT "CHK_scheduled_job_concurrencyLimit" CHECK (("concurrencyLimit" IS NULL OR ("concurrencyLimit" >= 1 AND "concurrencyLimit" <= 2147483647 AND CAST("concurrencyLimit" AS INTEGER) = "concurrencyLimit"))), CONSTRAINT "CHK_scheduled_job_timeoutSeconds" CHECK (("timeoutSeconds" > 0 AND "timeoutSeconds" <= 2147483 AND CAST("timeoutSeconds" AS INTEGER) = "timeoutSeconds")), CONSTRAINT "CHK_scheduled_job_leaseSeconds" CHECK (("leaseSeconds" > 0)), CONSTRAINT "CHK_scheduled_job_misfireGraceSeconds" CHECK (((((("misfireGraceSeconds" > 0)))))), CONSTRAINT "CHK_scheduled_job_recurrence_unit" CHECK ((((((("recurrenceUnit" IN ('hours', 'days', 'weeks', 'months')))))))), CONSTRAINT "CHK_scheduled_job_recurrence_size" CHECK ((((((("recurrenceSize" >= 2))))))), CONSTRAINT "CHK_scheduled_job_cron_expression" CHECK ((((((("kind" <> 'cron' OR "cronExpression" IS NOT NULL))))))), CONSTRAINT "CHK_scheduled_job_interval_seconds" CHECK ((((((("kind" <> 'interval' OR "intervalSeconds" IS NOT NULL))))))), CONSTRAINT "CHK_scheduled_job_fire_at" CHECK ((((((("kind" <> 'one_off' OR "fireAt" IS NOT NULL))))))), CONSTRAINT "CHK_scheduled_job_recurring_cron" CHECK (((((("kind" <> 'recurring_cron' OR ("cronExpression" IS NOT NULL AND "recurrenceUnit" IS NOT NULL AND "recurrenceSize" IS NOT NULL))))))), CONSTRAINT "CHK_scheduled_job_misfirePolicy" CHECK ((((("misfirePolicy" IN ('coalesce', 'skip', 'coalesce_owner')))))), CONSTRAINT "CHK_scheduled_job_kind" CHECK ("kind" IN ('cron', 'interval', 'one_off', 'recurring_cron', 'hold'))) +CREATE TABLE "scheduled_job" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(255) NOT NULL, "taskType" varchar(128) NOT NULL, "payload" text NOT NULL DEFAULT ('{}'), "kind" varchar(16) NOT NULL, "cronExpression" varchar(255), "timezone" varchar(64), "intervalSeconds" integer, "fireAt" datetime(3), "enabled" boolean NOT NULL DEFAULT (true), "nextRunAt" datetime(3), "lastFiredAt" datetime(3), "maxAttempts" integer NOT NULL DEFAULT (1), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "recurrenceUnit" varchar(16), "recurrenceSize" int, "misfirePolicy" varchar(16) NOT NULL DEFAULT ('coalesce'), "misfireGraceSeconds" int NOT NULL DEFAULT (60), "ownerType" varchar(32) NOT NULL, "ownerId" varchar(255) NOT NULL, "ownerMemberId" varchar(36), "orphanedAt" datetime(3), "concurrencyLimit" int, "timeoutSeconds" int NOT NULL DEFAULT (300), "leaseSeconds" int, CONSTRAINT "CHK_scheduled_job_concurrencyLimit" CHECK (("concurrencyLimit" IS NULL OR ("concurrencyLimit" >= 1 AND "concurrencyLimit" <= 2147483647 AND CAST("concurrencyLimit" AS INTEGER) = "concurrencyLimit"))), CONSTRAINT "CHK_scheduled_job_timeoutSeconds" CHECK (("timeoutSeconds" > 0 AND "timeoutSeconds" <= 2147483 AND CAST("timeoutSeconds" AS INTEGER) = "timeoutSeconds")), CONSTRAINT "CHK_scheduled_job_leaseSeconds" CHECK (("leaseSeconds" > 0)), CONSTRAINT "CHK_scheduled_job_misfireGraceSeconds" CHECK (((((("misfireGraceSeconds" > 0)))))), CONSTRAINT "CHK_scheduled_job_recurrence_unit" CHECK ((((((("recurrenceUnit" IN ('hours', 'days', 'weeks', 'months')))))))), CONSTRAINT "CHK_scheduled_job_recurrence_size" CHECK ((((((("recurrenceSize" >= 2))))))), CONSTRAINT "CHK_scheduled_job_cron_expression" CHECK ((((((("kind" <> 'cron' OR "cronExpression" IS NOT NULL))))))), CONSTRAINT "CHK_scheduled_job_interval_seconds" CHECK ((((((("kind" <> 'interval' OR "intervalSeconds" IS NOT NULL))))))), CONSTRAINT "CHK_scheduled_job_fire_at" CHECK ((((((("kind" <> 'one_off' OR "fireAt" IS NOT NULL))))))), CONSTRAINT "CHK_scheduled_job_recurring_cron" CHECK (((((("kind" <> 'recurring_cron' OR ("cronExpression" IS NOT NULL AND "recurrenceUnit" IS NOT NULL AND "recurrenceSize" IS NOT NULL))))))), CONSTRAINT "CHK_scheduled_job_misfirePolicy" CHECK ((((("misfirePolicy" IN ('coalesce', 'skip', 'coalesce_owner')))))), CONSTRAINT "CHK_scheduled_job_kind" CHECK ("kind" IN ('cron', 'interval', 'one_off', 'recurring_cron', 'hold'))) ``` diff --git a/docs/generated/sqlite-schema/scheduled_task.md b/docs/generated/sqlite-schema/scheduled_task.md index 054c0e5971a..50079a4d325 100644 --- a/docs/generated/sqlite-schema/scheduled_task.md +++ b/docs/generated/sqlite-schema/scheduled_task.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "scheduled_task" ("id" integer PRIMARY KEY NOT NULL, "jobId" integer NOT NULL, "taskType" varchar(128) NOT NULL, "payload" text NOT NULL DEFAULT ('{}'), "scheduledFor" datetime(3) NOT NULL, "runAt" datetime(3) NOT NULL, "status" varchar(16) NOT NULL DEFAULT ('pending'), "attempts" integer NOT NULL DEFAULT (0), "maxAttempts" integer NOT NULL DEFAULT (1), "claimedBy" varchar(255), "leaseExpiresAt" datetime(3), "leaseEpoch" integer NOT NULL DEFAULT (0), "startedAt" datetime(3), "finishedAt" datetime(3), "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "dispatchedAt" datetime(3), "missedAfter" datetime(3), "timeoutSeconds" int NOT NULL DEFAULT 300 CONSTRAINT "CHK_scheduled_task_timeoutSeconds" CHECK ("timeoutSeconds" > 0 AND "timeoutSeconds" <= 2147483 AND CAST("timeoutSeconds" AS INTEGER) = "timeoutSeconds"), "leaseSeconds" int CONSTRAINT "CHK_scheduled_task_leaseSeconds" CHECK ("leaseSeconds" > 0), "mode" varchar(16) NOT NULL DEFAULT 'once' CONSTRAINT "CHK_scheduled_task_mode" CHECK ("mode" IN ('once', 'hold')), "heldSince" datetime(3), CONSTRAINT "CHK_scheduled_task_running_lease" CHECK ("status" <> 'running' OR "leaseExpiresAt" IS NOT NULL), CONSTRAINT "CHK_scheduled_task_status" CHECK ("status" IN ('pending', 'running', 'succeeded', 'failed', 'missed', 'cancelled')), CONSTRAINT "FK_scheduled_task_jobId" FOREIGN KEY ("jobId") REFERENCES "scheduled_job" ("id") ON DELETE CASCADE) +CREATE TABLE "scheduled_task" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "jobId" integer NOT NULL, "taskType" varchar(128) NOT NULL, "payload" text NOT NULL DEFAULT ('{}'), "scheduledFor" datetime(3) NOT NULL, "runAt" datetime(3) NOT NULL, "status" varchar(16) NOT NULL DEFAULT ('pending'), "attempts" integer NOT NULL DEFAULT (0), "maxAttempts" integer NOT NULL DEFAULT (1), "claimedBy" varchar(255), "leaseExpiresAt" datetime(3), "leaseEpoch" integer NOT NULL DEFAULT (0), "startedAt" datetime(3), "finishedAt" datetime(3), "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "dispatchedAt" datetime(3), "missedAfter" datetime(3), "timeoutSeconds" int NOT NULL DEFAULT 300 CONSTRAINT "CHK_scheduled_task_timeoutSeconds" CHECK ("timeoutSeconds" > 0 AND "timeoutSeconds" <= 2147483 AND CAST("timeoutSeconds" AS INTEGER) = "timeoutSeconds"), "leaseSeconds" int CONSTRAINT "CHK_scheduled_task_leaseSeconds" CHECK ("leaseSeconds" > 0), "mode" varchar(16) NOT NULL DEFAULT 'once' CONSTRAINT "CHK_scheduled_task_mode" CHECK ("mode" IN ('once', 'hold')), "heldSince" datetime(3), CONSTRAINT "CHK_scheduled_task_running_lease" CHECK ("status" <> 'running' OR "leaseExpiresAt" IS NOT NULL), CONSTRAINT "CHK_scheduled_task_status" CHECK ("status" IN ('pending', 'running', 'succeeded', 'failed', 'missed', 'cancelled')), CONSTRAINT "FK_scheduled_task_jobId" FOREIGN KEY ("jobId") REFERENCES "scheduled_job" ("id") ON DELETE CASCADE) ``` diff --git a/packages/@n8n/db/src/migrations/sqlite/1792761455090-AutoincrementSchedulerIds.ts b/packages/@n8n/db/src/migrations/sqlite/1792761455090-AutoincrementSchedulerIds.ts new file mode 100644 index 00000000000..2e80d4ac65b --- /dev/null +++ b/packages/@n8n/db/src/migrations/sqlite/1792761455090-AutoincrementSchedulerIds.ts @@ -0,0 +1,58 @@ +import { UnexpectedError } from 'n8n-workflow'; + +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const tables = ['scheduled_job', 'scheduled_task']; +const rowidKey = '"id" integer PRIMARY KEY NOT NULL'; +const autoincrementKey = '"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL'; + +/** + * Rebuilds the scheduler tables with AUTOINCREMENT keys. A plain rowid table gives a + * new row the id of a deleted highest row, so a stale lease holder could match it. + */ +export class AutoincrementSchedulerIds1792761455090 implements ReversibleMigration { + // The rebuild drops scheduled_job. Disable foreign keys to preserve scheduled_task rows. + withFKsDisabled = true as const; + + async up(context: MigrationContext) { + for (const table of tables) await this.rebuild(context, table, rowidKey, autoincrementKey); + } + + async down(context: MigrationContext) { + for (const table of tables) await this.rebuild(context, table, autoincrementKey, rowidKey); + } + + private async rebuild( + { runQuery, escape, tablePrefix }: MigrationContext, + table: string, + fromKey: string, + toKey: string, + ) { + const name = `${tablePrefix}${table}`; + const tableName = escape.tableName(table); + const temporaryName = escape.tableName(`temporary_${table}`); + const [{ sql: definition }] = await runQuery>( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = :name", + { name }, + ); + const indexes = await runQuery>( + "SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = :name AND sql IS NOT NULL", + { name }, + ); + + const header = `CREATE TABLE ${tableName} (`; + if (!definition.startsWith(header) || definition.split(fromKey).length !== 2) { + throw new UnexpectedError(`Unexpected definition of table ${name}`, { + extra: { definition }, + }); + } + + await runQuery( + definition.replace(header, `CREATE TABLE ${temporaryName} (`).replace(fromKey, toKey), + ); + await runQuery(`INSERT INTO ${temporaryName} SELECT * FROM ${tableName}`); + await runQuery(`DROP TABLE ${tableName}`); + await runQuery(`ALTER TABLE ${temporaryName} RENAME TO ${tableName}`); + for (const { sql } of indexes) await runQuery(sql); + } +}