buncord-hybrid-sharding 1.0.0
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/.gitattributes +2 -0
- package/LICENSE +28 -0
- package/README.md +91 -0
- package/bun.lock +88 -0
- package/package.json +39 -0
- package/src/Core/Cluster.ts +414 -0
- package/src/Core/ClusterClient.ts +329 -0
- package/src/Core/ClusterManager.ts +545 -0
- package/src/Core/DashboardServer.ts +84 -0
- package/src/Plugins/AutoResharderSystem.ts +382 -0
- package/src/Plugins/HeartbeatSystem.ts +56 -0
- package/src/Plugins/QueueManager.ts +49 -0
- package/src/Plugins/ReCluster.ts +101 -0
- package/src/Structures/Child.ts +109 -0
- package/src/Structures/Data.ts +33 -0
- package/src/Structures/IPCHandler.ts +154 -0
- package/src/Structures/IPCMessage.ts +101 -0
- package/src/Structures/ManagerHooks.ts +9 -0
- package/src/Structures/PromiseHandler.ts +63 -0
- package/src/Structures/Queue.ts +84 -0
- package/src/Util/RedisClient.ts +77 -0
- package/src/Util/Util.ts +62 -0
- package/src/index.ts +17 -0
- package/src/types/shared.ts +164 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import {
|
|
2
|
+
arraysAreTheSame, ClusterClient, ClusterManager, DjsDiscordClient, fetchRecommendedShards,
|
|
3
|
+
messageType
|
|
4
|
+
} from "../index.js";
|
|
5
|
+
|
|
6
|
+
export interface AutoResharderSendData {
|
|
7
|
+
clusterId: number;
|
|
8
|
+
shardData: {
|
|
9
|
+
shardId: number;
|
|
10
|
+
guildCount: number;
|
|
11
|
+
}[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface sendDataMessage {
|
|
15
|
+
data: AutoResharderSendData;
|
|
16
|
+
_type: messageType;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface AutoResharderClusterClientOptions {
|
|
20
|
+
/**
|
|
21
|
+
* How often to send the data (the faster the bot grows, the more often you should send the data)
|
|
22
|
+
* @default 60e3
|
|
23
|
+
*/
|
|
24
|
+
sendDataIntervalMS: number;
|
|
25
|
+
/**
|
|
26
|
+
* Function to send the required Data for the AUTORESHARDING
|
|
27
|
+
* @param cluster
|
|
28
|
+
* @returns sendData can be either sync or async
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* sendDataFunction: (cluster:ClusterClient<DjsDiscordClient>) => {
|
|
33
|
+
* return {
|
|
34
|
+
* clusterId: cluster.id,
|
|
35
|
+
* shardData: cluster.info.SHARD_LIST.map(shardId => ({ shardId, guildCount: cluster.client.guilds.cache.filter(g => g.shardId === shardId).size }))
|
|
36
|
+
* }
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
sendDataFunction: (
|
|
41
|
+
cluster: ClusterClient<DjsDiscordClient>,
|
|
42
|
+
) => Promise<AutoResharderSendData> | AutoResharderSendData;
|
|
43
|
+
debug?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface AutoResharderManagerOptions {
|
|
47
|
+
/**
|
|
48
|
+
* How many shards to be set per cluster
|
|
49
|
+
*/
|
|
50
|
+
ShardsPerCluster: number | 'useManagerOption';
|
|
51
|
+
/**
|
|
52
|
+
* This Number declares how many new shards should spawn.
|
|
53
|
+
* if set to 1500 it aims to create as many shards that on avg. per shard 1500 guilds are set
|
|
54
|
+
* If set to "auto" it uses the recommendation amount of discord-gateway.
|
|
55
|
+
* If set to "auto" then MaxGuildsPerShard must be at least 2000
|
|
56
|
+
* @default 1500
|
|
57
|
+
*/
|
|
58
|
+
MinGuildsPerShard: 'auto' | number;
|
|
59
|
+
/** If this number is reached, autoresharding starts */
|
|
60
|
+
MaxGuildsPerShard: number;
|
|
61
|
+
/** Restart Options for reclustering */
|
|
62
|
+
restartOptions?: {
|
|
63
|
+
/** The restartMode of the clusterManager, gracefulSwitch = waits until all new clusters have spawned with maintenance mode, rolling = Once the Cluster is Ready, the old cluster will be killed */
|
|
64
|
+
restartMode?: 'gracefulSwitch' | 'rolling';
|
|
65
|
+
/** The delay to wait between each cluster spawn */
|
|
66
|
+
delay?: number;
|
|
67
|
+
/** The readyTimeout to wait until the cluster spawn promise is rejected */
|
|
68
|
+
timeout?: number;
|
|
69
|
+
};
|
|
70
|
+
debug?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class AutoResharderClusterClient {
|
|
74
|
+
private clusterClient: ClusterClient<DjsDiscordClient>;
|
|
75
|
+
/** The Options of the AutoResharderClusterClient */
|
|
76
|
+
private options: AutoResharderClusterClientOptions = {
|
|
77
|
+
sendDataIntervalMS: 60e3,
|
|
78
|
+
debug: false,
|
|
79
|
+
sendDataFunction: (cluster: ClusterClient<DjsDiscordClient>) => {
|
|
80
|
+
return {
|
|
81
|
+
clusterId: cluster.id,
|
|
82
|
+
shardData: cluster.info.SHARD_LIST.map(shardId => ({
|
|
83
|
+
shardId,
|
|
84
|
+
guildCount: cluster.client.guilds.cache.filter((g:{ shardId: number }) => g.shardId === shardId).size,
|
|
85
|
+
})),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
/** The Stored Interval */
|
|
90
|
+
private interval: any | null = null;
|
|
91
|
+
/** Wether it is running or not */
|
|
92
|
+
private started = false;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The Cluster client and what it shold contain
|
|
96
|
+
* @param {ClusterClient<DjsDiscordClient>} clusterClient
|
|
97
|
+
* @param {Partial<AutoResharderClusterClientOptions>} [options] the Optional options
|
|
98
|
+
*/
|
|
99
|
+
constructor(clusterClient: ClusterClient<DjsDiscordClient>, options?: Partial<AutoResharderClusterClientOptions>) {
|
|
100
|
+
this.clusterClient = clusterClient;
|
|
101
|
+
this.options = {
|
|
102
|
+
...this.options,
|
|
103
|
+
...options,
|
|
104
|
+
};
|
|
105
|
+
this.validate();
|
|
106
|
+
this.initialize();
|
|
107
|
+
}
|
|
108
|
+
private validate() {
|
|
109
|
+
if (
|
|
110
|
+
typeof this.clusterClient !== 'object' ||
|
|
111
|
+
typeof this.clusterClient.id !== 'number' ||
|
|
112
|
+
typeof this.clusterClient.info !== 'object' ||
|
|
113
|
+
!Array.isArray(this.clusterClient.info.SHARD_LIST) ||
|
|
114
|
+
typeof this.clusterClient.send !== 'function'
|
|
115
|
+
)
|
|
116
|
+
throw new SyntaxError(
|
|
117
|
+
'clusterClient must be provided with a valid clusterId, send function and info.SHARD_LISt',
|
|
118
|
+
);
|
|
119
|
+
if (typeof this.options.sendDataIntervalMS !== 'number' || this.options.sendDataIntervalMS < 1000)
|
|
120
|
+
throw new SyntaxError('CLIENT_AutoResharderOptions.sendDataIntervalMS must be a number >= 1000');
|
|
121
|
+
if (typeof this.options.sendDataFunction !== 'function')
|
|
122
|
+
throw new SyntaxError(
|
|
123
|
+
'CLIENT_AutoResharderOptions.sendDataFunction must be a function to return the sendData: { clusterId: number, shardData: { shardId: number; guildCount; number }[] }',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Stops the Function and interval
|
|
129
|
+
* @returns
|
|
130
|
+
*/
|
|
131
|
+
public stop() {
|
|
132
|
+
// clear the interval just to be sure
|
|
133
|
+
if (this.interval) {
|
|
134
|
+
clearInterval(this.interval);
|
|
135
|
+
this.interval = null;
|
|
136
|
+
}
|
|
137
|
+
if (this.started === false) throw new Error('Not running!');
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Start it manually after you stopped it (on initialization it automatically starts the function)
|
|
143
|
+
* @param newOptions
|
|
144
|
+
* @param executeSendData Wether it should send the data immediately or as normal: after the interval is reached.
|
|
145
|
+
* @returns
|
|
146
|
+
*/
|
|
147
|
+
public start(newOptions?: Partial<AutoResharderClusterClientOptions>, executeSendData = false) {
|
|
148
|
+
if (this.started === true) throw new Error('Already started');
|
|
149
|
+
|
|
150
|
+
// overide the options
|
|
151
|
+
this.options = {
|
|
152
|
+
...this.options,
|
|
153
|
+
...newOptions,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return this.initialize(executeSendData);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Restart the function and interval, if needed
|
|
161
|
+
* @param newOptions Optinally change the options to your new options
|
|
162
|
+
* @param executeSendData Wether it should send the data immediately or as normal: after the interval is reached.
|
|
163
|
+
* @returns
|
|
164
|
+
*/
|
|
165
|
+
public reStart(newOptions?: Partial<AutoResharderClusterClientOptions>, executeSendData = false) {
|
|
166
|
+
// clear the interval just to be sure
|
|
167
|
+
if (this.interval) {
|
|
168
|
+
clearInterval(this.interval);
|
|
169
|
+
this.interval = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// overide the options
|
|
173
|
+
this.options = {
|
|
174
|
+
...this.options,
|
|
175
|
+
...newOptions,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return this.initialize(executeSendData);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Initializes the interval
|
|
182
|
+
* @param executeSendData Wether it should send the data immediately or as normal: after the interval is reached.
|
|
183
|
+
* @returns
|
|
184
|
+
*/
|
|
185
|
+
private async initialize(executeSendData = false) {
|
|
186
|
+
// if interval exists
|
|
187
|
+
if (this.interval) clearInterval(this.interval);
|
|
188
|
+
|
|
189
|
+
if (executeSendData === true) {
|
|
190
|
+
await this.sendData();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this.interval = setInterval(() => this.sendData(), this.options.sendDataIntervalMS);
|
|
194
|
+
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async sendData() {
|
|
199
|
+
this.validate();
|
|
200
|
+
|
|
201
|
+
const sendData = await this.options.sendDataFunction(this.clusterClient);
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
typeof sendData !== 'object' ||
|
|
205
|
+
typeof sendData.clusterId !== 'number' ||
|
|
206
|
+
sendData.clusterId < 0 ||
|
|
207
|
+
!Array.isArray(sendData.shardData) ||
|
|
208
|
+
sendData.shardData.some(
|
|
209
|
+
v =>
|
|
210
|
+
typeof v.guildCount !== 'number' ||
|
|
211
|
+
v.guildCount < 0 ||
|
|
212
|
+
typeof v.shardId !== 'number' ||
|
|
213
|
+
v.shardId < 0,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
throw new SyntaxError(
|
|
217
|
+
'Invalid sendData, must be like this: { clusterId: number, shardData: { shardId: number; guildCount; number }[] }',
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
if (this.options.debug === true)
|
|
221
|
+
console.debug(` CLIENT-AUTORESHARDER :: Sending Data for Cluster #${sendData.clusterId}`);
|
|
222
|
+
|
|
223
|
+
return await this.clusterClient.send({
|
|
224
|
+
_type: messageType.CLIENT_AUTORESHARDER_SENDDATA,
|
|
225
|
+
data: sendData,
|
|
226
|
+
} as sendDataMessage);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export class AutoResharderManager {
|
|
231
|
+
public name: string = 'autoresharder';
|
|
232
|
+
private manager?: ClusterManager;
|
|
233
|
+
// private clustersListening = new Set<number>();
|
|
234
|
+
public clusterDatas: AutoResharderSendData[] = [];
|
|
235
|
+
public options: AutoResharderManagerOptions = {
|
|
236
|
+
ShardsPerCluster: 'useManagerOption',
|
|
237
|
+
MinGuildsPerShard: 1500,
|
|
238
|
+
MaxGuildsPerShard: 2400,
|
|
239
|
+
restartOptions: {
|
|
240
|
+
restartMode: 'gracefulSwitch',
|
|
241
|
+
delay: 7e3,
|
|
242
|
+
timeout: -1,
|
|
243
|
+
},
|
|
244
|
+
debug: false,
|
|
245
|
+
};
|
|
246
|
+
public isReClustering = false;
|
|
247
|
+
|
|
248
|
+
constructor(options?: Partial<AutoResharderManagerOptions>) {
|
|
249
|
+
this.name = 'autoresharder';
|
|
250
|
+
this.options = {
|
|
251
|
+
...this.options,
|
|
252
|
+
...options ,
|
|
253
|
+
restartOptions: {
|
|
254
|
+
...this.options.restartOptions,
|
|
255
|
+
...options?.restartOptions,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
build(manager: ClusterManager) {
|
|
260
|
+
// @ts-ignore
|
|
261
|
+
manager[this.name] = this;
|
|
262
|
+
this.manager = manager;
|
|
263
|
+
|
|
264
|
+
this.validate();
|
|
265
|
+
this.initialize();
|
|
266
|
+
|
|
267
|
+
return this;
|
|
268
|
+
}
|
|
269
|
+
public async checkReCluster() {
|
|
270
|
+
if (!this.manager) throw new Error('Manager is missing on AutoResharderManager');
|
|
271
|
+
// check for cross-hosting max cluster amount
|
|
272
|
+
if (this.clusterDatas.length <= this.manager.clusterList.length) {
|
|
273
|
+
if (this.options.debug === true)
|
|
274
|
+
console.debug('MANAGER-AUTORESHARDER :: Not all cluster data(s) reached yet');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (this.isReClustering === true) {
|
|
279
|
+
if (this.options.debug === true) console.debug('MANAGER-AUTORESHARDER :: Already re-sharding');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!arraysAreTheSame(Array.from(Array(this.manager.totalShards).keys()), this.manager.shardList)) {
|
|
283
|
+
throw new RangeError(
|
|
284
|
+
"It seems that you are using 'discord-cross-hosting' or a custom shardList specification. With either of those you can't run this plugin (yet)",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const reachedCluster = this.clusterDatas.find(v =>
|
|
289
|
+
v?.shardData.some(x => x && x.guildCount >= this.options.MaxGuildsPerShard),
|
|
290
|
+
);
|
|
291
|
+
if (reachedCluster) {
|
|
292
|
+
if (typeof this.manager.recluster === 'undefined')
|
|
293
|
+
throw new RangeError('ClusterManager must be extended with the ReCluster Plugin!');
|
|
294
|
+
this.isReClustering = true;
|
|
295
|
+
|
|
296
|
+
const newShardsCount =
|
|
297
|
+
this.options.MinGuildsPerShard === 'auto'
|
|
298
|
+
? await fetchRecommendedShards(this.manager.token as string)
|
|
299
|
+
: Math.ceil(
|
|
300
|
+
this.clusterDatas
|
|
301
|
+
.flatMap(v => v?.shardData)
|
|
302
|
+
.filter(d => d !== undefined)
|
|
303
|
+
.reduce((a, b) => (!isNaN(b?.guildCount) ? b?.guildCount : 0) + (a || 0), 0) /
|
|
304
|
+
this.options.MinGuildsPerShard,
|
|
305
|
+
);
|
|
306
|
+
const realShardCount =
|
|
307
|
+
newShardsCount > this.manager.totalShards ? newShardsCount : Math.ceil(this.manager.totalShards * 1.2);
|
|
308
|
+
if (this.options.debug)
|
|
309
|
+
console.debug(
|
|
310
|
+
`MANAGER-AUTORESHARDER :: Reclustering from [${
|
|
311
|
+
this.manager.totalShards
|
|
312
|
+
} Shards] to [${realShardCount} Shards]`,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const finalShardsPerCluster =
|
|
316
|
+
this.options.ShardsPerCluster === 'useManagerOption'
|
|
317
|
+
? this.manager.shardsPerClusters ||
|
|
318
|
+
Math.ceil(this.manager.shardList.length / this.manager.totalClusters)
|
|
319
|
+
: this.options.ShardsPerCluster;
|
|
320
|
+
|
|
321
|
+
const data = await this.manager.recluster.start({
|
|
322
|
+
...this.options.restartOptions,
|
|
323
|
+
shardsPerClusters: finalShardsPerCluster,
|
|
324
|
+
totalShards: realShardCount,
|
|
325
|
+
totalClusters: Math.ceil(realShardCount / finalShardsPerCluster),
|
|
326
|
+
shardList: Array.from(Array(realShardCount).keys()),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
this.isReClustering = false;
|
|
330
|
+
if (this.options.debug === true)
|
|
331
|
+
console.debug(
|
|
332
|
+
`MANAGER-AUTORESHARDER :: Finished Autoresharding`,
|
|
333
|
+
data,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
public getData(clusterData: AutoResharderSendData) {
|
|
338
|
+
const index = this.clusterDatas.findIndex(v => v && v.clusterId === clusterData.clusterId);
|
|
339
|
+
if (index < 0) this.clusterDatas.push(clusterData);
|
|
340
|
+
else this.clusterDatas[index] = clusterData;
|
|
341
|
+
|
|
342
|
+
if (this.options.debug === true)
|
|
343
|
+
console.debug(
|
|
344
|
+
`MANAGER-AUTORESHARDER :: Reached sendData of Cluster #${clusterData.clusterId}`,
|
|
345
|
+
);
|
|
346
|
+
this.checkReCluster();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
private initialize() {
|
|
350
|
+
if (!this.manager) throw new Error('Manager is missing on AutoResharderManager');
|
|
351
|
+
}
|
|
352
|
+
private validate() {
|
|
353
|
+
if (!this.manager) throw new Error('Manager is missing on AutoResharderManager');
|
|
354
|
+
if (typeof this.options.ShardsPerCluster === 'string' && this.options.ShardsPerCluster !== 'useManagerOption')
|
|
355
|
+
throw new SyntaxError("AutoResharderManagerOptions.ShardsPerCluster must be 'useManagerOption' or a number >= 1",);
|
|
356
|
+
else if (this.options.ShardsPerCluster !== 'useManagerOption' && (typeof this.options.ShardsPerCluster !== 'number' || this.options.ShardsPerCluster < 1))
|
|
357
|
+
throw new SyntaxError("AutoResharderManagerOptions.ShardsPerCluster must be 'useManagerOption' or a number >= 1",);
|
|
358
|
+
if (typeof this.options.MinGuildsPerShard === 'string' && this.options.MinGuildsPerShard !== 'auto')
|
|
359
|
+
throw new SyntaxError("AutoResharderManagerOptions.MinGuildsPerShard must be 'auto' or a number >= 500");
|
|
360
|
+
else if (this.options.MinGuildsPerShard !== 'auto' && (typeof this.options.MinGuildsPerShard !== 'number' || this.options.MinGuildsPerShard < 500))
|
|
361
|
+
throw new SyntaxError("AutoResharderManagerOptions.MinGuildsPerShard must be 'auto' or a number >= 500");
|
|
362
|
+
if (
|
|
363
|
+
typeof this.options.MaxGuildsPerShard !== 'number' ||
|
|
364
|
+
(typeof this.options.MinGuildsPerShard === 'number' &&
|
|
365
|
+
this.options.MaxGuildsPerShard <= this.options.MinGuildsPerShard) ||
|
|
366
|
+
this.options.MaxGuildsPerShard > 2500
|
|
367
|
+
)
|
|
368
|
+
throw new SyntaxError(
|
|
369
|
+
'AutoResharderManagerOptions.MinGuildsPerShard must be higher (if not auto) than AutoResharderManagerOptions.MaxGuildsPerShard and lower than 2500',
|
|
370
|
+
);
|
|
371
|
+
if (typeof this.manager.recluster === 'undefined')
|
|
372
|
+
throw new RangeError('ClusterManager must be extended with the ReCluster Plugin!');
|
|
373
|
+
if (
|
|
374
|
+
typeof this.options.MinGuildsPerShard === 'string' &&
|
|
375
|
+
this.options.MinGuildsPerShard === 'auto' &&
|
|
376
|
+
this.options.MaxGuildsPerShard <= 2000
|
|
377
|
+
)
|
|
378
|
+
throw new RangeError(
|
|
379
|
+
"If AutoResharderManagerOptions.MinGuildsPerShard is set to 'auto' than AutoResharderManagerOptions.MaxGuildsPerShard must be a number > 2000",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ClusterManager } from '../Core/ClusterManager.js';
|
|
2
|
+
import { RedisClient } from '../Util/RedisClient.js';
|
|
3
|
+
|
|
4
|
+
export interface HeartbeatOptions {
|
|
5
|
+
interval?: number;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class HeartbeatManager {
|
|
10
|
+
public manager?: ClusterManager;
|
|
11
|
+
public redis: RedisClient;
|
|
12
|
+
public options: Required<HeartbeatOptions>;
|
|
13
|
+
private checkInterval?: any;
|
|
14
|
+
|
|
15
|
+
constructor(options: HeartbeatOptions = {}) {
|
|
16
|
+
this.options = {
|
|
17
|
+
interval: options.interval || 10000,
|
|
18
|
+
timeout: options.timeout || 30000
|
|
19
|
+
};
|
|
20
|
+
this.redis = new RedisClient();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public build(manager: ClusterManager) {
|
|
24
|
+
this.manager = manager;
|
|
25
|
+
// @ts-ignore
|
|
26
|
+
this.manager.heartbeat = this;
|
|
27
|
+
this.start();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public async start() {
|
|
31
|
+
await this.redis.connect().catch(() => {});
|
|
32
|
+
this.checkInterval = setInterval(() => this.checkClusters(), this.options.interval);
|
|
33
|
+
this.manager?._debug("[Heartbeat] Redis-backed monitoring started");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private async checkClusters() {
|
|
37
|
+
if (!this.manager) return;
|
|
38
|
+
try {
|
|
39
|
+
for (const [id, cluster] of this.manager.clusters) {
|
|
40
|
+
if (!cluster.thread) continue;
|
|
41
|
+
|
|
42
|
+
const lastHeartbeat = await this.redis.get(`hb:cluster:${id}`);
|
|
43
|
+
if (!lastHeartbeat && cluster.ready) {
|
|
44
|
+
this.manager._debug(`[Heartbeat] Cluster ${id} missed heartbeat, respawning...`);
|
|
45
|
+
cluster.respawn();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
this.manager._debug(`[Heartbeat] Error checking clusters: ${(error as Error).message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public stop() {
|
|
54
|
+
if (this.checkInterval) clearInterval(this.checkInterval);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ClusterManager } from "../Core/ClusterManager.js";
|
|
2
|
+
import { Plugin } from "../types/shared.js";
|
|
3
|
+
|
|
4
|
+
export interface QueueManagerOptions {
|
|
5
|
+
maxParallelSpawns?: number;
|
|
6
|
+
spawnDelay?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class QueueManager implements Plugin {
|
|
10
|
+
public manager?: ClusterManager;
|
|
11
|
+
public options: QueueManagerOptions;
|
|
12
|
+
private queue: any[] = [];
|
|
13
|
+
private processing = false;
|
|
14
|
+
|
|
15
|
+
constructor(options: QueueManagerOptions = {}) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public build(manager: ClusterManager) {
|
|
20
|
+
this.manager = manager;
|
|
21
|
+
// @ts-ignore
|
|
22
|
+
this.manager.queueManager = this;
|
|
23
|
+
this.manager._debug("[QueueManager] Plugin initialized");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public add(task: () => Promise<any>) {
|
|
27
|
+
this.queue.push(task);
|
|
28
|
+
this.process();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async process() {
|
|
32
|
+
if (this.processing || this.queue.length === 0) return;
|
|
33
|
+
this.processing = true;
|
|
34
|
+
|
|
35
|
+
while (this.queue.length > 0) {
|
|
36
|
+
const task = this.queue.shift();
|
|
37
|
+
try {
|
|
38
|
+
await task();
|
|
39
|
+
} catch (err) {
|
|
40
|
+
this.manager?._debug(`[QueueManager] Task failed: ${err}`);
|
|
41
|
+
}
|
|
42
|
+
if (this.options.spawnDelay) {
|
|
43
|
+
await new Promise(resolve => setTimeout(resolve, this.options.spawnDelay));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.processing = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Cluster } from "../Core/Cluster.js";
|
|
2
|
+
import { ClusterManager } from "../Core/ClusterManager.js";
|
|
3
|
+
import { chunkArray, fetchRecommendedShards } from "../Util/Util.js";
|
|
4
|
+
|
|
5
|
+
export type ReClusterRestartMode = 'rolling';
|
|
6
|
+
|
|
7
|
+
export interface ReClusterOptions {
|
|
8
|
+
delay?: number;
|
|
9
|
+
timeout?: number;
|
|
10
|
+
totalShards?: number | 'auto';
|
|
11
|
+
totalClusters?: number;
|
|
12
|
+
shardsPerClusters?: number;
|
|
13
|
+
shardList?: number[];
|
|
14
|
+
shardClusterList?: number[][];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ReClusterManager {
|
|
18
|
+
options: ReClusterOptions;
|
|
19
|
+
onProgress: boolean = false;
|
|
20
|
+
manager?: ClusterManager;
|
|
21
|
+
|
|
22
|
+
constructor(options: ReClusterOptions = {}) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
build(manager: ClusterManager) {
|
|
27
|
+
// @ts-ignore
|
|
28
|
+
manager.recluster = this;
|
|
29
|
+
this.manager = manager;
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public async start(options?: ReClusterOptions) {
|
|
34
|
+
const {
|
|
35
|
+
delay = 7000,
|
|
36
|
+
timeout = 300000,
|
|
37
|
+
totalClusters,
|
|
38
|
+
shardsPerClusters,
|
|
39
|
+
shardClusterList,
|
|
40
|
+
shardList = this.manager?.shardList,
|
|
41
|
+
} = options || {};
|
|
42
|
+
let totalShards = options?.totalShards;
|
|
43
|
+
|
|
44
|
+
if (this.onProgress) throw new Error('Zero Downtime Reclustering is already in progress');
|
|
45
|
+
if (!this.manager) throw new Error('Manager is missing on ReClusterManager');
|
|
46
|
+
|
|
47
|
+
if (totalShards) {
|
|
48
|
+
if (totalShards === 'auto' || totalShards === -1) {
|
|
49
|
+
if (!this.manager?.token) throw new Error('Token must be defined on manager for auto totalShards');
|
|
50
|
+
totalShards = await fetchRecommendedShards(this.manager.token);
|
|
51
|
+
}
|
|
52
|
+
this.manager.totalShards = totalShards as number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (totalClusters) this.manager.totalClusters = totalClusters;
|
|
56
|
+
if (shardsPerClusters) {
|
|
57
|
+
this.manager.shardsPerClusters = shardsPerClusters;
|
|
58
|
+
this.manager.totalClusters = Math.ceil(this.manager.totalShards / this.manager.shardsPerClusters);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (shardList) this.manager.shardList = shardList;
|
|
62
|
+
if (shardClusterList) this.manager.shardClusterList = shardClusterList;
|
|
63
|
+
else {
|
|
64
|
+
this.manager.shardClusterList = chunkArray(
|
|
65
|
+
this.manager.shardList,
|
|
66
|
+
Math.ceil(this.manager.shardList.length / this.manager.totalClusters),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.onProgress = true;
|
|
71
|
+
this.manager._debug(`[ReCluster] Starting zero-downtime rolling restart...`);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
for (let i = 0; i < this.manager.totalClusters; i++) {
|
|
75
|
+
const clusterId = this.manager.clusterList[i] || i;
|
|
76
|
+
const newShards = this.manager.shardClusterList[i] as number[];
|
|
77
|
+
|
|
78
|
+
this.manager._debug(`[ReCluster] Spawning replacement for cluster ${clusterId}...`);
|
|
79
|
+
const newCluster = this.manager.createCluster(clusterId, newShards, this.manager.totalShards, true);
|
|
80
|
+
|
|
81
|
+
await newCluster.spawn(timeout);
|
|
82
|
+
|
|
83
|
+
const oldCluster = this.manager.clusters.get(clusterId);
|
|
84
|
+
if (oldCluster) {
|
|
85
|
+
oldCluster.kill({ force: true, reason: 'rolling restart replacement' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.manager.clusters.set(clusterId, newCluster);
|
|
89
|
+
this.manager._debug(`[ReCluster] Cluster ${clusterId} replaced successfully`);
|
|
90
|
+
|
|
91
|
+
if (delay > 0 && i < this.manager.totalClusters - 1) {
|
|
92
|
+
await new Promise(r => globalThis.setTimeout(r, delay));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
this.onProgress = false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { success: true };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export interface ChildProcessOptions {
|
|
2
|
+
clusterData: Record<string, any> | undefined;
|
|
3
|
+
args?: string[] | undefined;
|
|
4
|
+
env?: Record<string, string> | undefined;
|
|
5
|
+
cwd?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class Child {
|
|
9
|
+
file: string;
|
|
10
|
+
process: import("bun").Subprocess | null;
|
|
11
|
+
options: ChildProcessOptions;
|
|
12
|
+
args: string[];
|
|
13
|
+
|
|
14
|
+
constructor(file: string, options: ChildProcessOptions) {
|
|
15
|
+
this.file = file;
|
|
16
|
+
this.process = null;
|
|
17
|
+
this.options = options;
|
|
18
|
+
this.args = options.args || [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public spawn() {
|
|
22
|
+
const env = {
|
|
23
|
+
...process.env,
|
|
24
|
+
...this.options.env,
|
|
25
|
+
...(this.options.clusterData as any)
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Convert all env values to string for Bun.spawn
|
|
29
|
+
const stringEnv: Record<string, string> = {};
|
|
30
|
+
for (const [key, value] of Object.entries(env)) {
|
|
31
|
+
stringEnv[key] = String(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.process = Bun.spawn({
|
|
35
|
+
cmd: ["bun", "run", this.file, ...this.args],
|
|
36
|
+
env: stringEnv,
|
|
37
|
+
cwd: this.options.cwd,
|
|
38
|
+
stdout: "inherit",
|
|
39
|
+
stderr: "inherit",
|
|
40
|
+
ipc: (message) => {
|
|
41
|
+
this._onMessage(message);
|
|
42
|
+
},
|
|
43
|
+
onExit: (proc, exitCode, signalCode) => {
|
|
44
|
+
this._onExit(exitCode ?? 0);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private _messageListeners: ((message: any) => void)[] = [];
|
|
52
|
+
private _exitListeners: ((exitCode: number) => void)[] = [];
|
|
53
|
+
private _errorListeners: ((error: Error) => void)[] = [];
|
|
54
|
+
|
|
55
|
+
public on(event: 'message' | 'exit' | 'error', listener: (...args: any[]) => void) {
|
|
56
|
+
if (event === 'message') this._messageListeners.push(listener);
|
|
57
|
+
if (event === 'exit') this._exitListeners.push(listener);
|
|
58
|
+
if (event === 'error') this._errorListeners.push(listener);
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private _onMessage(message: any) {
|
|
63
|
+
for (const listener of this._messageListeners) listener(message);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private _onExit(exitCode: number) {
|
|
67
|
+
for (const listener of this._exitListeners) listener(exitCode);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public kill() {
|
|
71
|
+
this.process?.kill();
|
|
72
|
+
this.process = null;
|
|
73
|
+
this._messageListeners = [];
|
|
74
|
+
this._exitListeners = [];
|
|
75
|
+
this._errorListeners = [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public send(message: any) {
|
|
79
|
+
return new Promise<void>((resolve, reject) => {
|
|
80
|
+
try {
|
|
81
|
+
this.process?.send(message);
|
|
82
|
+
resolve();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
reject(err);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public get killed() {
|
|
90
|
+
return !this.process || this.process.killed;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class ChildClient {
|
|
95
|
+
public send(message: any) {
|
|
96
|
+
return new Promise<void>((resolve, reject) => {
|
|
97
|
+
try {
|
|
98
|
+
process.send?.(message);
|
|
99
|
+
resolve();
|
|
100
|
+
} catch (err) {
|
|
101
|
+
reject(err);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public getData() {
|
|
107
|
+
return process.env;
|
|
108
|
+
}
|
|
109
|
+
}
|