@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.
package/build/package.json
CHANGED
|
@@ -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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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]);
|