Skip to content

Commit e8ea62c

Browse files
jadchRhysSullivan
andauthored
Prevent duplicate migration-stamp failures during concurrent startup (#1454)
* 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. --------- Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent 69b0e64 commit e8ea62c

3 files changed

Lines changed: 78 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

packages/core/sdk/src/sqlite-data-migrations.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "@effect/vitest";
2+
import { createClient } from "@libsql/client";
23
import { Effect, Predicate } from "effect";
34

45
import {
@@ -76,6 +77,66 @@ describe("runSqliteDataMigrations", () => {
7677
}),
7778
);
7879

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+
79140
it.effect("a failing migration leaves no stamp and surfaces the failure", () =>
80141
Effect.gen(function* () {
81142
const { client, stamps } = makeFakeClient([]);

packages/core/sdk/src/sqlite-data-migrations.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// ---------------------------------------------------------------------------
2-
// Stamped data-migration ledger for the libSQL-backed apps (local boot,
3-
// selfhost boot). Cloud runs schema + data migrations through its drizzle
4-
// chain out-of-band; the local apps have no operator, so their migrations
5-
// run at boot — and before this ledger existed, each one re-scanned its
6-
// tables on every startup to decide "did I already run?" by data shape.
2+
// Stamped data-migration ledger for the SQLite-backed hosts (local,
3+
// selfhost, Cloudflare D1). PostgreSQL cloud runs schema + data migrations
4+
// through its drizzle chain out-of-band; the SQLite hosts run them at boot —
5+
// and before this ledger existed, each one re-scanned its tables on every
6+
// startup to decide "did I already run?" by data shape.
77
// That accumulates (N migrations = N full-table scans per boot, forever)
88
// and makes idempotence a per-migration proof obligation.
99
//
@@ -112,10 +112,16 @@ export const runSqliteDataMigrations = (
112112
for (const migration of migrations) {
113113
if (completed.has(migration.name)) continue;
114114
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.
115119
yield* execute(
116120
client,
117121
{
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`,
119125
args: [migration.name, Date.now()],
120126
},
121127
migration.name,

0 commit comments

Comments
 (0)