prisma-sharding 0.0.5 → 0.0.7
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/README.md +237 -59
- package/dist/cli/migrate.js +327 -70
- package/dist/cli/studio.js +668 -59
- package/dist/cli/test.js +169 -59
- package/dist/cli/update.js +340 -75
- package/dist/cli/utils/command.js +232 -0
- package/dist/cli/utils/postgres.js +55 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +414 -90
- package/dist/index.mjs +414 -90
- package/package.json +4 -3
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
// src/utils/env.ts
|
|
2
|
+
var parseBooleanEnv = (name, defaultValue, env = process.env) => {
|
|
3
|
+
const value = env[name]?.trim();
|
|
4
|
+
if (value === void 0 || value === "") {
|
|
5
|
+
return defaultValue;
|
|
6
|
+
}
|
|
7
|
+
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
|
8
|
+
};
|
|
9
|
+
|
|
1
10
|
// src/utils/index.ts
|
|
2
11
|
function hashString(str) {
|
|
3
12
|
let hash = 0;
|
|
@@ -12,8 +21,13 @@ function validateUrl(url) {
|
|
|
12
21
|
return url.startsWith("postgresql://") || url.startsWith("postgres://");
|
|
13
22
|
}
|
|
14
23
|
function createDefaultLogger() {
|
|
24
|
+
const verbose = parseBooleanEnv("PRISMA_SHARDING_VERBOSE", false);
|
|
15
25
|
return {
|
|
16
|
-
info: (msg) =>
|
|
26
|
+
info: (msg) => {
|
|
27
|
+
if (verbose) {
|
|
28
|
+
console.log(`[PrismaSharding] ${msg}`);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
17
31
|
warn: (msg) => console.warn(`[PrismaSharding] ${msg}`),
|
|
18
32
|
error: (msg) => console.error(`[PrismaSharding] ${msg}`)
|
|
19
33
|
};
|
|
@@ -82,6 +96,11 @@ var ShardRouter = class {
|
|
|
82
96
|
if (this.strategy === "consistent-hash") {
|
|
83
97
|
this.initializeConsistentHashRing();
|
|
84
98
|
}
|
|
99
|
+
this.sortedRingHashes = Array.from(this.consistentHashRing.keys()).sort((a, b) => a - b);
|
|
100
|
+
this.randomShardWeights = this.shardIds.map(
|
|
101
|
+
(shardId) => config.shardWeights?.get(shardId) ?? 1
|
|
102
|
+
);
|
|
103
|
+
this.totalRandomWeight = this.randomShardWeights.reduce((sum, weight) => sum + weight, 0);
|
|
85
104
|
}
|
|
86
105
|
initializeConsistentHashRing() {
|
|
87
106
|
for (const shardId of this.shardIds) {
|
|
@@ -106,25 +125,49 @@ var ShardRouter = class {
|
|
|
106
125
|
return hash % shardCount;
|
|
107
126
|
}
|
|
108
127
|
getIndexConsistentHash(key) {
|
|
128
|
+
return this.shardIds.indexOf(this.getConsistentHashShardId(key));
|
|
129
|
+
}
|
|
130
|
+
getConsistentHashShardId(key) {
|
|
109
131
|
const hash = hashString(key);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
132
|
+
let low = 0;
|
|
133
|
+
let high = this.sortedRingHashes.length - 1;
|
|
134
|
+
let matchIndex = 0;
|
|
135
|
+
while (low <= high) {
|
|
136
|
+
const middle = Math.floor((low + high) / 2);
|
|
137
|
+
if (this.sortedRingHashes[middle] >= hash) {
|
|
138
|
+
matchIndex = middle;
|
|
139
|
+
high = middle - 1;
|
|
140
|
+
} else {
|
|
141
|
+
low = middle + 1;
|
|
116
142
|
}
|
|
117
143
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
144
|
+
if (low >= this.sortedRingHashes.length) {
|
|
145
|
+
matchIndex = 0;
|
|
146
|
+
}
|
|
147
|
+
return this.consistentHashRing.get(this.sortedRingHashes[matchIndex]);
|
|
121
148
|
}
|
|
122
149
|
getShardId(key) {
|
|
150
|
+
if (this.shardIds.length === 0) {
|
|
151
|
+
throw new RoutingError("No shards available");
|
|
152
|
+
}
|
|
153
|
+
if (this.strategy === "consistent-hash") {
|
|
154
|
+
return this.getConsistentHashShardId(key);
|
|
155
|
+
}
|
|
123
156
|
const index = this.getShardIndex(key);
|
|
124
157
|
return this.shardIds[index] || `shard_${index + 1}`;
|
|
125
158
|
}
|
|
126
159
|
getRandomShardIndex() {
|
|
127
|
-
|
|
160
|
+
if (this.shardIds.length === 0) {
|
|
161
|
+
throw new RoutingError("No shards available");
|
|
162
|
+
}
|
|
163
|
+
let selection = Math.random() * this.totalRandomWeight;
|
|
164
|
+
for (let index = 0; index < this.randomShardWeights.length; index++) {
|
|
165
|
+
selection -= this.randomShardWeights[index];
|
|
166
|
+
if (selection < 0) {
|
|
167
|
+
return index;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return this.shardIds.length - 1;
|
|
128
171
|
}
|
|
129
172
|
getRandomShardId() {
|
|
130
173
|
const index = this.getRandomShardIndex();
|
|
@@ -132,18 +175,235 @@ var ShardRouter = class {
|
|
|
132
175
|
}
|
|
133
176
|
};
|
|
134
177
|
|
|
178
|
+
// src/constants/internal.ts
|
|
179
|
+
var INTERNAL_DEFAULTS = {
|
|
180
|
+
HEALTH_CHECK_TIMEOUT_MS: 5e3,
|
|
181
|
+
CROSS_SHARD_CONCURRENCY: 4,
|
|
182
|
+
CROSS_SHARD_TIMEOUT_MS: 1e4,
|
|
183
|
+
CLI_COMMAND_TIMEOUT_MS: 12e4,
|
|
184
|
+
CLI_FORCE_KILL_GRACE_MS: 5e3,
|
|
185
|
+
CLI_MAX_OUTPUT_LENGTH: 1e6,
|
|
186
|
+
CLI_TEST_TCP_TIMEOUT_MS: 5e3,
|
|
187
|
+
CLI_TEST_COMMAND_TIMEOUT_MS: 15e3,
|
|
188
|
+
STUDIO_BASE_PORT: 51212,
|
|
189
|
+
STUDIO_REUSE_EXISTING: true,
|
|
190
|
+
STUDIO_STRICT_PORT_CHECK: false,
|
|
191
|
+
STUDIO_START_TIMEOUT_MS: 15e3,
|
|
192
|
+
STUDIO_STABILITY_MS: 500,
|
|
193
|
+
STUDIO_SHUTDOWN_TIMEOUT_MS: 5e3
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// src/utils/sanitize.ts
|
|
197
|
+
var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
198
|
+
var maskDatabaseUrl = (url) => {
|
|
199
|
+
try {
|
|
200
|
+
const parsed = new URL(url);
|
|
201
|
+
if (parsed.password) {
|
|
202
|
+
parsed.password = "***";
|
|
203
|
+
}
|
|
204
|
+
return parsed.toString();
|
|
205
|
+
} catch {
|
|
206
|
+
return url.replace(/:[^:@]+@/, ":***@");
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
var sanitizeDatabaseText = (output, databaseUrls) => {
|
|
210
|
+
let sanitized = output;
|
|
211
|
+
for (const url of databaseUrls) {
|
|
212
|
+
sanitized = sanitized.replace(new RegExp(escapeRegExp(url), "g"), maskDatabaseUrl(url));
|
|
213
|
+
try {
|
|
214
|
+
const password = new URL(url).password;
|
|
215
|
+
if (password.length >= 4) {
|
|
216
|
+
sanitized = sanitized.replace(
|
|
217
|
+
new RegExp(escapeRegExp(decodeURIComponent(password)), "g"),
|
|
218
|
+
"***"
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return sanitized.replace(
|
|
225
|
+
/(postgres(?:ql)?:\/\/[^:/\s]+:)[^@\s]+(@)/gi,
|
|
226
|
+
"$1***$2"
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// src/core/executor.ts
|
|
231
|
+
var timeoutError = (shardId, timeoutMs) => new Error(`Shard ${shardId} operation timed out after ${timeoutMs}ms`);
|
|
232
|
+
var CrossShardExecutor = class {
|
|
233
|
+
constructor(config = {}) {
|
|
234
|
+
this.metrics = {
|
|
235
|
+
operationCount: 0,
|
|
236
|
+
timeoutCount: 0,
|
|
237
|
+
failuresByShard: /* @__PURE__ */ new Map()
|
|
238
|
+
};
|
|
239
|
+
this.concurrency = config.concurrency ?? INTERNAL_DEFAULTS.CROSS_SHARD_CONCURRENCY;
|
|
240
|
+
this.timeoutMs = config.timeoutMs ?? INTERNAL_DEFAULTS.CROSS_SHARD_TIMEOUT_MS;
|
|
241
|
+
}
|
|
242
|
+
orderedTargets(targets) {
|
|
243
|
+
return targets.map((target, index) => ({ target, index })).sort((left, right) => {
|
|
244
|
+
if (left.target.isHealthy !== right.target.isHealthy) {
|
|
245
|
+
return left.target.isHealthy ? -1 : 1;
|
|
246
|
+
}
|
|
247
|
+
const leftLatency = left.target.latencyMs >= 0 ? left.target.latencyMs : Number.POSITIVE_INFINITY;
|
|
248
|
+
const rightLatency = right.target.latencyMs >= 0 ? right.target.latencyMs : Number.POSITIVE_INFINITY;
|
|
249
|
+
return leftLatency - rightLatency || left.index - right.index;
|
|
250
|
+
}).map(({ target }) => target);
|
|
251
|
+
}
|
|
252
|
+
recordFailure(shardId, error) {
|
|
253
|
+
this.metrics.failuresByShard.set(
|
|
254
|
+
shardId,
|
|
255
|
+
(this.metrics.failuresByShard.get(shardId) ?? 0) + 1
|
|
256
|
+
);
|
|
257
|
+
if (error.message.includes("timed out")) {
|
|
258
|
+
this.metrics.timeoutCount++;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
withTimeout(shardId, operation) {
|
|
262
|
+
return new Promise((resolve, reject) => {
|
|
263
|
+
let settled = false;
|
|
264
|
+
const finish = (callback) => {
|
|
265
|
+
if (settled) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
settled = true;
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
callback();
|
|
271
|
+
};
|
|
272
|
+
const timer = setTimeout(() => {
|
|
273
|
+
finish(() => reject(timeoutError(shardId, this.timeoutMs)));
|
|
274
|
+
}, this.timeoutMs);
|
|
275
|
+
Promise.resolve().then(operation).then(
|
|
276
|
+
(result) => finish(() => resolve(result)),
|
|
277
|
+
(error) => finish(() => reject(error))
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async executeAll(targets, operation) {
|
|
282
|
+
this.metrics.operationCount++;
|
|
283
|
+
const originalIndexes = new Map(targets.map((target, index) => [target.shardId, index]));
|
|
284
|
+
const ordered = this.orderedTargets(targets);
|
|
285
|
+
const results = new Array(targets.length);
|
|
286
|
+
let nextIndex = 0;
|
|
287
|
+
const worker = async () => {
|
|
288
|
+
while (nextIndex < ordered.length) {
|
|
289
|
+
const target = ordered[nextIndex++];
|
|
290
|
+
const resultIndex = originalIndexes.get(target.shardId);
|
|
291
|
+
try {
|
|
292
|
+
const result = await this.withTimeout(
|
|
293
|
+
target.shardId,
|
|
294
|
+
() => operation(target.client, target.shardId)
|
|
295
|
+
);
|
|
296
|
+
results[resultIndex] = { shardId: target.shardId, result, error: void 0 };
|
|
297
|
+
} catch (error) {
|
|
298
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
299
|
+
this.recordFailure(target.shardId, normalizedError);
|
|
300
|
+
results[resultIndex] = {
|
|
301
|
+
shardId: target.shardId,
|
|
302
|
+
result: null,
|
|
303
|
+
error: normalizedError
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const workerCount = Math.min(this.concurrency, ordered.length);
|
|
309
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
310
|
+
return results;
|
|
311
|
+
}
|
|
312
|
+
findFirst(targets, operation) {
|
|
313
|
+
this.metrics.operationCount++;
|
|
314
|
+
const ordered = this.orderedTargets(targets);
|
|
315
|
+
return new Promise((resolve) => {
|
|
316
|
+
if (ordered.length === 0) {
|
|
317
|
+
resolve({ result: null, shardId: null });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
let nextIndex = 0;
|
|
321
|
+
let active = 0;
|
|
322
|
+
let completed = 0;
|
|
323
|
+
let found = false;
|
|
324
|
+
const launch = () => {
|
|
325
|
+
while (!found && active < this.concurrency && nextIndex < ordered.length) {
|
|
326
|
+
const target = ordered[nextIndex++];
|
|
327
|
+
active++;
|
|
328
|
+
this.withTimeout(target.shardId, () => operation(target.client)).then((result) => {
|
|
329
|
+
if (!found && result !== null && result !== void 0) {
|
|
330
|
+
found = true;
|
|
331
|
+
resolve({ result, shardId: target.shardId });
|
|
332
|
+
}
|
|
333
|
+
}).catch((error) => {
|
|
334
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
335
|
+
this.recordFailure(target.shardId, normalizedError);
|
|
336
|
+
}).finally(() => {
|
|
337
|
+
active--;
|
|
338
|
+
completed++;
|
|
339
|
+
if (!found && completed === ordered.length) {
|
|
340
|
+
resolve({ result: null, shardId: null });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
launch();
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
launch();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
135
352
|
// src/core/manager.ts
|
|
353
|
+
var isObject = (value) => typeof value === "object" && value !== null;
|
|
354
|
+
var isConnectable = (client) => isObject(client) && typeof client.$connect === "function";
|
|
355
|
+
var isDisconnectable = (client) => isObject(client) && typeof client.$disconnect === "function";
|
|
356
|
+
var isQueryable = (client) => isObject(client) && typeof client.$queryRaw === "function";
|
|
357
|
+
var withTimeout = (operation, timeoutMs, timeoutMessage) => new Promise((resolve, reject) => {
|
|
358
|
+
let settled = false;
|
|
359
|
+
const timer = setTimeout(() => {
|
|
360
|
+
if (!settled) {
|
|
361
|
+
settled = true;
|
|
362
|
+
reject(new Error(timeoutMessage));
|
|
363
|
+
}
|
|
364
|
+
}, timeoutMs);
|
|
365
|
+
operation.then(
|
|
366
|
+
(result) => {
|
|
367
|
+
if (!settled) {
|
|
368
|
+
settled = true;
|
|
369
|
+
clearTimeout(timer);
|
|
370
|
+
resolve(result);
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
(error) => {
|
|
374
|
+
if (!settled) {
|
|
375
|
+
settled = true;
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
reject(error);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
});
|
|
136
382
|
var ShardManager = class {
|
|
137
383
|
constructor(config) {
|
|
138
384
|
this.instances = /* @__PURE__ */ new Map();
|
|
385
|
+
this.unhealthyAccessWarnings = /* @__PURE__ */ new Set();
|
|
139
386
|
this.healthCheckInterval = null;
|
|
387
|
+
this.healthCheckInFlight = false;
|
|
388
|
+
this.healthCheckPromise = null;
|
|
140
389
|
this.config = config;
|
|
390
|
+
this.executor = new CrossShardExecutor();
|
|
141
391
|
}
|
|
142
392
|
async initialize() {
|
|
143
393
|
this.config.logger.info(`Initializing ${this.config.shards.length} shard(s)...`);
|
|
144
|
-
|
|
145
|
-
this.
|
|
394
|
+
try {
|
|
395
|
+
for (const shardConfig of this.config.shards) {
|
|
396
|
+
this.initializeShard(shardConfig);
|
|
397
|
+
}
|
|
398
|
+
} catch (error) {
|
|
399
|
+
await this.disconnectInstances();
|
|
400
|
+
this.instances.clear();
|
|
401
|
+
throw error;
|
|
146
402
|
}
|
|
403
|
+
void this.performHealthChecks(true).catch((error) => {
|
|
404
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
405
|
+
this.config.logger.error(`Initial shard health verification failed: ${message}`);
|
|
406
|
+
});
|
|
147
407
|
this.startHealthChecks();
|
|
148
408
|
this.config.logger.info("All shards initialized successfully");
|
|
149
409
|
}
|
|
@@ -165,7 +425,8 @@ var ShardManager = class {
|
|
|
165
425
|
});
|
|
166
426
|
this.config.logger.info(`Shard ${shardConfig.id} initialized`);
|
|
167
427
|
} catch (error) {
|
|
168
|
-
|
|
428
|
+
const message = sanitizeDatabaseText(String(error), [shardConfig.url]);
|
|
429
|
+
this.config.logger.error(`Failed to initialize shard ${shardConfig.id}: ${message}`);
|
|
169
430
|
throw new ConnectionError(`Failed to initialize shard ${shardConfig.id}`, shardConfig.id);
|
|
170
431
|
}
|
|
171
432
|
}
|
|
@@ -173,58 +434,108 @@ var ShardManager = class {
|
|
|
173
434
|
if (this.healthCheckInterval) {
|
|
174
435
|
clearInterval(this.healthCheckInterval);
|
|
175
436
|
}
|
|
176
|
-
this.healthCheckInterval = setInterval(
|
|
177
|
-
|
|
437
|
+
this.healthCheckInterval = setInterval(() => {
|
|
438
|
+
if (!this.healthCheckInFlight) {
|
|
439
|
+
void this.performHealthChecks(false);
|
|
440
|
+
}
|
|
178
441
|
}, this.config.healthCheckIntervalMs);
|
|
442
|
+
this.healthCheckInterval.unref?.();
|
|
179
443
|
}
|
|
180
|
-
async
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
await client.$
|
|
444
|
+
async verifyShard(instance, warmConnection) {
|
|
445
|
+
const startTime = Date.now();
|
|
446
|
+
const previousHealth = instance.health;
|
|
447
|
+
try {
|
|
448
|
+
const verification = async () => {
|
|
449
|
+
if (warmConnection && isConnectable(instance.client)) {
|
|
450
|
+
await instance.client.$connect();
|
|
187
451
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
...instance.health,
|
|
191
|
-
isHealthy: true,
|
|
192
|
-
latencyMs,
|
|
193
|
-
lastChecked: /* @__PURE__ */ new Date(),
|
|
194
|
-
consecutiveFailures: 0
|
|
195
|
-
};
|
|
196
|
-
} catch (error) {
|
|
197
|
-
const consecutiveFailures = instance.health.consecutiveFailures + 1;
|
|
198
|
-
const isHealthy = consecutiveFailures < this.config.circuitBreakerThreshold;
|
|
199
|
-
instance.health = {
|
|
200
|
-
...instance.health,
|
|
201
|
-
isHealthy,
|
|
202
|
-
latencyMs: -1,
|
|
203
|
-
lastChecked: /* @__PURE__ */ new Date(),
|
|
204
|
-
errorCount: instance.health.errorCount + 1,
|
|
205
|
-
consecutiveFailures
|
|
206
|
-
};
|
|
207
|
-
if (!isHealthy) {
|
|
208
|
-
this.config.logger.error(
|
|
209
|
-
`Shard ${instance.config.id} marked unhealthy after ${consecutiveFailures} consecutive failures`
|
|
210
|
-
);
|
|
452
|
+
if (isQueryable(instance.client)) {
|
|
453
|
+
await instance.client.$queryRaw`SELECT 1`;
|
|
211
454
|
}
|
|
455
|
+
};
|
|
456
|
+
await withTimeout(
|
|
457
|
+
verification(),
|
|
458
|
+
INTERNAL_DEFAULTS.HEALTH_CHECK_TIMEOUT_MS,
|
|
459
|
+
`Health check timed out for shard ${instance.config.id}`
|
|
460
|
+
);
|
|
461
|
+
instance.health = {
|
|
462
|
+
...previousHealth,
|
|
463
|
+
isHealthy: true,
|
|
464
|
+
latencyMs: Date.now() - startTime,
|
|
465
|
+
lastChecked: /* @__PURE__ */ new Date(),
|
|
466
|
+
consecutiveFailures: 0
|
|
467
|
+
};
|
|
468
|
+
this.unhealthyAccessWarnings.delete(instance.config.id);
|
|
469
|
+
if (!previousHealth.isHealthy && previousHealth.consecutiveFailures > 0) {
|
|
470
|
+
this.config.logger.info(`Shard ${instance.config.id} recovered`);
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
const consecutiveFailures = previousHealth.consecutiveFailures + 1;
|
|
474
|
+
const wasVerifiedHealthy = previousHealth.isHealthy;
|
|
475
|
+
const isHealthy = warmConnection ? false : wasVerifiedHealthy && consecutiveFailures < this.config.circuitBreakerThreshold;
|
|
476
|
+
instance.health = {
|
|
477
|
+
...previousHealth,
|
|
478
|
+
isHealthy,
|
|
479
|
+
latencyMs: -1,
|
|
480
|
+
lastChecked: /* @__PURE__ */ new Date(),
|
|
481
|
+
errorCount: previousHealth.errorCount + 1,
|
|
482
|
+
consecutiveFailures
|
|
483
|
+
};
|
|
484
|
+
const message = sanitizeDatabaseText(
|
|
485
|
+
error instanceof Error ? error.message : String(error),
|
|
486
|
+
[instance.config.url]
|
|
487
|
+
);
|
|
488
|
+
const isFirstFailure = previousHealth.errorCount === 0;
|
|
489
|
+
const becameUnhealthy = previousHealth.isHealthy && !isHealthy;
|
|
490
|
+
if (isFirstFailure || becameUnhealthy) {
|
|
491
|
+
this.config.logger.error(
|
|
492
|
+
`Health check failed for ${instance.config.id}: ${message}${!isHealthy ? "; shard marked unhealthy" : ""}`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
performHealthChecks(warmConnections) {
|
|
498
|
+
if (this.healthCheckInFlight) {
|
|
499
|
+
return this.healthCheckPromise ?? Promise.resolve();
|
|
500
|
+
}
|
|
501
|
+
this.healthCheckInFlight = true;
|
|
502
|
+
const operation = this.executor.executeAll(this.getExecutionTargets(), async (_client, shardId) => {
|
|
503
|
+
const instance = this.instances.get(shardId);
|
|
504
|
+
if (instance) {
|
|
505
|
+
await this.verifyShard(instance, warmConnections);
|
|
212
506
|
}
|
|
507
|
+
return null;
|
|
508
|
+
}).then(() => void 0).finally(() => {
|
|
509
|
+
this.healthCheckInFlight = false;
|
|
510
|
+
this.healthCheckPromise = null;
|
|
213
511
|
});
|
|
214
|
-
|
|
512
|
+
this.healthCheckPromise = operation;
|
|
513
|
+
return operation;
|
|
514
|
+
}
|
|
515
|
+
getExecutionTargets() {
|
|
516
|
+
return Array.from(this.instances.values()).map((instance) => ({
|
|
517
|
+
shardId: instance.config.id,
|
|
518
|
+
client: instance.client,
|
|
519
|
+
isHealthy: instance.health.isHealthy,
|
|
520
|
+
latencyMs: instance.health.latencyMs
|
|
521
|
+
}));
|
|
215
522
|
}
|
|
216
523
|
getClient(shardId) {
|
|
217
524
|
const instance = this.instances.get(shardId);
|
|
218
525
|
if (!instance) {
|
|
219
526
|
throw new ConnectionError(`Shard ${shardId} not found`, shardId);
|
|
220
527
|
}
|
|
221
|
-
if (!instance.health.isHealthy) {
|
|
528
|
+
if (!instance.health.isHealthy && !this.unhealthyAccessWarnings.has(shardId)) {
|
|
529
|
+
this.unhealthyAccessWarnings.add(shardId);
|
|
222
530
|
this.config.logger.warn(`Accessing unhealthy shard ${shardId}`);
|
|
223
531
|
}
|
|
224
532
|
return instance.client;
|
|
225
533
|
}
|
|
226
534
|
getClientByIndex(index) {
|
|
227
|
-
const shardId =
|
|
535
|
+
const shardId = this.getShardIds()[index];
|
|
536
|
+
if (!shardId) {
|
|
537
|
+
throw new ConnectionError(`Shard index ${index} not found`, String(index));
|
|
538
|
+
}
|
|
228
539
|
return this.getClient(shardId);
|
|
229
540
|
}
|
|
230
541
|
getAllClients() {
|
|
@@ -245,56 +556,38 @@ var ShardManager = class {
|
|
|
245
556
|
getAllHealth() {
|
|
246
557
|
return Array.from(this.instances.values()).map((instance) => instance.health);
|
|
247
558
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
Array.from(this.instances.entries()).map(async ([shardId, instance]) => {
|
|
251
|
-
try {
|
|
252
|
-
const result = await operation(instance.client, shardId);
|
|
253
|
-
return { shardId, result, error: void 0 };
|
|
254
|
-
} catch (error) {
|
|
255
|
-
return { shardId, result: null, error };
|
|
256
|
-
}
|
|
257
|
-
})
|
|
258
|
-
);
|
|
259
|
-
return results.map((result) => {
|
|
260
|
-
if (result.status === "fulfilled") {
|
|
261
|
-
return result.value;
|
|
262
|
-
}
|
|
263
|
-
return {
|
|
264
|
-
shardId: "unknown",
|
|
265
|
-
result: null,
|
|
266
|
-
error: result.reason
|
|
267
|
-
};
|
|
268
|
-
});
|
|
559
|
+
executeOnAll(operation) {
|
|
560
|
+
return this.executor.executeAll(this.getExecutionTargets(), operation);
|
|
269
561
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
for (const res of results) {
|
|
273
|
-
if (res.result !== null && res.result !== void 0) {
|
|
274
|
-
return { result: res.result, shardId: res.shardId };
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
return { result: null, shardId: null };
|
|
562
|
+
findFirst(operation) {
|
|
563
|
+
return this.executor.findFirst(this.getExecutionTargets(), operation);
|
|
278
564
|
}
|
|
279
|
-
async
|
|
280
|
-
this.config.logger.info("Shutting down...");
|
|
281
|
-
if (this.healthCheckInterval) {
|
|
282
|
-
clearInterval(this.healthCheckInterval);
|
|
283
|
-
this.healthCheckInterval = null;
|
|
284
|
-
}
|
|
565
|
+
async disconnectInstances() {
|
|
285
566
|
const disconnects = Array.from(this.instances.values()).map(async (instance) => {
|
|
286
567
|
try {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
await client.$disconnect();
|
|
568
|
+
if (isDisconnectable(instance.client)) {
|
|
569
|
+
await instance.client.$disconnect();
|
|
290
570
|
}
|
|
291
571
|
this.config.logger.info(`Shard ${instance.config.id} disconnected`);
|
|
292
572
|
} catch (error) {
|
|
293
|
-
|
|
573
|
+
const message = sanitizeDatabaseText(String(error), [instance.config.url]);
|
|
574
|
+
this.config.logger.error(`Error disconnecting shard ${instance.config.id}: ${message}`);
|
|
294
575
|
}
|
|
295
576
|
});
|
|
296
577
|
await Promise.allSettled(disconnects);
|
|
578
|
+
}
|
|
579
|
+
async shutdown() {
|
|
580
|
+
this.config.logger.info("Shutting down...");
|
|
581
|
+
if (this.healthCheckInterval) {
|
|
582
|
+
clearInterval(this.healthCheckInterval);
|
|
583
|
+
this.healthCheckInterval = null;
|
|
584
|
+
}
|
|
585
|
+
if (this.healthCheckPromise) {
|
|
586
|
+
await this.healthCheckPromise;
|
|
587
|
+
}
|
|
588
|
+
await this.disconnectInstances();
|
|
297
589
|
this.instances.clear();
|
|
590
|
+
this.unhealthyAccessWarnings.clear();
|
|
298
591
|
this.config.logger.info("Shutdown complete");
|
|
299
592
|
}
|
|
300
593
|
};
|
|
@@ -313,17 +606,34 @@ var PrismaSharding = class {
|
|
|
313
606
|
if (!config.shards || config.shards.length === 0) {
|
|
314
607
|
throw new ConfigError(ERROR_MESSAGES.NO_SHARDS);
|
|
315
608
|
}
|
|
316
|
-
if (
|
|
609
|
+
if (typeof config.createClient !== "function") {
|
|
317
610
|
throw new ConfigError(ERROR_MESSAGES.MISSING_CLIENT_FACTORY);
|
|
318
611
|
}
|
|
612
|
+
const shardIds = /* @__PURE__ */ new Set();
|
|
319
613
|
for (const shard of config.shards) {
|
|
614
|
+
if (!shard.id || shard.id.trim().length === 0) {
|
|
615
|
+
throw new ConfigError("Shard ID must not be empty");
|
|
616
|
+
}
|
|
617
|
+
if (shardIds.has(shard.id)) {
|
|
618
|
+
throw new ConfigError(`Duplicate shard ID: "${shard.id}"`);
|
|
619
|
+
}
|
|
620
|
+
shardIds.add(shard.id);
|
|
320
621
|
if (!shard.url || !validateUrl(shard.url)) {
|
|
321
622
|
throw new ConfigError(ERROR_MESSAGES.INVALID_SHARD_URL(shard.id));
|
|
322
623
|
}
|
|
624
|
+
if (shard.weight !== void 0 && (!Number.isFinite(shard.weight) || shard.weight <= 0)) {
|
|
625
|
+
throw new ConfigError(`Weight for shard "${shard.id}" must be positive`);
|
|
626
|
+
}
|
|
323
627
|
}
|
|
324
628
|
if (config.strategy && config.strategy !== "modulo" && config.strategy !== "consistent-hash") {
|
|
325
629
|
throw new ConfigError(ERROR_MESSAGES.INVALID_STRATEGY(config.strategy));
|
|
326
630
|
}
|
|
631
|
+
if (config.healthCheckIntervalMs !== void 0 && (!Number.isFinite(config.healthCheckIntervalMs) || config.healthCheckIntervalMs <= 0)) {
|
|
632
|
+
throw new ConfigError("healthCheckIntervalMs must be positive");
|
|
633
|
+
}
|
|
634
|
+
if (config.circuitBreakerThreshold !== void 0 && (!Number.isFinite(config.circuitBreakerThreshold) || config.circuitBreakerThreshold <= 0)) {
|
|
635
|
+
throw new ConfigError("circuitBreakerThreshold must be positive");
|
|
636
|
+
}
|
|
327
637
|
}
|
|
328
638
|
async connect() {
|
|
329
639
|
if (this.connected) {
|
|
@@ -337,10 +647,20 @@ var PrismaSharding = class {
|
|
|
337
647
|
circuitBreakerThreshold: this.config.circuitBreakerThreshold ?? DEFAULTS.CIRCUIT_BREAKER_THRESHOLD,
|
|
338
648
|
logger: this.logger
|
|
339
649
|
});
|
|
340
|
-
|
|
650
|
+
try {
|
|
651
|
+
await this.manager.initialize();
|
|
652
|
+
} catch (error) {
|
|
653
|
+
this.manager = null;
|
|
654
|
+
this.router = null;
|
|
655
|
+
this.connected = false;
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
341
658
|
this.router = new ShardRouter({
|
|
342
659
|
strategy: this.config.strategy ?? "modulo",
|
|
343
660
|
shardIds: this.manager.getShardIds(),
|
|
661
|
+
shardWeights: new Map(
|
|
662
|
+
this.config.shards.map((shard) => [shard.id, shard.weight ?? 1])
|
|
663
|
+
),
|
|
344
664
|
logger: this.logger
|
|
345
665
|
});
|
|
346
666
|
this.connected = true;
|
|
@@ -381,6 +701,10 @@ var PrismaSharding = class {
|
|
|
381
701
|
const shardId = this.router.getRandomShardId();
|
|
382
702
|
return this.manager.getClient(shardId);
|
|
383
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* Returns both the random shard client and its shardId.
|
|
706
|
+
* Used when the shardId must be stored on the user record for future routing.
|
|
707
|
+
*/
|
|
384
708
|
getRandomShardWithInfo() {
|
|
385
709
|
this.ensureConnected();
|
|
386
710
|
const shardId = this.router.getRandomShardId();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prisma-sharding",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "Lightweight database sharding library for Prisma with connection pooling and health monitoring",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -35,11 +35,12 @@
|
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
|
-
"build": "tsup src/index.ts --format cjs,esm --dts --clean && tsup src/cli/migrate.ts src/cli/studio.ts src/cli/test.ts src/cli/update.ts --format cjs --outDir dist/cli --clean",
|
|
38
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean && tsup src/cli/migrate.ts src/cli/studio.ts src/cli/test.ts src/cli/update.ts src/cli/utils/command.ts src/cli/utils/postgres.ts --format cjs --outDir dist/cli --clean",
|
|
39
39
|
"build:lib": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
40
|
-
"build:cli": "tsup src/cli/migrate.ts src/cli/studio.ts src/cli/test.ts src/cli/update.ts --format cjs --outDir dist/cli",
|
|
40
|
+
"build:cli": "tsup src/cli/migrate.ts src/cli/studio.ts src/cli/test.ts src/cli/update.ts src/cli/utils/command.ts src/cli/utils/postgres.ts --format cjs --outDir dist/cli",
|
|
41
41
|
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
42
42
|
"lint": "eslint src --ext .ts",
|
|
43
|
+
"test": "yarn build && node --test test/*.test.js",
|
|
43
44
|
"typecheck": "tsc --noEmit",
|
|
44
45
|
"prepublishOnly": "yarn build",
|
|
45
46
|
"patch": "yarn version --patch && yarn publish",
|