* Converge concurrent data migration stamps When multiple hosts boot against the same database, let a runner accept a failed ledger insert only after confirming another runner committed the exact migration stamp. Preserve unrelated stamp failures and cover both outcomes with concurrent regressions. * Use atomic conflict handling for migration stamps Resolve duplicate stamp races in one SQLite statement, avoiding a cross-session verification read. Exercise the behavior against real libSQL and align the changeset with repository conventions. ---------
Unified · Split
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Prevent concurrent SQLite data-migration runners from failing when another runner commits the same ledger stamp first. | ||
| @@ -1,4 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { createClient } from "@libsql/client"; | ||
| 2 | import { Effect, Predicate } from "effect"; | 3 | import { Effect, Predicate } from "effect"; |
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| @@ -76,6 +77,66 @@ describe("runSqliteDataMigrations", () => { · 71 unmodified lines | |||
| 76 | }), | 77 | }), |
| 77 | ); | 78 | ); |
| 78 | 79 | ||
| 80 | it.effect("converges when concurrent runners stamp the same migration", () => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const client = createClient({ url: ":memory:" }); | ||
| 83 | let bodiesStarted = 0; | ||
| 84 | let releaseBodies: (() => void) | undefined; | ||
| 85 | // Hold both bodies until both runners have read the ledger as empty. | ||
| 86 | const bothBodiesStarted = new Promise<void>((resolve) => { | ||
| 87 | releaseBodies = resolve; | ||
| 88 | }); | ||
| 89 | const migration: SqliteDataMigration = { | ||
| 90 | name: "2026-06-05-concurrent", | ||
| 91 | run: () => | ||
| 92 | Effect.promise(() => { | ||
| 93 | bodiesStarted++; | ||
| 94 | if (bodiesStarted === 2) releaseBodies?.(); | ||
| 95 | return bothBodiesStarted; | ||
| 96 | }), | ||
| 97 | }; | ||
| 98 | |||
| 99 | const results = yield* Effect.all( | ||
| 100 | [ | ||
| 101 | runSqliteDataMigrations(client, [migration]), | ||
| 102 | runSqliteDataMigrations(client, [migration]), | ||
| 103 | ], | ||
| 104 | { concurrency: "unbounded" }, | ||
| 105 | ); | ||
| 106 | |||
| 107 | expect(results).toEqual([["2026-06-05-concurrent"], ["2026-06-05-concurrent"]]); | ||
| 108 | expect(bodiesStarted).toBe(2); | ||
| 109 | const stamps = yield* Effect.promise(() => | ||
| 110 | client.execute("SELECT name FROM data_migration ORDER BY name"), | ||
| 111 | ); | ||
| 112 | expect(stamps.rows.map((row) => row.name)).toEqual(["2026-06-05-concurrent"]); | ||
| 113 | client.close(); | ||
| 114 | }), | ||
| 115 | ); | ||
| 116 | |||
| 117 | it.effect("surfaces non-conflict stamp failures", () => | ||
| 118 | Effect.gen(function* () { | ||
| 119 | const client: SqliteDataMigrationClient = { | ||
| 120 | execute: (stmt) => { | ||
| 121 | const sql = typeof stmt === "string" ? stmt : stmt.sql; | ||
| 122 | if (typeof stmt === "object" && sql.startsWith("INSERT INTO data_migration")) { | ||
| 123 | // oxlint-disable-next-line executor/no-promise-reject -- simulates a storage-driver rejection at the adapter boundary under test | ||
| 124 | return Promise.reject("disk full"); | ||
| 125 | } | ||
| 126 | return Promise.resolve({ rows: [] }); | ||
| 127 | }, | ||
| 128 | }; | ||
| 129 | const migration = migrationSpy("2026-06-05-unstamped"); | ||
| 130 | |||
| 131 | const failure = yield* runSqliteDataMigrations(client, [migration.migration]).pipe( | ||
| 132 | Effect.flip, | ||
| 133 | ); | ||
| 134 | |||
| 135 | expect(Predicate.isTagged(failure, "DataMigrationError")).toBe(true); | ||
| 136 | expect((failure as DataMigrationError).cause).toBe("disk full"); | ||
| 137 | }), | ||
| 138 | ); | ||
| 139 | |||
| 79 | it.effect("a failing migration leaves no stamp and surfaces the failure", () => | 140 | it.effect("a failing migration leaves no stamp and surfaces the failure", () => |
| 80 | Effect.gen(function* () { | 141 | Effect.gen(function* () { |
| 81 | const { client, stamps } = makeFakeClient([]); | 142 | const { client, stamps } = makeFakeClient([]); |
| @@ -1,9 +1,9 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Stamped data-migration ledger for the libSQL-backed apps (local boot, | 2 | // Stamped data-migration ledger for the SQLite-backed hosts (local, |
| 3 | // selfhost boot). Cloud runs schema + data migrations through its drizzle | 3 | // selfhost, Cloudflare D1). PostgreSQL cloud runs schema + data migrations |
| 4 | // chain out-of-band; the local apps have no operator, so their migrations | 4 | // through its drizzle chain out-of-band; the SQLite hosts run them at boot — |
| 5 | // run at boot — and before this ledger existed, each one re-scanned its | 5 | // and before this ledger existed, each one re-scanned its tables on every |
| 6 | // tables on every startup to decide "did I already run?" by data shape. | 6 | // startup to decide "did I already run?" by data shape. |
| 7 | // That accumulates (N migrations = N full-table scans per boot, forever) | 7 | // That accumulates (N migrations = N full-table scans per boot, forever) |
| 8 | // and makes idempotence a per-migration proof obligation. | 8 | // and makes idempotence a per-migration proof obligation. |
| 9 | // | 9 | // |
| @@ -112,10 +112,16 @@ export const runSqliteDataMigrations = ( · 102 unmodified lines | |||
| 112 | for (const migration of migrations) { | 112 | for (const migration of migrations) { |
| 113 | if (completed.has(migration.name)) continue; | 113 | if (completed.has(migration.name)) continue; |
| 114 | yield* migration.run(client); | 114 | yield* migration.run(client); |
| 115 | // Multiple hosts can boot against the same database and observe the same | ||
| 116 | // migration as pending. Their idempotent bodies may both finish, but only | ||
| 117 | // one ledger insert can win. Ignore only a conflict on this exact stamp; | ||
| 118 | // every other ledger failure still fails the boot. | ||
| 115 | yield* execute( | 119 | yield* execute( |
| 116 | client, | 120 | client, |
| 117 | { | 121 | { |
| 118 | sql: `INSERT INTO ${LEDGER_TABLE} (name, time_completed) VALUES (?, ?)`, | 122 | sql: `INSERT INTO ${LEDGER_TABLE} (name, time_completed) |
| 123 | VALUES (?, ?) | ||
| 124 | ON CONFLICT(name) DO NOTHING`, | ||
| 119 | args: [migration.name, Date.now()], | 125 | args: [migration.name, Date.now()], |
| 120 | }, | 126 | }, |
| 121 | migration.name, | 127 | migration.name, |