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.js
CHANGED
|
@@ -33,6 +33,15 @@ __export(index_exports, {
|
|
|
33
33
|
});
|
|
34
34
|
module.exports = __toCommonJS(index_exports);
|
|
35
35
|
|
|
36
|
+
// src/utils/env.ts
|
|
37
|
+
var parseBooleanEnv = (name, defaultValue, env = process.env) => {
|
|
38
|
+
const value = env[name]?.trim();
|
|
39
|
+
if (value === void 0 || value === "") {
|
|
40
|
+
return defaultValue;
|
|
41
|
+
}
|
|
42
|
+
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
|
43
|
+
};
|
|
44
|
+
|
|
36
45
|
// src/utils/index.ts
|
|
37
46
|
function hashString(str) {
|
|
38
47
|
let hash = 0;
|
|
@@ -47,8 +56,13 @@ function validateUrl(url) {
|
|
|
47
56
|
return url.startsWith("postgresql://") || url.startsWith("postgres://");
|
|
48
57
|
}
|
|
49
58
|
function createDefaultLogger() {
|
|
59
|
+
const verbose = parseBooleanEnv("PRISMA_SHARDING_VERBOSE", false);
|
|
50
60
|
return {
|
|
51
|
-
info: (msg) =>
|
|
61
|
+
info: (msg) => {
|
|
62
|
+
if (verbose) {
|
|
63
|
+
console.log(`[PrismaSharding] ${msg}`);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
52
66
|
warn: (msg) => console.warn(`[PrismaSharding] ${msg}`),
|
|
53
67
|
error: (msg) => console.error(`[PrismaSharding] ${msg}`)
|
|
54
68
|
};
|
|
@@ -117,6 +131,11 @@ var ShardRouter = class {
|
|
|
117
131
|
if (this.strategy === "consistent-hash") {
|
|
118
132
|
this.initializeConsistentHashRing();
|
|
119
133
|
}
|
|
134
|
+
this.sortedRingHashes = Array.from(this.consistentHashRing.keys()).sort((a, b) => a - b);
|
|
135
|
+
this.randomShardWeights = this.shardIds.map(
|
|
136
|
+
(shardId) => config.shardWeights?.get(shardId) ?? 1
|
|
137
|
+
);
|
|
138
|
+
this.totalRandomWeight = this.randomShardWeights.reduce((sum, weight) => sum + weight, 0);
|
|
120
139
|
}
|
|
121
140
|
initializeConsistentHashRing() {
|
|
122
141
|
for (const shardId of this.shardIds) {
|
|
@@ -141,25 +160,49 @@ var ShardRouter = class {
|
|
|
141
160
|
return hash % shardCount;
|
|
142
161
|
}
|
|
143
162
|
getIndexConsistentHash(key) {
|
|
163
|
+
return this.shardIds.indexOf(this.getConsistentHashShardId(key));
|
|
164
|
+
}
|
|
165
|
+
getConsistentHashShardId(key) {
|
|
144
166
|
const hash = hashString(key);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
167
|
+
let low = 0;
|
|
168
|
+
let high = this.sortedRingHashes.length - 1;
|
|
169
|
+
let matchIndex = 0;
|
|
170
|
+
while (low <= high) {
|
|
171
|
+
const middle = Math.floor((low + high) / 2);
|
|
172
|
+
if (this.sortedRingHashes[middle] >= hash) {
|
|
173
|
+
matchIndex = middle;
|
|
174
|
+
high = middle - 1;
|
|
175
|
+
} else {
|
|
176
|
+
low = middle + 1;
|
|
151
177
|
}
|
|
152
178
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
179
|
+
if (low >= this.sortedRingHashes.length) {
|
|
180
|
+
matchIndex = 0;
|
|
181
|
+
}
|
|
182
|
+
return this.consistentHashRing.get(this.sortedRingHashes[matchIndex]);
|
|
156
183
|
}
|
|
157
184
|
getShardId(key) {
|
|
185
|
+
if (this.shardIds.length === 0) {
|
|
186
|
+
throw new RoutingError("No shards available");
|
|
187
|
+
}
|
|
188
|
+
if (this.strategy === "consistent-hash") {
|
|
189
|
+
return this.getConsistentHashShardId(key);
|
|
190
|
+
}
|
|
158
191
|
const index = this.getShardIndex(key);
|
|
159
192
|
return this.shardIds[index] || `shard_${index + 1}`;
|
|
160
193
|
}
|
|
161
194
|
getRandomShardIndex() {
|
|
162
|
-
|
|
195
|
+
if (this.shardIds.length === 0) {
|
|
196
|
+
throw new RoutingError("No shards available");
|
|
197
|
+
}
|
|
198
|
+
let selection = Math.random() * this.totalRandomWeight;
|
|
199
|
+
for (let index = 0; index < this.randomShardWeights.length; index++) {
|
|
200
|
+
selection -= this.randomShardWeights[index];
|
|
201
|
+
if (selection < 0) {
|
|
202
|
+
return index;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return this.shardIds.length - 1;
|
|
163
206
|
}
|
|
164
207
|
getRandomShardId() {
|
|
165
208
|
const index = this.getRandomShardIndex();
|
|
@@ -167,18 +210,235 @@ var ShardRouter = class {
|
|
|
167
210
|
}
|
|
168
211
|
};
|
|
169
212
|
|
|
213
|
+
// src/constants/internal.ts
|
|
214
|
+
var INTERNAL_DEFAULTS = {
|
|
215
|
+
HEALTH_CHECK_TIMEOUT_MS: 5e3,
|
|
216
|
+
CROSS_SHARD_CONCURRENCY: 4,
|
|
217
|
+
CROSS_SHARD_TIMEOUT_MS: 1e4,
|
|
218
|
+
CLI_COMMAND_TIMEOUT_MS: 12e4,
|
|
219
|
+
CLI_FORCE_KILL_GRACE_MS: 5e3,
|
|
220
|
+
CLI_MAX_OUTPUT_LENGTH: 1e6,
|
|
221
|
+
CLI_TEST_TCP_TIMEOUT_MS: 5e3,
|
|
222
|
+
CLI_TEST_COMMAND_TIMEOUT_MS: 15e3,
|
|
223
|
+
STUDIO_BASE_PORT: 51212,
|
|
224
|
+
STUDIO_REUSE_EXISTING: true,
|
|
225
|
+
STUDIO_STRICT_PORT_CHECK: false,
|
|
226
|
+
STUDIO_START_TIMEOUT_MS: 15e3,
|
|
227
|
+
STUDIO_STABILITY_MS: 500,
|
|
228
|
+
STUDIO_SHUTDOWN_TIMEOUT_MS: 5e3
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// src/utils/sanitize.ts
|
|
232
|
+
var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
233
|
+
var maskDatabaseUrl = (url) => {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = new URL(url);
|
|
236
|
+
if (parsed.password) {
|
|
237
|
+
parsed.password = "***";
|
|
238
|
+
}
|
|
239
|
+
return parsed.toString();
|
|
240
|
+
} catch {
|
|
241
|
+
return url.replace(/:[^:@]+@/, ":***@");
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
var sanitizeDatabaseText = (output, databaseUrls) => {
|
|
245
|
+
let sanitized = output;
|
|
246
|
+
for (const url of databaseUrls) {
|
|
247
|
+
sanitized = sanitized.replace(new RegExp(escapeRegExp(url), "g"), maskDatabaseUrl(url));
|
|
248
|
+
try {
|
|
249
|
+
const password = new URL(url).password;
|
|
250
|
+
if (password.length >= 4) {
|
|
251
|
+
sanitized = sanitized.replace(
|
|
252
|
+
new RegExp(escapeRegExp(decodeURIComponent(password)), "g"),
|
|
253
|
+
"***"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return sanitized.replace(
|
|
260
|
+
/(postgres(?:ql)?:\/\/[^:/\s]+:)[^@\s]+(@)/gi,
|
|
261
|
+
"$1***$2"
|
|
262
|
+
);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// src/core/executor.ts
|
|
266
|
+
var timeoutError = (shardId, timeoutMs) => new Error(`Shard ${shardId} operation timed out after ${timeoutMs}ms`);
|
|
267
|
+
var CrossShardExecutor = class {
|
|
268
|
+
constructor(config = {}) {
|
|
269
|
+
this.metrics = {
|
|
270
|
+
operationCount: 0,
|
|
271
|
+
timeoutCount: 0,
|
|
272
|
+
failuresByShard: /* @__PURE__ */ new Map()
|
|
273
|
+
};
|
|
274
|
+
this.concurrency = config.concurrency ?? INTERNAL_DEFAULTS.CROSS_SHARD_CONCURRENCY;
|
|
275
|
+
this.timeoutMs = config.timeoutMs ?? INTERNAL_DEFAULTS.CROSS_SHARD_TIMEOUT_MS;
|
|
276
|
+
}
|
|
277
|
+
orderedTargets(targets) {
|
|
278
|
+
return targets.map((target, index) => ({ target, index })).sort((left, right) => {
|
|
279
|
+
if (left.target.isHealthy !== right.target.isHealthy) {
|
|
280
|
+
return left.target.isHealthy ? -1 : 1;
|
|
281
|
+
}
|
|
282
|
+
const leftLatency = left.target.latencyMs >= 0 ? left.target.latencyMs : Number.POSITIVE_INFINITY;
|
|
283
|
+
const rightLatency = right.target.latencyMs >= 0 ? right.target.latencyMs : Number.POSITIVE_INFINITY;
|
|
284
|
+
return leftLatency - rightLatency || left.index - right.index;
|
|
285
|
+
}).map(({ target }) => target);
|
|
286
|
+
}
|
|
287
|
+
recordFailure(shardId, error) {
|
|
288
|
+
this.metrics.failuresByShard.set(
|
|
289
|
+
shardId,
|
|
290
|
+
(this.metrics.failuresByShard.get(shardId) ?? 0) + 1
|
|
291
|
+
);
|
|
292
|
+
if (error.message.includes("timed out")) {
|
|
293
|
+
this.metrics.timeoutCount++;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
withTimeout(shardId, operation) {
|
|
297
|
+
return new Promise((resolve, reject) => {
|
|
298
|
+
let settled = false;
|
|
299
|
+
const finish = (callback) => {
|
|
300
|
+
if (settled) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
settled = true;
|
|
304
|
+
clearTimeout(timer);
|
|
305
|
+
callback();
|
|
306
|
+
};
|
|
307
|
+
const timer = setTimeout(() => {
|
|
308
|
+
finish(() => reject(timeoutError(shardId, this.timeoutMs)));
|
|
309
|
+
}, this.timeoutMs);
|
|
310
|
+
Promise.resolve().then(operation).then(
|
|
311
|
+
(result) => finish(() => resolve(result)),
|
|
312
|
+
(error) => finish(() => reject(error))
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
async executeAll(targets, operation) {
|
|
317
|
+
this.metrics.operationCount++;
|
|
318
|
+
const originalIndexes = new Map(targets.map((target, index) => [target.shardId, index]));
|
|
319
|
+
const ordered = this.orderedTargets(targets);
|
|
320
|
+
const results = new Array(targets.length);
|
|
321
|
+
let nextIndex = 0;
|
|
322
|
+
const worker = async () => {
|
|
323
|
+
while (nextIndex < ordered.length) {
|
|
324
|
+
const target = ordered[nextIndex++];
|
|
325
|
+
const resultIndex = originalIndexes.get(target.shardId);
|
|
326
|
+
try {
|
|
327
|
+
const result = await this.withTimeout(
|
|
328
|
+
target.shardId,
|
|
329
|
+
() => operation(target.client, target.shardId)
|
|
330
|
+
);
|
|
331
|
+
results[resultIndex] = { shardId: target.shardId, result, error: void 0 };
|
|
332
|
+
} catch (error) {
|
|
333
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
334
|
+
this.recordFailure(target.shardId, normalizedError);
|
|
335
|
+
results[resultIndex] = {
|
|
336
|
+
shardId: target.shardId,
|
|
337
|
+
result: null,
|
|
338
|
+
error: normalizedError
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
const workerCount = Math.min(this.concurrency, ordered.length);
|
|
344
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
345
|
+
return results;
|
|
346
|
+
}
|
|
347
|
+
findFirst(targets, operation) {
|
|
348
|
+
this.metrics.operationCount++;
|
|
349
|
+
const ordered = this.orderedTargets(targets);
|
|
350
|
+
return new Promise((resolve) => {
|
|
351
|
+
if (ordered.length === 0) {
|
|
352
|
+
resolve({ result: null, shardId: null });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
let nextIndex = 0;
|
|
356
|
+
let active = 0;
|
|
357
|
+
let completed = 0;
|
|
358
|
+
let found = false;
|
|
359
|
+
const launch = () => {
|
|
360
|
+
while (!found && active < this.concurrency && nextIndex < ordered.length) {
|
|
361
|
+
const target = ordered[nextIndex++];
|
|
362
|
+
active++;
|
|
363
|
+
this.withTimeout(target.shardId, () => operation(target.client)).then((result) => {
|
|
364
|
+
if (!found && result !== null && result !== void 0) {
|
|
365
|
+
found = true;
|
|
366
|
+
resolve({ result, shardId: target.shardId });
|
|
367
|
+
}
|
|
368
|
+
}).catch((error) => {
|
|
369
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
370
|
+
this.recordFailure(target.shardId, normalizedError);
|
|
371
|
+
}).finally(() => {
|
|
372
|
+
active--;
|
|
373
|
+
completed++;
|
|
374
|
+
if (!found && completed === ordered.length) {
|
|
375
|
+
resolve({ result: null, shardId: null });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
launch();
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
launch();
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
170
387
|
// src/core/manager.ts
|
|
388
|
+
var isObject = (value) => typeof value === "object" && value !== null;
|
|
389
|
+
var isConnectable = (client) => isObject(client) && typeof client.$connect === "function";
|
|
390
|
+
var isDisconnectable = (client) => isObject(client) && typeof client.$disconnect === "function";
|
|
391
|
+
var isQueryable = (client) => isObject(client) && typeof client.$queryRaw === "function";
|
|
392
|
+
var withTimeout = (operation, timeoutMs, timeoutMessage) => new Promise((resolve, reject) => {
|
|
393
|
+
let settled = false;
|
|
394
|
+
const timer = setTimeout(() => {
|
|
395
|
+
if (!settled) {
|
|
396
|
+
settled = true;
|
|
397
|
+
reject(new Error(timeoutMessage));
|
|
398
|
+
}
|
|
399
|
+
}, timeoutMs);
|
|
400
|
+
operation.then(
|
|
401
|
+
(result) => {
|
|
402
|
+
if (!settled) {
|
|
403
|
+
settled = true;
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
resolve(result);
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
(error) => {
|
|
409
|
+
if (!settled) {
|
|
410
|
+
settled = true;
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
reject(error);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
);
|
|
416
|
+
});
|
|
171
417
|
var ShardManager = class {
|
|
172
418
|
constructor(config) {
|
|
173
419
|
this.instances = /* @__PURE__ */ new Map();
|
|
420
|
+
this.unhealthyAccessWarnings = /* @__PURE__ */ new Set();
|
|
174
421
|
this.healthCheckInterval = null;
|
|
422
|
+
this.healthCheckInFlight = false;
|
|
423
|
+
this.healthCheckPromise = null;
|
|
175
424
|
this.config = config;
|
|
425
|
+
this.executor = new CrossShardExecutor();
|
|
176
426
|
}
|
|
177
427
|
async initialize() {
|
|
178
428
|
this.config.logger.info(`Initializing ${this.config.shards.length} shard(s)...`);
|
|
179
|
-
|
|
180
|
-
this.
|
|
429
|
+
try {
|
|
430
|
+
for (const shardConfig of this.config.shards) {
|
|
431
|
+
this.initializeShard(shardConfig);
|
|
432
|
+
}
|
|
433
|
+
} catch (error) {
|
|
434
|
+
await this.disconnectInstances();
|
|
435
|
+
this.instances.clear();
|
|
436
|
+
throw error;
|
|
181
437
|
}
|
|
438
|
+
void this.performHealthChecks(true).catch((error) => {
|
|
439
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
440
|
+
this.config.logger.error(`Initial shard health verification failed: ${message}`);
|
|
441
|
+
});
|
|
182
442
|
this.startHealthChecks();
|
|
183
443
|
this.config.logger.info("All shards initialized successfully");
|
|
184
444
|
}
|
|
@@ -200,7 +460,8 @@ var ShardManager = class {
|
|
|
200
460
|
});
|
|
201
461
|
this.config.logger.info(`Shard ${shardConfig.id} initialized`);
|
|
202
462
|
} catch (error) {
|
|
203
|
-
|
|
463
|
+
const message = sanitizeDatabaseText(String(error), [shardConfig.url]);
|
|
464
|
+
this.config.logger.error(`Failed to initialize shard ${shardConfig.id}: ${message}`);
|
|
204
465
|
throw new ConnectionError(`Failed to initialize shard ${shardConfig.id}`, shardConfig.id);
|
|
205
466
|
}
|
|
206
467
|
}
|
|
@@ -208,58 +469,108 @@ var ShardManager = class {
|
|
|
208
469
|
if (this.healthCheckInterval) {
|
|
209
470
|
clearInterval(this.healthCheckInterval);
|
|
210
471
|
}
|
|
211
|
-
this.healthCheckInterval = setInterval(
|
|
212
|
-
|
|
472
|
+
this.healthCheckInterval = setInterval(() => {
|
|
473
|
+
if (!this.healthCheckInFlight) {
|
|
474
|
+
void this.performHealthChecks(false);
|
|
475
|
+
}
|
|
213
476
|
}, this.config.healthCheckIntervalMs);
|
|
477
|
+
this.healthCheckInterval.unref?.();
|
|
214
478
|
}
|
|
215
|
-
async
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
await client.$
|
|
479
|
+
async verifyShard(instance, warmConnection) {
|
|
480
|
+
const startTime = Date.now();
|
|
481
|
+
const previousHealth = instance.health;
|
|
482
|
+
try {
|
|
483
|
+
const verification = async () => {
|
|
484
|
+
if (warmConnection && isConnectable(instance.client)) {
|
|
485
|
+
await instance.client.$connect();
|
|
222
486
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
...instance.health,
|
|
226
|
-
isHealthy: true,
|
|
227
|
-
latencyMs,
|
|
228
|
-
lastChecked: /* @__PURE__ */ new Date(),
|
|
229
|
-
consecutiveFailures: 0
|
|
230
|
-
};
|
|
231
|
-
} catch (error) {
|
|
232
|
-
const consecutiveFailures = instance.health.consecutiveFailures + 1;
|
|
233
|
-
const isHealthy = consecutiveFailures < this.config.circuitBreakerThreshold;
|
|
234
|
-
instance.health = {
|
|
235
|
-
...instance.health,
|
|
236
|
-
isHealthy,
|
|
237
|
-
latencyMs: -1,
|
|
238
|
-
lastChecked: /* @__PURE__ */ new Date(),
|
|
239
|
-
errorCount: instance.health.errorCount + 1,
|
|
240
|
-
consecutiveFailures
|
|
241
|
-
};
|
|
242
|
-
if (!isHealthy) {
|
|
243
|
-
this.config.logger.error(
|
|
244
|
-
`Shard ${instance.config.id} marked unhealthy after ${consecutiveFailures} consecutive failures`
|
|
245
|
-
);
|
|
487
|
+
if (isQueryable(instance.client)) {
|
|
488
|
+
await instance.client.$queryRaw`SELECT 1`;
|
|
246
489
|
}
|
|
490
|
+
};
|
|
491
|
+
await withTimeout(
|
|
492
|
+
verification(),
|
|
493
|
+
INTERNAL_DEFAULTS.HEALTH_CHECK_TIMEOUT_MS,
|
|
494
|
+
`Health check timed out for shard ${instance.config.id}`
|
|
495
|
+
);
|
|
496
|
+
instance.health = {
|
|
497
|
+
...previousHealth,
|
|
498
|
+
isHealthy: true,
|
|
499
|
+
latencyMs: Date.now() - startTime,
|
|
500
|
+
lastChecked: /* @__PURE__ */ new Date(),
|
|
501
|
+
consecutiveFailures: 0
|
|
502
|
+
};
|
|
503
|
+
this.unhealthyAccessWarnings.delete(instance.config.id);
|
|
504
|
+
if (!previousHealth.isHealthy && previousHealth.consecutiveFailures > 0) {
|
|
505
|
+
this.config.logger.info(`Shard ${instance.config.id} recovered`);
|
|
247
506
|
}
|
|
507
|
+
} catch (error) {
|
|
508
|
+
const consecutiveFailures = previousHealth.consecutiveFailures + 1;
|
|
509
|
+
const wasVerifiedHealthy = previousHealth.isHealthy;
|
|
510
|
+
const isHealthy = warmConnection ? false : wasVerifiedHealthy && consecutiveFailures < this.config.circuitBreakerThreshold;
|
|
511
|
+
instance.health = {
|
|
512
|
+
...previousHealth,
|
|
513
|
+
isHealthy,
|
|
514
|
+
latencyMs: -1,
|
|
515
|
+
lastChecked: /* @__PURE__ */ new Date(),
|
|
516
|
+
errorCount: previousHealth.errorCount + 1,
|
|
517
|
+
consecutiveFailures
|
|
518
|
+
};
|
|
519
|
+
const message = sanitizeDatabaseText(
|
|
520
|
+
error instanceof Error ? error.message : String(error),
|
|
521
|
+
[instance.config.url]
|
|
522
|
+
);
|
|
523
|
+
const isFirstFailure = previousHealth.errorCount === 0;
|
|
524
|
+
const becameUnhealthy = previousHealth.isHealthy && !isHealthy;
|
|
525
|
+
if (isFirstFailure || becameUnhealthy) {
|
|
526
|
+
this.config.logger.error(
|
|
527
|
+
`Health check failed for ${instance.config.id}: ${message}${!isHealthy ? "; shard marked unhealthy" : ""}`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
performHealthChecks(warmConnections) {
|
|
533
|
+
if (this.healthCheckInFlight) {
|
|
534
|
+
return this.healthCheckPromise ?? Promise.resolve();
|
|
535
|
+
}
|
|
536
|
+
this.healthCheckInFlight = true;
|
|
537
|
+
const operation = this.executor.executeAll(this.getExecutionTargets(), async (_client, shardId) => {
|
|
538
|
+
const instance = this.instances.get(shardId);
|
|
539
|
+
if (instance) {
|
|
540
|
+
await this.verifyShard(instance, warmConnections);
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}).then(() => void 0).finally(() => {
|
|
544
|
+
this.healthCheckInFlight = false;
|
|
545
|
+
this.healthCheckPromise = null;
|
|
248
546
|
});
|
|
249
|
-
|
|
547
|
+
this.healthCheckPromise = operation;
|
|
548
|
+
return operation;
|
|
549
|
+
}
|
|
550
|
+
getExecutionTargets() {
|
|
551
|
+
return Array.from(this.instances.values()).map((instance) => ({
|
|
552
|
+
shardId: instance.config.id,
|
|
553
|
+
client: instance.client,
|
|
554
|
+
isHealthy: instance.health.isHealthy,
|
|
555
|
+
latencyMs: instance.health.latencyMs
|
|
556
|
+
}));
|
|
250
557
|
}
|
|
251
558
|
getClient(shardId) {
|
|
252
559
|
const instance = this.instances.get(shardId);
|
|
253
560
|
if (!instance) {
|
|
254
561
|
throw new ConnectionError(`Shard ${shardId} not found`, shardId);
|
|
255
562
|
}
|
|
256
|
-
if (!instance.health.isHealthy) {
|
|
563
|
+
if (!instance.health.isHealthy && !this.unhealthyAccessWarnings.has(shardId)) {
|
|
564
|
+
this.unhealthyAccessWarnings.add(shardId);
|
|
257
565
|
this.config.logger.warn(`Accessing unhealthy shard ${shardId}`);
|
|
258
566
|
}
|
|
259
567
|
return instance.client;
|
|
260
568
|
}
|
|
261
569
|
getClientByIndex(index) {
|
|
262
|
-
const shardId =
|
|
570
|
+
const shardId = this.getShardIds()[index];
|
|
571
|
+
if (!shardId) {
|
|
572
|
+
throw new ConnectionError(`Shard index ${index} not found`, String(index));
|
|
573
|
+
}
|
|
263
574
|
return this.getClient(shardId);
|
|
264
575
|
}
|
|
265
576
|
getAllClients() {
|
|
@@ -280,56 +591,38 @@ var ShardManager = class {
|
|
|
280
591
|
getAllHealth() {
|
|
281
592
|
return Array.from(this.instances.values()).map((instance) => instance.health);
|
|
282
593
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
Array.from(this.instances.entries()).map(async ([shardId, instance]) => {
|
|
286
|
-
try {
|
|
287
|
-
const result = await operation(instance.client, shardId);
|
|
288
|
-
return { shardId, result, error: void 0 };
|
|
289
|
-
} catch (error) {
|
|
290
|
-
return { shardId, result: null, error };
|
|
291
|
-
}
|
|
292
|
-
})
|
|
293
|
-
);
|
|
294
|
-
return results.map((result) => {
|
|
295
|
-
if (result.status === "fulfilled") {
|
|
296
|
-
return result.value;
|
|
297
|
-
}
|
|
298
|
-
return {
|
|
299
|
-
shardId: "unknown",
|
|
300
|
-
result: null,
|
|
301
|
-
error: result.reason
|
|
302
|
-
};
|
|
303
|
-
});
|
|
594
|
+
executeOnAll(operation) {
|
|
595
|
+
return this.executor.executeAll(this.getExecutionTargets(), operation);
|
|
304
596
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
for (const res of results) {
|
|
308
|
-
if (res.result !== null && res.result !== void 0) {
|
|
309
|
-
return { result: res.result, shardId: res.shardId };
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return { result: null, shardId: null };
|
|
597
|
+
findFirst(operation) {
|
|
598
|
+
return this.executor.findFirst(this.getExecutionTargets(), operation);
|
|
313
599
|
}
|
|
314
|
-
async
|
|
315
|
-
this.config.logger.info("Shutting down...");
|
|
316
|
-
if (this.healthCheckInterval) {
|
|
317
|
-
clearInterval(this.healthCheckInterval);
|
|
318
|
-
this.healthCheckInterval = null;
|
|
319
|
-
}
|
|
600
|
+
async disconnectInstances() {
|
|
320
601
|
const disconnects = Array.from(this.instances.values()).map(async (instance) => {
|
|
321
602
|
try {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
await client.$disconnect();
|
|
603
|
+
if (isDisconnectable(instance.client)) {
|
|
604
|
+
await instance.client.$disconnect();
|
|
325
605
|
}
|
|
326
606
|
this.config.logger.info(`Shard ${instance.config.id} disconnected`);
|
|
327
607
|
} catch (error) {
|
|
328
|
-
|
|
608
|
+
const message = sanitizeDatabaseText(String(error), [instance.config.url]);
|
|
609
|
+
this.config.logger.error(`Error disconnecting shard ${instance.config.id}: ${message}`);
|
|
329
610
|
}
|
|
330
611
|
});
|
|
331
612
|
await Promise.allSettled(disconnects);
|
|
613
|
+
}
|
|
614
|
+
async shutdown() {
|
|
615
|
+
this.config.logger.info("Shutting down...");
|
|
616
|
+
if (this.healthCheckInterval) {
|
|
617
|
+
clearInterval(this.healthCheckInterval);
|
|
618
|
+
this.healthCheckInterval = null;
|
|
619
|
+
}
|
|
620
|
+
if (this.healthCheckPromise) {
|
|
621
|
+
await this.healthCheckPromise;
|
|
622
|
+
}
|
|
623
|
+
await this.disconnectInstances();
|
|
332
624
|
this.instances.clear();
|
|
625
|
+
this.unhealthyAccessWarnings.clear();
|
|
333
626
|
this.config.logger.info("Shutdown complete");
|
|
334
627
|
}
|
|
335
628
|
};
|
|
@@ -348,17 +641,34 @@ var PrismaSharding = class {
|
|
|
348
641
|
if (!config.shards || config.shards.length === 0) {
|
|
349
642
|
throw new ConfigError(ERROR_MESSAGES.NO_SHARDS);
|
|
350
643
|
}
|
|
351
|
-
if (
|
|
644
|
+
if (typeof config.createClient !== "function") {
|
|
352
645
|
throw new ConfigError(ERROR_MESSAGES.MISSING_CLIENT_FACTORY);
|
|
353
646
|
}
|
|
647
|
+
const shardIds = /* @__PURE__ */ new Set();
|
|
354
648
|
for (const shard of config.shards) {
|
|
649
|
+
if (!shard.id || shard.id.trim().length === 0) {
|
|
650
|
+
throw new ConfigError("Shard ID must not be empty");
|
|
651
|
+
}
|
|
652
|
+
if (shardIds.has(shard.id)) {
|
|
653
|
+
throw new ConfigError(`Duplicate shard ID: "${shard.id}"`);
|
|
654
|
+
}
|
|
655
|
+
shardIds.add(shard.id);
|
|
355
656
|
if (!shard.url || !validateUrl(shard.url)) {
|
|
356
657
|
throw new ConfigError(ERROR_MESSAGES.INVALID_SHARD_URL(shard.id));
|
|
357
658
|
}
|
|
659
|
+
if (shard.weight !== void 0 && (!Number.isFinite(shard.weight) || shard.weight <= 0)) {
|
|
660
|
+
throw new ConfigError(`Weight for shard "${shard.id}" must be positive`);
|
|
661
|
+
}
|
|
358
662
|
}
|
|
359
663
|
if (config.strategy && config.strategy !== "modulo" && config.strategy !== "consistent-hash") {
|
|
360
664
|
throw new ConfigError(ERROR_MESSAGES.INVALID_STRATEGY(config.strategy));
|
|
361
665
|
}
|
|
666
|
+
if (config.healthCheckIntervalMs !== void 0 && (!Number.isFinite(config.healthCheckIntervalMs) || config.healthCheckIntervalMs <= 0)) {
|
|
667
|
+
throw new ConfigError("healthCheckIntervalMs must be positive");
|
|
668
|
+
}
|
|
669
|
+
if (config.circuitBreakerThreshold !== void 0 && (!Number.isFinite(config.circuitBreakerThreshold) || config.circuitBreakerThreshold <= 0)) {
|
|
670
|
+
throw new ConfigError("circuitBreakerThreshold must be positive");
|
|
671
|
+
}
|
|
362
672
|
}
|
|
363
673
|
async connect() {
|
|
364
674
|
if (this.connected) {
|
|
@@ -372,10 +682,20 @@ var PrismaSharding = class {
|
|
|
372
682
|
circuitBreakerThreshold: this.config.circuitBreakerThreshold ?? DEFAULTS.CIRCUIT_BREAKER_THRESHOLD,
|
|
373
683
|
logger: this.logger
|
|
374
684
|
});
|
|
375
|
-
|
|
685
|
+
try {
|
|
686
|
+
await this.manager.initialize();
|
|
687
|
+
} catch (error) {
|
|
688
|
+
this.manager = null;
|
|
689
|
+
this.router = null;
|
|
690
|
+
this.connected = false;
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
376
693
|
this.router = new ShardRouter({
|
|
377
694
|
strategy: this.config.strategy ?? "modulo",
|
|
378
695
|
shardIds: this.manager.getShardIds(),
|
|
696
|
+
shardWeights: new Map(
|
|
697
|
+
this.config.shards.map((shard) => [shard.id, shard.weight ?? 1])
|
|
698
|
+
),
|
|
379
699
|
logger: this.logger
|
|
380
700
|
});
|
|
381
701
|
this.connected = true;
|
|
@@ -416,6 +736,10 @@ var PrismaSharding = class {
|
|
|
416
736
|
const shardId = this.router.getRandomShardId();
|
|
417
737
|
return this.manager.getClient(shardId);
|
|
418
738
|
}
|
|
739
|
+
/**
|
|
740
|
+
* Returns both the random shard client and its shardId.
|
|
741
|
+
* Used when the shardId must be stored on the user record for future routing.
|
|
742
|
+
*/
|
|
419
743
|
getRandomShardWithInfo() {
|
|
420
744
|
this.ensureConnected();
|
|
421
745
|
const shardId = this.router.getRandomShardId();
|