discord-hybrid-sharding
Version:
The first package which combines sharding manager & internal sharding to save a lot of resources, which allows clustering!
463 lines (462 loc) • 20.1 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ClusterManager = void 0;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const os_1 = __importDefault(require("os"));
const events_1 = __importDefault(require("events"));
const Util_1 = require("../Util/Util");
const Queue_1 = require("../Structures/Queue");
const Cluster_1 = require("./Cluster");
const PromiseHandler_1 = require("../Structures/PromiseHandler");
const ManagerHooks_1 = require("../Structures/ManagerHooks");
class ClusterManager extends events_1.default {
/**
* Whether clusters should automatically respawn upon exiting
*/
respawn;
/**
* How many times a cluster can maximally restart in the given interval
*/
restarts;
/**
* Data, which is passed to the workerData or the processEnv
*/
clusterData;
/**
* Options, which is passed when forking a child or creating a thread
*/
clusterOptions;
/**
* Path to the bot script file
*/
file;
/**
* Amount of internal shards in total
*/
totalShards;
/**
* Amount of total clusters to spawn
*/
totalClusters;
/**
* Amount of Shards per Clusters
*/
shardsPerClusters;
/** Mode for Clusters to spawn with */
mode;
/**
* An array of arguments to pass to clusters (only when {@link ClusterManager#mode} is `process`)
*/
shardArgs;
/**
* An array of arguments to pass to the executable (only when {@link ClusterManager#mode} is `process`)
*/
execArgv;
/**
* List of internal shard ids this cluster manager spawns
*/
shardList;
/**
* Token to use for obtaining the automatic internal shards count, and passing to bot script
*/
token;
/**
* A collection of all clusters the manager spawned
*/
clusters;
shardClusterList;
/**
* An Array of IDS[Number], which should be assigned to the spawned Clusters
*/
clusterList;
spawnOptions;
queue;
promise;
/** HeartbeatManager Plugin */
heartbeat;
/** Reclustering Plugin */
recluster;
/** Containing some useful hook funtions */
hooks;
constructor(file, options) {
super();
if (!options)
options = {};
if (options.keepAlive)
throw new Error('keepAlive is not supported anymore on and above v1.6.0. Import it as plugin ("HeartbeatManager"), therefore check the libs readme');
this.respawn = options.respawn ?? true;
this.restarts = options.restarts || { max: 3, interval: 60000 * 60, current: 0 };
this.clusterData = options.clusterData || {};
this.clusterOptions = options.clusterOptions || {};
this.file = file;
if (!file)
throw new Error('CLIENT_INVALID_OPTION | No File specified.');
if (!path_1.default.isAbsolute(file))
this.file = path_1.default.resolve(process.cwd(), file);
const stats = fs_1.default.statSync(this.file);
if (!stats.isFile())
throw new Error('CLIENT_INVALID_OPTION | Provided is file is not type of file');
this.totalShards = options.totalShards === 'auto' ? -1 : (options.totalShards ?? -1);
if (this.totalShards !== -1) {
if (typeof this.totalShards !== 'number' || isNaN(this.totalShards)) {
throw new TypeError('CLIENT_INVALID_OPTION | Amount of internal shards must be a number.');
}
if (this.totalShards < 1)
throw new RangeError('CLIENT_INVALID_OPTION | Amount of internal shards must be at least 1.');
if (!Number.isInteger(this.totalShards)) {
throw new RangeError('CLIENT_INVALID_OPTION | Amount of internal shards must be an integer.');
}
}
this.totalClusters = options.totalClusters === 'auto' ? -1 : (options.totalClusters ?? -1);
if (this.totalClusters !== -1) {
if (typeof this.totalClusters !== 'number' || isNaN(this.totalClusters)) {
throw new TypeError('CLIENT_INVALID_OPTION | Amount of Clusters must be a number.');
}
if (this.totalClusters < 1)
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Clusters must be at least 1.');
if (!Number.isInteger(this.totalClusters)) {
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Clusters must be an integer.');
}
}
this.shardsPerClusters = options.shardsPerClusters;
if (this.shardsPerClusters) {
if (typeof this.shardsPerClusters !== 'number' || isNaN(this.shardsPerClusters)) {
throw new TypeError('CLIENT_INVALID_OPTION | Amount of ShardsPerClusters must be a number.');
}
if (this.shardsPerClusters < 1)
throw new RangeError('CLIENT_INVALID_OPTION | Amount of shardsPerClusters must be at least 1.');
if (!Number.isInteger(this.shardsPerClusters)) {
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Shards Per Clusters must be an integer.');
}
}
this.mode = options.mode || 'process';
if (this.mode !== 'worker' && this.mode !== 'process') {
throw new RangeError('CLIENT_INVALID_OPTION' + 'Cluster mode must be ' + '"worker" or "process"');
}
this.shardArgs = options.shardArgs ?? [];
this.execArgv = options.execArgv ?? [];
this.shardList = options.shardList ?? [];
if (this.shardList.length) {
if (!Array.isArray(this.shardList)) {
throw new TypeError('CLIENT_INVALID_OPTION | shardList must be an array.');
}
this.shardList = Array.from(new Set(this.shardList));
if (this.shardList.length < 1)
throw new RangeError('CLIENT_INVALID_OPTION | shardList must contain at least 1 ID.');
if (this.shardList.some(shardID => typeof shardID !== 'number' || isNaN(shardID) || !Number.isInteger(shardID) || shardID < 0)) {
throw new TypeError('CLIENT_INVALID_OPTION | shardList has to contain an array of positive integers.');
}
}
if (!options.token)
options.token = process.env.DISCORD_TOKEN;
this.token = options.token ? options.token.replace(/^Bot\s*/i, '') : null;
this.clusters = new Map();
this.shardClusterList = [];
process.env.SHARD_LIST = undefined;
process.env.TOTAL_SHARDS = this.totalShards;
process.env.CLUSTER = undefined;
process.env.CLUSTER_COUNT = this.totalClusters;
process.env.CLUSTER_MANAGER = 'true';
process.env.CLUSTER_MANAGER_MODE = this.mode;
process.env.DISCORD_TOKEN = String(this.token);
process.env.MAINTENANCE = undefined;
if (options.queue?.auto)
process.env.CLUSTER_QUEUE_MODE = 'auto';
else
process.env.CLUSTER_QUEUE_MODE = 'manual';
this.clusterList = options.clusterList || [];
this.spawnOptions = options.spawnOptions || {};
if (!this.spawnOptions.delay)
this.spawnOptions.delay = 7000;
if (!this.spawnOptions.amount)
this.spawnOptions.amount = this.totalShards;
if (!this.spawnOptions.timeout)
this.spawnOptions.timeout = -1;
if (!options.queue)
options.queue = { auto: true };
if (!options.queue.timeout)
options.queue.timeout = this.spawnOptions.delay;
this.queue = new Queue_1.Queue(options.queue);
this._debug(`[START] Cluster Manager has been initialized`);
this.promise = new PromiseHandler_1.PromiseHandler();
this.hooks = new ManagerHooks_1.ClusterManagerHooks();
}
/**
* Spawns multiple internal shards.
*/
async spawn({ amount = this.spawnOptions.amount = this.totalShards, delay = this.spawnOptions.delay = 7000, timeout = this.spawnOptions.timeout = -1 } = this.spawnOptions) {
if (delay < 7000) {
process.emitWarning(`Spawn Delay (delay: ${delay}) is smaller than 7s, this can cause global rate limits on /gateway/bot`, {
code: 'CLUSTER_MANAGER',
});
}
if (amount === -1 || amount === 'auto') {
if (!this.token)
throw new Error('A Token must be provided, when totalShards is set on auto.');
amount = await (0, Util_1.fetchRecommendedShards)(this.token, 1000);
this.totalShards = amount;
this._debug(`Discord recommended a total shard count of ${amount}`);
}
else {
if (typeof amount !== 'number' || isNaN(amount)) {
throw new TypeError('CLIENT_INVALID_OPTION | Amount of Internal Shards must be a number.');
}
if (amount < 1)
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Internal Shards must be at least 1.');
if (!Number.isInteger(amount)) {
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Internal Shards must be an integer.');
}
}
let clusterAmount = this.totalClusters;
if (clusterAmount === -1) {
clusterAmount = os_1.default.cpus().length;
this.totalClusters = clusterAmount;
}
else {
if (typeof clusterAmount !== 'number' || isNaN(clusterAmount)) {
throw new TypeError('CLIENT_INVALID_OPTION | Amount of Clusters must be a number.');
}
if (clusterAmount < 1)
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Clusters must be at least 1.');
if (!Number.isInteger(clusterAmount)) {
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Clusters must be an integer.');
}
}
if (!this.shardList.length)
this.shardList = Array.from(Array(amount).keys());
//Calculate Shards per Cluster:
if (this.shardsPerClusters)
this.totalClusters = Math.ceil(this.shardList.length / this.shardsPerClusters);
this.shardClusterList = (0, Util_1.chunkArray)(this.shardList, !isNaN(this.shardsPerClusters)
? this.shardsPerClusters
: Math.ceil(this.shardList.length / this.totalClusters));
if (this.shardClusterList.length !== this.totalClusters) {
this.totalClusters = this.shardClusterList.length;
}
if (this.shardList.some(shardID => shardID >= Number(amount))) {
throw new RangeError('CLIENT_INVALID_OPTION | Shard IDs must be smaller than the amount of shards.');
}
// Update spawn options
this.spawnOptions = { delay, timeout, amount };
this._debug(`[Spawning Clusters]
ClusterCount: ${this.totalClusters}
ShardCount: ${amount}
ShardList: ${this.shardClusterList.join(', ')}`);
for (let i = 0; i < this.totalClusters; i++) {
const clusterId = this.clusterList[i] || i;
if (this.shardClusterList[i]) {
const length = this.shardClusterList[i]?.length;
const readyTimeout = timeout !== -1 ? timeout + delay * length : timeout;
const spawnDelay = delay * length;
this.queue.add({
run: (...a) => {
const cluster = this.createCluster(clusterId, this.shardClusterList[i], this.totalShards);
return cluster.spawn(...a);
},
args: [readyTimeout],
timeout: spawnDelay,
});
}
}
return this.queue.start();
}
/**
* Sends a message to all clusters.
*/
broadcast(message) {
const promises = [];
for (const cluster of Array.from(this.clusters.values()))
promises.push(cluster.send(message));
return Promise.all(promises);
}
/**
* Creates a single cluster.
* <warn>Using this method is usually not necessary if you use the spawn method.</warn>
* <info>This is usually not necessary to manually specify.</info>
* @returns Note that the created cluster needs to be explicitly spawned using its spawn method.
*/
createCluster(id, shardsToSpawn, totalShards, recluster = false) {
const cluster = new Cluster_1.Cluster(this, id, shardsToSpawn, totalShards);
if (!recluster)
this.clusters.set(id, cluster);
/**
* Emitted upon creating a cluster.
* @event ClusterManager#clusterCreate
* @param {Cluster} cluster Cluster that was created
*/
// @todo clusterReady event
this.emit('clusterCreate', cluster);
this._debug(`[CREATE] Created Cluster ${cluster.id}`);
return cluster;
}
async broadcastEval(script, evalOptions) {
const options = evalOptions ?? {};
if (!script || (typeof script !== 'string' && typeof script !== 'function'))
return Promise.reject(new TypeError('ClUSTERING_INVALID_EVAL_BROADCAST'));
script = typeof script === 'function' ? `(${script})(this, ${JSON.stringify(options.context)})` : script;
if (Object.prototype.hasOwnProperty.call(options, 'cluster')) {
if (typeof options.cluster === 'number') {
if (options.cluster < 0)
throw new RangeError('CLUSTER_ID_OUT_OF_RANGE');
}
if (Array.isArray(options.cluster)) {
if (options.cluster.length === 0)
throw new RangeError('ARRAY_MUST_CONTAIN_ONE CLUSTER_ID');
}
}
if (options.guildId) {
options.shard = (0, Util_1.shardIdForGuildId)(options.guildId, this.totalShards);
}
if (options.shard !== undefined && options.shard !== null) {
if (typeof options.shard === 'number') {
if (options.shard < 0)
throw new RangeError('SHARD_ID_OUT_OF_RANGE');
}
if (Array.isArray(options.shard)) {
// @todo Support Array of Shards
if (options.shard.length === 0)
throw new RangeError('ARRAY_MUST_CONTAIN_ONE SHARD_ID');
}
options.cluster = Array.from(this.clusters.values()).find(c => c.shardList.includes(options.shard))?.id;
}
return this._performOnClusters('eval', [script], options.cluster, options.timeout);
}
/**
* Fetches a client property value of each cluster, or a given cluster.
* @param prop Name of the client property to get, using periods for nesting
* @param cluster Cluster to fetch property from, all if undefined
* @example
* manager.fetchClientValues('guilds.cache.size')
* .then(results => console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`))
* .catch(console.error);
*/
fetchClientValues(prop, cluster) {
return this.broadcastEval(`this.${prop}`, { cluster });
}
/**
* Runs a method with given arguments on all clusters, or a given cluster.
* @param method Method name to run on each cluster
* @param args Arguments to pass through to the method call
* @param cluster cluster to run on, all if undefined
* @param timeout the amount of time to wait until the promise will be rejected
* @returns Results of the method execution
* @private
*/
_performOnClusters(method, args, cluster, timeout) {
if (this.clusters.size === 0)
return Promise.reject(new Error('CLUSTERING_NO_CLUSTERS'));
if (typeof cluster === 'number') {
if (this.clusters.has(cluster))
return (this.clusters
.get(cluster)
// @ts-expect-error
?.
// @ts-expect-error
[method](...args, undefined, timeout)
.then((e) => [e]));
return Promise.reject(new Error('CLUSTERING_CLUSTER_NOT_FOUND FOR ClusterId: ' + cluster));
}
let clusters = Array.from(this.clusters.values());
if (cluster)
clusters = clusters.filter(c => cluster.includes(c.id));
if (clusters.length === 0)
return Promise.reject(new Error('CLUSTERING_NO_CLUSTERS_FOUND'));
/* if (this.clusters.size !== this.totalClusters && !cluster) return Promise.reject(new Error('CLUSTERING_IN_PROCESS')); */
const promises = [];
// @ts-expect-error
for (const cl of clusters)
promises.push(cl[method](...args, undefined, timeout));
return Promise.all(promises);
}
/**
* Kills all running clusters and respawns them.
* @param options Options for respawning shards
*/
async respawnAll({ clusterDelay = (this.spawnOptions.delay = 5500), respawnDelay = (this.spawnOptions.delay = 5500), timeout = -1, } = {}) {
this.promise.nonce.clear();
let s = 0;
let i = 0;
this._debug('Respawning all Clusters');
for (const cluster of Array.from(this.clusters.values())) {
const promises = [cluster.respawn({ delay: respawnDelay, timeout })];
const length = this.shardClusterList[i]?.length || this.totalShards / this.totalClusters;
if (++s < this.clusters.size && clusterDelay > 0)
promises.push((0, Util_1.delayFor)(length * clusterDelay));
i++;
await Promise.all(promises); // eslint-disable-line no-await-in-loop
}
return this.clusters;
}
//Custom Functions:
/**
* Runs a method with given arguments on the Manager itself
*/
async evalOnManager(script) {
script = typeof script === 'function' ? `(${script})(this)` : script;
let result;
let error;
try {
result = await eval(script);
}
catch (err) {
error = err;
}
return { _result: result, _error: error ? (0, Util_1.makePlainError)(error) : null };
}
/**
* Runs a method with given arguments on the provided Cluster Client
* @returns Results of the script execution
* @private
*/
evalOnCluster(script, options) {
return this.broadcastEval(script, options)?.then((r) => r[0]);
}
/**
* Adds a plugin to the cluster manager
*/
extend(...plugins) {
if (!plugins)
throw new Error('NO_PLUGINS_PROVIDED');
if (!Array.isArray(plugins))
plugins = [plugins];
for (const plugin of plugins) {
if (!plugin)
throw new Error('PLUGIN_NOT_PROVIDED');
if (typeof plugin !== 'object')
throw new Error('PLUGIN_NOT_A_OBJECT');
plugin.build(this);
}
}
/**
* @param reason If maintenance should be enabled on all clusters with a given reason or disabled when nonce provided
*/
triggerMaintenance(reason) {
return Array.from(this.clusters.values()).forEach(cluster => cluster.triggerMaintenance(reason));
}
/**
* Logs out the Debug Messages
* <warn>Using this method just emits the Debug Event.</warn>
* <info>This is usually not necessary to manually specify.</info>
*/
_debug(message, cluster) {
let log;
if (cluster === undefined) {
log = `[CM => Manager] ` + message;
}
else {
log = `[CM => Cluster ${cluster}] ` + message;
}
/**
* Emitted upon receiving a message
* @event ClusterManager#debug
* @param {string} Message, which was received
*/
this.emit('debug', log);
return log;
}
}
exports.ClusterManager = ClusterManager;