Skip to content

Commit c186ad8

Browse files
authored
Fix ENSNode Schema migrations execution (#1996)
1 parent aa26180 commit c186ad8

5 files changed

Lines changed: 112 additions & 10 deletions

File tree

.changeset/tough-clubs-eat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ensnode/ensdb-sdk": patch
3+
---
4+
5+
Made `EnsDbWriter.migrateEnsNodeSchema` race-condition safe.

packages/ensdb-sdk/src/client/ensdb-writer.test.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ import * as ensDbClientMock from "./ensdb-client.mock";
1111
import { EnsDbWriter } from "./ensdb-writer";
1212
import { EnsNodeMetadataKeys } from "./ensnode-metadata";
1313

14+
const executeMock = vi.fn(async () => undefined);
1415
const onConflictDoUpdateMock = vi.fn(async () => undefined);
1516
const valuesMock = vi.fn(() => ({ onConflictDoUpdate: onConflictDoUpdateMock }));
1617
const insertMock = vi.fn(() => ({ values: valuesMock }));
17-
const drizzleClientMock = { insert: insertMock } as any;
18+
const transactionMock = vi.fn(async (callback: (tx: any) => Promise<void>) => {
19+
const tx = { execute: executeMock, insert: insertMock };
20+
return callback(tx);
21+
});
22+
const drizzleClientMock = {
23+
insert: insertMock,
24+
transaction: transactionMock,
25+
execute: executeMock,
26+
} as any;
1827

1928
vi.mock("drizzle-orm/node-postgres", () => ({
2029
drizzle: vi.fn(() => drizzleClientMock),
@@ -26,9 +35,11 @@ describe("EnsDbWriter", () => {
2635
new EnsDbWriter(ensDbClientMock.ensDbUrl, ensDbClientMock.ensIndexerSchemaName);
2736

2837
beforeEach(() => {
38+
executeMock.mockClear();
2939
onConflictDoUpdateMock.mockClear();
3040
valuesMock.mockClear();
3141
insertMock.mockClear();
42+
transactionMock.mockClear();
3243
vi.mocked(migrate).mockClear();
3344
});
3445

@@ -84,18 +95,31 @@ describe("EnsDbWriter", () => {
8495
});
8596

8697
describe("migrateEnsNodeSchema", () => {
87-
it("calls drizzle-orm migrateEnsNodeSchema with the correct parameters", async () => {
98+
it("calls drizzle-orm migrate with the correct parameters inside a transaction", async () => {
8899
const migrationsDirPath = "/path/to/migrations";
89100

90101
await createEnsDbWriter().migrateEnsNodeSchema(migrationsDirPath);
91102

92-
expect(vi.mocked(migrate)).toHaveBeenCalledWith(drizzleClientMock, {
93-
migrationsFolder: migrationsDirPath,
94-
migrationsSchema: "ensnode",
95-
});
103+
expect(transactionMock).toHaveBeenCalled();
104+
expect(executeMock).toHaveBeenCalledWith(
105+
expect.objectContaining({
106+
queryChunks: expect.arrayContaining([
107+
expect.objectContaining({ value: ["SELECT pg_advisory_xact_lock("] }),
108+
expect.any(BigInt),
109+
expect.objectContaining({ value: [")"] }),
110+
]),
111+
}),
112+
);
113+
expect(vi.mocked(migrate)).toHaveBeenCalledWith(
114+
expect.objectContaining({ execute: executeMock }),
115+
{
116+
migrationsFolder: migrationsDirPath,
117+
migrationsSchema: "ensnode",
118+
},
119+
);
96120
});
97121

98-
it("propagates errors from the migrateEnsNodeSchema function", async () => {
122+
it("propagates errors from the migrate function", async () => {
99123
const migrationsDirPath = "/path/to/migrations";
100124
vi.mocked(migrate).mockRejectedValueOnce(new Error("Migration failed"));
101125

packages/ensdb-sdk/src/client/ensdb-writer.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sql } from "drizzle-orm";
12
import { migrate } from "drizzle-orm/node-postgres/migrator";
23

34
import {
@@ -7,6 +8,7 @@ import {
78
serializeEnsIndexerPublicConfig,
89
} from "@ensnode/ensnode-sdk";
910

11+
import { advisoryLockId } from "../lib/advisory-lock-id";
1012
import { EnsDbReader } from "./ensdb-reader";
1113
import { EnsNodeMetadataKeys } from "./ensnode-metadata";
1214
import type { SerializedEnsNodeMetadata } from "./serialize/ensnode-metadata";
@@ -19,17 +21,40 @@ import type { SerializedEnsNodeMetadata } from "./serialize/ensnode-metadata";
1921
* - updating ENSNode Metadata records in ENSDb for the given ENSIndexer instance.
2022
*/
2123
export class EnsDbWriter extends EnsDbReader {
24+
/**
25+
* Stable arbitrary lock ID for ENSNode Schema migrations to
26+
* prevent concurrent migration execution across multiple ENSIndexer instances.
27+
*/
28+
private static readonly MIGRATION_LOCK_ID: bigint = advisoryLockId(
29+
"ensnode-schema-migration-lock",
30+
);
31+
2232
/**
2333
* Execute pending database migrations for ENSNode Schema in ENSDb.
2434
*
35+
* This function is:
36+
* - idempotent and can be safely executed multiple times,
37+
* - safe to execute concurrently across multiple ENSIndexer instances,
38+
* as it uses a stable arbitrary advisory lock to prevent concurrent
39+
* execution of migrations.
40+
*
2541
* @param migrationsDirPath - The file path to the directory containing
2642
* database migration files for ENSNode Schema.
2743
* @throws error when migration execution fails.
2844
*/
2945
async migrateEnsNodeSchema(migrationsDirPath: string): Promise<void> {
30-
return migrate(this.drizzleClient, {
31-
migrationsFolder: migrationsDirPath,
32-
migrationsSchema: "ensnode",
46+
// `pg_advisory_xact_lock` is transaction-scoped, and is automatically released
47+
// when the transaction ends, with no explicit unlock needed. Running it inside
48+
// a Drizzle transaction also guarantees that the lock acquisition, all
49+
// migration queries, and the lock release all run on the same physical
50+
// connection — which is required for advisory locks to work correctly with a
51+
// connection pool.
52+
await this.drizzleClient.transaction(async (tx) => {
53+
await tx.execute(sql`SELECT pg_advisory_xact_lock(${EnsDbWriter.MIGRATION_LOCK_ID})`);
54+
await migrate(tx, {
55+
migrationsFolder: migrationsDirPath,
56+
migrationsSchema: "ensnode",
57+
});
3358
});
3459
}
3560

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { advisoryLockId } from "./advisory-lock-id";
4+
5+
describe("advisoryLockId", () => {
6+
it("returns a bigint for any string input", () => {
7+
expect(advisoryLockId("test-name")).toBeTypeOf("bigint");
8+
expect(advisoryLockId("")).toBeTypeOf("bigint");
9+
expect(advisoryLockId("schema-migrations")).toBeTypeOf("bigint");
10+
});
11+
12+
it("returns consistent (deterministic) results for the same input", () => {
13+
expect(advisoryLockId("name")).toBe(advisoryLockId("name"));
14+
expect(advisoryLockId("")).toBe(advisoryLockId(""));
15+
});
16+
17+
it("returns different results for different inputs", () => {
18+
expect(advisoryLockId("name-one")).not.toBe(advisoryLockId("name-two"));
19+
});
20+
21+
it("produces expected lock ID for known input", () => {
22+
// SHA-256 of "hello" -> first 8 bytes as signed 64-bit big-endian
23+
expect(advisoryLockId("hello")).toBe(3238736544897475342n);
24+
});
25+
26+
it("produces values within PostgreSQL bigint range", () => {
27+
// PostgreSQL bigint is signed 64-bit
28+
const result = advisoryLockId("any-name");
29+
30+
expect(result).toBeGreaterThanOrEqual(-9223372036854775808n);
31+
expect(result).toBeLessThanOrEqual(9223372036854775807n);
32+
});
33+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { createHash } from "node:crypto";
2+
3+
/**
4+
* Generate a stable arbitrary advisory lock ID for the given name.
5+
*
6+
* @param name - The name to derive the advisory lock ID from. This should be
7+
* a fixed string that uniquely identifies the critical section of code that
8+
* requires synchronization, such as "schema-migrations".
9+
* @returns A bigint representing the advisory lock ID to be used with PostgreSQL advisory locks.
10+
*/
11+
export function advisoryLockId(name: string): bigint {
12+
const hash = createHash("sha256").update(name).digest();
13+
// Read the first 8 bytes as a signed 64-bit integer (Postgres bigint range)
14+
return hash.readBigInt64BE(0);
15+
}

0 commit comments

Comments
 (0)