@hotmeshio/hotmesh 0.19.3 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.19.3",
3
+ "version": "0.19.4",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -119,6 +119,13 @@ declare class DBA {
119
119
  * @private
120
120
  */
121
121
  constructor();
122
+ /**
123
+ * Derives a deterministic advisory lock ID from an appId.
124
+ * Uses a different offset than the stream/store lock IDs to
125
+ * avoid collisions with those deployment locks.
126
+ * @private
127
+ */
128
+ static getAdvisoryLockId(appId: string): number;
122
129
  /**
123
130
  * Sanitizes an appId for use as a Postgres schema name.
124
131
  * Mirrors the naming logic used during table deployment.
@@ -122,6 +122,20 @@ class DBA {
122
122
  * @private
123
123
  */
124
124
  constructor() { }
125
+ /**
126
+ * Derives a deterministic advisory lock ID from an appId.
127
+ * Uses a different offset than the stream/store lock IDs to
128
+ * avoid collisions with those deployment locks.
129
+ * @private
130
+ */
131
+ static getAdvisoryLockId(appId) {
132
+ let hash = 0x44424130; // 'DBA0' — distinct namespace
133
+ for (let i = 0; i < appId.length; i++) {
134
+ hash = (hash << 5) - hash + appId.charCodeAt(i);
135
+ hash |= 0;
136
+ }
137
+ return Math.abs(hash);
138
+ }
125
139
  /**
126
140
  * Sanitizes an appId for use as a Postgres schema name.
127
141
  * Mirrors the naming logic used during table deployment.
@@ -345,8 +359,23 @@ class DBA {
345
359
  const schema = DBA.safeName(appId);
346
360
  const { client, release } = await DBA.getClient(connection);
347
361
  try {
348
- await client.query(DBA.getMigrationSQL(schema));
349
- await client.query(DBA.getPruneFunctionSQL(schema));
362
+ // Guard DDL with an advisory lock. CREATE INDEX IF NOT EXISTS
363
+ // is not atomic under concurrent transactions — two sessions
364
+ // can both see the index as absent, causing a unique_violation
365
+ // on pg_class_relname_nsp_index.
366
+ const lockId = DBA.getAdvisoryLockId(appId);
367
+ const lockResult = await client.query('SELECT pg_try_advisory_lock($1) AS locked', [lockId]);
368
+ if (lockResult.rows[0].locked) {
369
+ try {
370
+ await client.query(DBA.getMigrationSQL(schema));
371
+ await client.query(DBA.getPruneFunctionSQL(schema));
372
+ }
373
+ finally {
374
+ await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
375
+ }
376
+ }
377
+ // If another session holds the lock it is already running
378
+ // the same idempotent DDL — safe to skip.
350
379
  }
351
380
  finally {
352
381
  await release();
@@ -23,28 +23,33 @@ const KVTables = (context) => ({
23
23
  client = transactionClient;
24
24
  }
25
25
  try {
26
- // First, check if tables already exist (no lock needed)
27
26
  const tablesExist = await this.checkIfTablesExist(client, appName);
28
- if (tablesExist) {
29
- // Tables exist; apply any pending migrations
30
- await this.migrate(client, appName);
31
- return;
32
- }
33
- // Tables don't exist, need to acquire lock and create them
27
+ // Acquire advisory lock for ALL DDL: table creation and
28
+ // migrations. CREATE INDEX IF NOT EXISTS is not atomic under
29
+ // concurrent transactions — two sessions can both see the
30
+ // index as absent and both attempt creation, causing a
31
+ // unique_violation on pg_class_relname_nsp_index.
34
32
  const lockId = this.getAdvisoryLockId(appName);
35
33
  const lockResult = await client.query('SELECT pg_try_advisory_lock($1) AS locked', [lockId]);
36
34
  if (lockResult.rows[0].locked) {
37
- // Begin transaction
38
- await client.query('BEGIN');
39
- // Double-check tables don't exist (race condition safety)
40
- const tablesStillMissing = !(await this.checkIfTablesExist(client, appName));
41
- if (tablesStillMissing) {
42
- await this.createTables(client, appName);
35
+ try {
36
+ if (!tablesExist) {
37
+ // Begin transaction
38
+ await client.query('BEGIN');
39
+ // Double-check tables don't exist (race condition safety)
40
+ const tablesStillMissing = !(await this.checkIfTablesExist(client, appName));
41
+ if (tablesStillMissing) {
42
+ await this.createTables(client, appName);
43
+ }
44
+ // Commit transaction
45
+ await client.query('COMMIT');
46
+ }
47
+ // Always run migrations under the lock
48
+ await this.migrate(client, appName);
49
+ }
50
+ finally {
51
+ await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
43
52
  }
44
- // Commit transaction
45
- await client.query('COMMIT');
46
- // Release the lock
47
- await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
48
53
  }
49
54
  else {
50
55
  // Release the client before waiting
@@ -172,6 +177,11 @@ const KVTables = (context) => ({
172
177
  const fullTableName = `${tableDef.schema}.${tableDef.name}`;
173
178
  switch (tableDef.type) {
174
179
  case 'relational_app':
180
+ // Public tables are shared across all appIds. Use a fixed
181
+ // advisory lock to prevent concurrent CREATE TABLE races
182
+ // from different appId deployments (each has its own
183
+ // per-appId lock, but those don't overlap).
184
+ await client.query('SELECT pg_advisory_xact_lock($1)', [0x484D5348]);
175
185
  await client.query(`
176
186
  CREATE TABLE IF NOT EXISTS ${fullTableName} (
177
187
  app_id TEXT PRIMARY KEY,
@@ -11,25 +11,28 @@ async function deploySchema(streamClient, appId, logger) {
11
11
  const releaseClient = isPool;
12
12
  try {
13
13
  const schemaName = appId.replace(/[^a-zA-Z0-9_]/g, '_');
14
- // First, check if tables already exist (no lock needed)
15
14
  const tablesExist = await checkIfTablesExist(client, schemaName);
16
- if (tablesExist) {
17
- await ensureIndexes(client, schemaName);
18
- return;
19
- }
20
- // Tables don't exist, need to acquire lock and create them
15
+ // Acquire advisory lock for ALL DDL: table creation, index
16
+ // migrations, and trigger setup. CREATE INDEX IF NOT EXISTS is
17
+ // not atomic under concurrent transactions — two sessions can
18
+ // both see the index as absent and both attempt creation,
19
+ // causing a unique_violation on pg_class_relname_nsp_index.
21
20
  const lockId = getAdvisoryLockId(appId);
22
21
  const lockResult = await client.query('SELECT pg_try_advisory_lock($1) AS locked', [lockId]);
23
22
  if (lockResult.rows[0].locked) {
24
23
  try {
25
- await client.query('BEGIN');
26
- // Double-check tables don't exist (race condition safety)
27
- const tablesStillMissing = !(await checkIfTablesExist(client, schemaName));
28
- if (tablesStillMissing) {
29
- await createTables(client, schemaName);
30
- await createNotificationTriggers(client, schemaName);
24
+ if (!tablesExist) {
25
+ await client.query('BEGIN');
26
+ // Double-check tables don't exist (race condition safety)
27
+ const tablesStillMissing = !(await checkIfTablesExist(client, schemaName));
28
+ if (tablesStillMissing) {
29
+ await createTables(client, schemaName);
30
+ await createNotificationTriggers(client, schemaName);
31
+ }
32
+ await client.query('COMMIT');
31
33
  }
32
- await client.query('COMMIT');
34
+ // Always run index migrations under the lock
35
+ await ensureIndexes(client, schemaName);
33
36
  }
34
37
  finally {
35
38
  await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.19.3",
3
+ "version": "0.19.4",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",